Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62db8edcdb | |||
| 9ffe33a6f2 | |||
| 3b142e5332 | |||
| 005965d6de | |||
| 4fca0e66cb | |||
| 4d2aebe3ed | |||
| ca1bc5af62 | |||
| e2432caa65 | |||
| 2e7b81fdfe | |||
| ec0cc37bc9 | |||
| 1e2c486356 | |||
| e772938a3b | |||
| 0c2b86e736 | |||
| ce36760819 | |||
| 2d9775244c | |||
| 035b000bb0 | |||
| a59967be20 | |||
| a5e2abb8c4 | |||
| a4f293b7cf | |||
| 3f61079c02 | |||
| a0aea00667 | |||
| c7ee0871a5 | |||
| b9accf6934 | |||
| 222742e368 | |||
| d67c0de596 | |||
| e3957b8eed | |||
| 1379595e82 | |||
| a571282031 | |||
| 47aa178850 | |||
| 8652d7124e | |||
| 3054e8702b | |||
| 179519689b | |||
| e43281d1d0 | |||
| d2a0b7d780 | |||
| 69569a5c2b | |||
| 45c72993f3 | |||
| 7a0437087a | |||
| d12afdad6e | |||
| 33b11a3b3d | |||
| 29fee5aa37 | |||
| 02336967b4 | |||
| 452e29bc59 | |||
| f6ee837be6 | |||
| d27dd69bfc | |||
| b991fde3fe | |||
| 335940cf23 | |||
| 60085b1368 | |||
| fb95a462fb | |||
| 67bacac84b | |||
| e59ccba961 | |||
| e38189470b | |||
| 5511f87b4b | |||
| e5ab471ce1 | |||
| 28b0107925 | |||
| bfad4dddb6 | |||
| d1e276204e | |||
| 2df35e6227 | |||
| 573aa4226d | |||
| 30fc7603d4 | |||
| 86d67f6fc6 | |||
| 8cb9a8b797 | |||
| 7339815ea9 | |||
| 2a18e91c39 | |||
| 507c532f6d | |||
| bf0ef5e0c3 | |||
| 6efb3159d5 | |||
| 8f1bc60757 | |||
| c29d25d1cb | |||
| 2ebe6229b7 | |||
| 6a08d94255 | |||
| 3e7b2582a2 | |||
| baa601765e | |||
| 3db90020d4 | |||
| 7367595e71 | |||
| 158a5d7506 | |||
| f2fa441405 | |||
| a324454efe | |||
| 03c13d21c6 | |||
| 0119eacf14 | |||
| 0504cae27c | |||
| 64db364834 | |||
| 99462185b4 | |||
| 395a6efb26 | |||
| 1a2de0e738 | |||
| 32c8d4f28f | |||
| f732c49645 | |||
| ae5de91006 | |||
| 369ed800b9 | |||
| 7cfaafd360 | |||
| 22bd06a578 | |||
| efa52484d4 | |||
| 3e52ff7fa3 | |||
| 89ded7b46c | |||
| bb9e979876 | |||
| e282766268 | |||
| 046ee8d576 | |||
| b5c5dbbe76 | |||
| 872b0de304 | |||
| 89d8b4b5a0 | |||
| 6b7e4f1dee | |||
| c2168afbf0 | |||
| 7fac264c73 | |||
| 46c8edfa82 | |||
| 230da7bdcb | |||
| 5cd342d521 | |||
| b4801c2dd3 | |||
| 90d8aae51a | |||
| 15063ca0b4 | |||
| f7dfeff256 | |||
| 84fc6b8d1b | |||
| ee7f0cdb42 | |||
| 3ffa5608d8 | |||
| a5500aeeff | |||
| 170614baf1 | |||
| b466b6494a | |||
| 9acad5461e | |||
| 1f0f7eee1a | |||
| 0d009b34e2 | |||
| 5fc04f14b7 | |||
| a09b636e1a | |||
| 356f8f5d6c | |||
| 96aa2407d9 | |||
| e856172d60 | |||
| ab62a3d118 | |||
| a3c0aed63e | |||
| 1ddde12959 |
@@ -1,9 +1,13 @@
|
|||||||
name: flutter
|
name: flutter
|
||||||
|
|
||||||
# Analyze + test + build the Flutter mobile client. Only runs when the
|
# Analyze + test the Flutter mobile client; build an APK only where
|
||||||
# diff touches flutter_client/ or one of the shared web inputs the
|
# it's actually consumed. dev pushes: analyze+test+codegen only
|
||||||
# sync_shared.sh script copies. Other pushes skip — this workflow is
|
# (~3min) — the operator dev-tests via a local Android Studio build,
|
||||||
# independent from the Go/web test.yml and release.yml.
|
# not the CI artifact. main pushes: also build a debug APK as the
|
||||||
|
# native-build safety net (Gradle/manifest/plugin breakage that
|
||||||
|
# analyze+test can't catch) before any release tag. tags: signed
|
||||||
|
# release APK. Only runs when the diff touches flutter_client/ or a
|
||||||
|
# shared web input sync_shared.sh copies.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -69,7 +73,9 @@ jobs:
|
|||||||
run: flutter test --reporter compact
|
run: flutter test --reporter compact
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
# main-only: native-build safety net before release tags.
|
||||||
|
# dev skips this (~7min) — operator dev-tests locally.
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
run: flutter build apk --debug
|
run: flutter build apk --debug
|
||||||
|
|
||||||
- name: Decode signing keystore
|
- name: Decode signing keystore
|
||||||
@@ -112,7 +118,7 @@ jobs:
|
|||||||
flutter build apk --release --build-name="${TAG}"
|
flutter build apk --release --build-name="${TAG}"
|
||||||
|
|
||||||
- name: Upload debug APK artifact
|
- name: Upload debug APK artifact
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
uses: forgejo/upload-artifact@v3
|
uses: forgejo/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: minstrel-debug-${{ github.sha }}
|
name: minstrel-debug-${{ github.sha }}
|
||||||
|
|||||||
+41
-9
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"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/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -103,8 +105,8 @@ func run() error {
|
|||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
library.RunScanConfig{
|
library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -120,8 +122,8 @@ func run() error {
|
|||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
library.RunScanConfig{
|
library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -146,9 +148,38 @@ func run() error {
|
|||||||
// import requests and reconciles them against the library. Short-circuits
|
// import requests and reconciles them against the library. Short-circuits
|
||||||
// to no-op when lidarr_config.enabled = false.
|
// to no-op when lidarr_config.enabled = false.
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"))
|
// Live-event bus shared between SSE subscribers (api.Mount) and
|
||||||
|
// background workers that publish (reconciler today; scanner later).
|
||||||
|
// Constructed before any service that publishes so they all share the
|
||||||
|
// same instance.
|
||||||
|
bus := eventbus.New()
|
||||||
|
// Scan-run lifecycle events use a package-level setter rather than
|
||||||
|
// threading the bus through RunScan + TryStartScan + the Scheduler
|
||||||
|
// + every test caller. Per-process singleton, set once at startup.
|
||||||
|
library.SetEventBus(bus)
|
||||||
|
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus)
|
||||||
go lidarrReconciler.Run(ctx)
|
go lidarrReconciler.Run(ctx)
|
||||||
|
|
||||||
|
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||||
|
// rows older than the configured retention so the change-log table
|
||||||
|
// doesn't grow unbounded. Clients that drop offline longer than
|
||||||
|
// retention hit the /api/library/sync 410 fallback and resync.
|
||||||
|
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
|
||||||
|
go libraryChangesCompactor.Run(ctx)
|
||||||
|
|
||||||
|
// Per-user system-playlist scheduler (#392 Half B). Fires each
|
||||||
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
|
// of this arc).
|
||||||
|
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||||
|
}
|
||||||
|
if err := playlistScheduler.Start(ctx); err != nil {
|
||||||
|
return fmt.Errorf("start playlist scheduler: %w", err)
|
||||||
|
}
|
||||||
|
defer playlistScheduler.Stop()
|
||||||
|
|
||||||
// Ensure DataDir exists before any service tries to write into it.
|
// Ensure DataDir exists before any service tries to write into it.
|
||||||
// Fatal on failure: a non-writable data_dir silently breaks every
|
// Fatal on failure: a non-writable data_dir silently breaks every
|
||||||
// downstream cache (playlist covers, artist art, album-cover fallback)
|
// downstream cache (playlist covers, artist art, album-cover fallback)
|
||||||
@@ -163,8 +194,8 @@ func run() error {
|
|||||||
|
|
||||||
scanCfg := library.RunScanConfig{
|
scanCfg := library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
}
|
}
|
||||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||||
@@ -172,8 +203,9 @@ func run() error {
|
|||||||
scheduler.Start(ctx)
|
scheduler.Start(ctx)
|
||||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||||
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir)
|
srv.Bus = bus
|
||||||
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../shared/live_events_provider.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'admin_providers.dart';
|
import 'admin_providers.dart';
|
||||||
@@ -12,6 +13,15 @@ class AdminQuarantineScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// #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);
|
final items = ref.watch(adminQuarantineProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../models/admin_user.dart';
|
import '../models/admin_user.dart';
|
||||||
|
import '../shared/live_events_provider.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'admin_providers.dart';
|
import 'admin_providers.dart';
|
||||||
@@ -13,6 +14,15 @@ class AdminRequestsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// #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);
|
final requests = ref.watch(adminRequestsProvider);
|
||||||
// Best-effort lookup for requester usernames. If the users provider
|
// Best-effort lookup for requester usernames. If the users provider
|
||||||
// hasn't loaded yet, valueOrNull is null and rows fall back to the
|
// hasn't loaded yet, valueOrNull is null and rows fall back to the
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Thin client for POST /api/events — the play-event lifecycle the
|
||||||
|
/// server uses for history, recommendation scoring, ListenBrainz
|
||||||
|
/// scrobbles, and (since #415) system-playlist rotation.
|
||||||
|
///
|
||||||
|
/// Mirrors the web events dispatcher's three calls. Best-effort by
|
||||||
|
/// contract: callers swallow errors — a missed event is acceptable
|
||||||
|
/// per the server spec's v1 stance, and the server's
|
||||||
|
/// auto-close-prior-open keeps history sane even if an ended/skipped
|
||||||
|
/// is lost.
|
||||||
|
class EventsApi {
|
||||||
|
EventsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// POST play_started. Returns the server's play_event_id (used to
|
||||||
|
/// close the row later), or null if the call failed / response was
|
||||||
|
/// malformed. `source` tags the originating system playlist
|
||||||
|
/// ('for_you' | 'discover') so the server advances that rotation;
|
||||||
|
/// omit for library / user-playlist / radio plays.
|
||||||
|
Future<String?> playStarted({
|
||||||
|
required String trackId,
|
||||||
|
required String clientId,
|
||||||
|
String? source,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_started',
|
||||||
|
'track_id': trackId,
|
||||||
|
'client_id': clientId,
|
||||||
|
if (source != null && source.isNotEmpty) 'source': source,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (r.data ?? const {})['play_event_id'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playEnded({
|
||||||
|
required String playEventId,
|
||||||
|
required int durationPlayedMs,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_ended',
|
||||||
|
'play_event_id': playEventId,
|
||||||
|
'duration_played_ms': durationPlayedMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playSkipped({
|
||||||
|
required String playEventId,
|
||||||
|
required int positionMs,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_skipped',
|
||||||
|
'play_event_id': playEventId,
|
||||||
|
'position_ms': positionMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a complete play that happened offline / on a flaky
|
||||||
|
/// connection (#426 part B). One call: the server records start+end
|
||||||
|
/// from `atIso` (the original play-start time) + durationPlayedMs,
|
||||||
|
/// applies the canonical skip rule, and advances #415 rotation when
|
||||||
|
/// source is a system playlist. Driven by the offline mutation
|
||||||
|
/// queue, never the live path.
|
||||||
|
Future<void> playOffline({
|
||||||
|
required String trackId,
|
||||||
|
required String clientId,
|
||||||
|
required String atIso,
|
||||||
|
required int durationPlayedMs,
|
||||||
|
String? source,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_offline',
|
||||||
|
'track_id': trackId,
|
||||||
|
'client_id': clientId,
|
||||||
|
'at': atIso,
|
||||||
|
'duration_played_ms': durationPlayedMs,
|
||||||
|
if (source != null && source.isNotEmpty) 'source': source,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
import '../../models/home_data.dart';
|
import '../../models/home_data.dart';
|
||||||
|
import '../../models/home_index.dart';
|
||||||
import '../../models/track.dart';
|
import '../../models/track.dart';
|
||||||
|
|
||||||
/// LibraryApi wraps the server's native /api/* library surface.
|
/// LibraryApi wraps the server's native /api/* library surface.
|
||||||
@@ -27,6 +28,24 @@ class LibraryApi {
|
|||||||
return HomeData.fromJson(r.data ?? const {});
|
return HomeData.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/home/index — per-item rendering variant. Returns just IDs
|
||||||
|
/// per section; client hydrates each tile via the per-entity
|
||||||
|
/// endpoints. ~10× smaller than /api/home on populated libraries so
|
||||||
|
/// the cold-visit round-trip is correspondingly short.
|
||||||
|
Future<HomeIndex> getHomeIndex() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
|
||||||
|
return HomeIndex.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
|
||||||
|
/// the HydrationQueue to populate cached_tracks on a per-tile miss
|
||||||
|
/// — the existing endpoint already joins album + artist so the
|
||||||
|
/// response carries everything TrackRef needs.
|
||||||
|
Future<TrackRef> getTrack(String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
|
||||||
|
return TrackRef.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
|
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
|
||||||
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
||||||
/// it cares about, so passing the whole body is correct.
|
/// it cares about, so passing the whole body is correct.
|
||||||
@@ -35,6 +54,24 @@ class LibraryApi {
|
|||||||
return ArtistRef.fromJson(r.data ?? const {});
|
return ArtistRef.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/artists/{id} — full response with the ArtistRef AND the
|
||||||
|
/// embedded album list, both parsed. Single round-trip variant used
|
||||||
|
/// by CacheFiller and other callers that want to populate both
|
||||||
|
/// cached_artists and cached_albums in one shot. The existing
|
||||||
|
/// getArtist / getArtistAlbums keep working for callers that only
|
||||||
|
/// need one half — they hit the same URL but the per-id Riverpod
|
||||||
|
/// caching layer dedupes.
|
||||||
|
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
|
||||||
|
String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||||||
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
|
final artist = ArtistRef.fromJson(body);
|
||||||
|
final albums = ((body['albums'] as List?) ?? const [])
|
||||||
|
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
return (artist: artist, albums: albums);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
||||||
/// that need both the artist and its albums should issue two provider
|
/// that need both the artist and its albums should issue two provider
|
||||||
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
||||||
@@ -48,6 +85,19 @@ class LibraryApi {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
|
||||||
|
/// tracks — the online source for "Shuffle all". Bare JSON array.
|
||||||
|
Future<List<TrackRef>> shuffle({int limit = 100}) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
'/api/library/shuffle',
|
||||||
|
queryParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||||||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||||||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||||||
|
|||||||
@@ -38,4 +38,12 @@ class MeApi {
|
|||||||
);
|
);
|
||||||
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
|
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});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ class PlaylistsApi {
|
|||||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2).
|
||||||
|
/// Same shape as get() but tracks are server-ordered rotation-aware
|
||||||
|
/// (unplayed-this-rotation first; resets when exhausted). {kind} is
|
||||||
|
/// the raw system_variant. Intentionally uncached — varies per play.
|
||||||
|
Future<PlaylistDetail> systemShuffle(String variant) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/playlists/system/$variant/shuffle',
|
||||||
|
);
|
||||||
|
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
||||||
/// playlist detail with the new rows.
|
/// playlist detail with the new rows.
|
||||||
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
||||||
@@ -53,4 +64,17 @@ class PlaylistsApi {
|
|||||||
data: {'track_ids': trackIds},
|
data: {'track_ids': trackIds},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds
|
||||||
|
/// the caller's system playlists and returns the named kind's new
|
||||||
|
/// playlist id, or null when the library is empty. {kind} is the
|
||||||
|
/// raw system_variant — the server routes generically off the
|
||||||
|
/// kind registry, no hyphen mapping.
|
||||||
|
Future<String?> refreshSystem(String variant) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/api/playlists/system/$variant/refresh',
|
||||||
|
data: const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
return (r.data ?? const {})['playlist_id'] as String?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'cache/cache_filler.dart';
|
||||||
import 'cache/metadata_prefetcher.dart';
|
import 'cache/metadata_prefetcher.dart';
|
||||||
|
import 'cache/offline_provider.dart';
|
||||||
|
import 'cache/mutation_queue.dart';
|
||||||
import 'cache/prefetcher.dart';
|
import 'cache/prefetcher.dart';
|
||||||
import 'cache/sync_controller.dart';
|
import 'cache/sync_controller.dart';
|
||||||
|
import 'player/play_events_reporter.dart';
|
||||||
|
import 'shared/live_events_dispatcher.dart';
|
||||||
import 'shared/routing.dart';
|
import 'shared/routing.dart';
|
||||||
import 'theme/theme_data.dart';
|
import 'theme/theme_data.dart';
|
||||||
import 'theme/theme_mode_provider.dart';
|
import 'theme/theme_mode_provider.dart';
|
||||||
@@ -33,6 +38,34 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
|||||||
// each section so subsequent taps are drift hits, not network
|
// each section so subsequent taps are drift hits, not network
|
||||||
// round trips.
|
// round trips.
|
||||||
ref.read(metadataPrefetcherProvider);
|
ref.read(metadataPrefetcherProvider);
|
||||||
|
// Live events (#392): subscribes to /api/events/stream and
|
||||||
|
// invalidates publicly-scoped providers when relevant events
|
||||||
|
// arrive. Also installs an AppLifecycleState observer for
|
||||||
|
// resume-time defensive invalidation.
|
||||||
|
ref.read(liveEventsDispatcherProvider);
|
||||||
|
// Cache filler: background sweeper that walks cached_artists
|
||||||
|
// / cached_albums for missing relations and fetches the
|
||||||
|
// per-entity detail so tapping an artist surfaces albums
|
||||||
|
// immediately (drift hit, no /api/artists/:id round-trip at
|
||||||
|
// tap time). First sweep 10s after launch; every 5 minutes
|
||||||
|
// thereafter. Throttled 200ms between requests so it never
|
||||||
|
// competes with user activity.
|
||||||
|
ref.read(cacheFillerProvider);
|
||||||
|
// Mutation replayer: drains the cached_mutations queue when
|
||||||
|
// connectivity comes back. Controllers (LikesController,
|
||||||
|
// MyQuarantineController, the add-to-playlist + request flows)
|
||||||
|
// enqueue on REST failure so user intent persists across
|
||||||
|
// network loss instead of getting rolled back.
|
||||||
|
ref.read(mutationReplayerProvider);
|
||||||
|
// Play-events reporter (#415): the Flutter client otherwise
|
||||||
|
// reports no plays at all — this feeds history, recommendation
|
||||||
|
// scoring, scrobbles, and system-playlist rotation, and is the
|
||||||
|
// path that carries the `source` tag for #415.
|
||||||
|
ref.read(playEventsReporterProvider);
|
||||||
|
// Offline marker (#427 S1): periodic /healthz reachability
|
||||||
|
// probe → offlineProvider. Read here to start the poller; S4
|
||||||
|
// gates system-playlist play + Shuffle-all on it.
|
||||||
|
ref.read(offlineProvider);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
|
||||||
const _kServerUrl = 'server_url';
|
const _kServerUrl = 'server_url';
|
||||||
const _kSessionToken = 'session_token';
|
const _kSessionToken = 'session_token';
|
||||||
const _kCurrentUser = 'current_user';
|
const _kCurrentUser = 'current_user';
|
||||||
|
const _kTzLastSentAt = 'tz_last_sent_at';
|
||||||
|
const _weeklyMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
||||||
(ref) => const FlutterSecureStorage(),
|
(ref) => const FlutterSecureStorage(),
|
||||||
@@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
_storage = ref.watch(secureStorageProvider);
|
_storage = ref.watch(secureStorageProvider);
|
||||||
final raw = await _storage.read(key: _kCurrentUser);
|
final raw = await _storage.read(key: _kCurrentUser);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
return User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
final user = User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||||
|
// Fire-and-forget timezone send on app start with an existing
|
||||||
|
// session — no-op when last_sent_at is fresh.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
_sendTimezoneIfStale();
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setServerUrl(String url) async {
|
Future<void> setServerUrl(String url) async {
|
||||||
@@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
await _storage.write(key: _kCurrentUser, value: userJson);
|
await _storage.write(key: _kCurrentUser, value: userJson);
|
||||||
ref.invalidate(sessionTokenProvider);
|
ref.invalidate(sessionTokenProvider);
|
||||||
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
|
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
_sendTimezoneIfStale();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearSession() async {
|
Future<void> clearSession() async {
|
||||||
@@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
ref.invalidate(sessionTokenProvider);
|
ref.invalidate(sessionTokenProvider);
|
||||||
state = const AsyncData(null);
|
state = const AsyncData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends the device's current IANA timezone to PUT /api/me/timezone
|
||||||
|
/// when the last successful send was >7 days ago (or never).
|
||||||
|
/// Cadence persists in flutter_secure_storage so it survives app
|
||||||
|
/// restarts. Failures are swallowed: the server keeps its previous
|
||||||
|
/// value (or 'UTC' default) until the next attempt.
|
||||||
|
Future<void> _sendTimezoneIfStale() async {
|
||||||
|
try {
|
||||||
|
final lastStr = await _storage.read(key: _kTzLastSentAt);
|
||||||
|
final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0;
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (nowMs - lastMs < _weeklyMs) return;
|
||||||
|
final tz = await FlutterTimezone.getLocalTimezone();
|
||||||
|
if (tz.isEmpty) return;
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
await MeApi(dio).putTimezone(tz);
|
||||||
|
await _storage.write(key: _kTzLastSentAt, value: nowMs.toString());
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal — server falls back to UTC or last-known value.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authControllerProvider =
|
final authControllerProvider =
|
||||||
|
|||||||
+12
-1
@@ -17,10 +17,21 @@ import '../models/track.dart';
|
|||||||
import 'db.dart';
|
import 'db.dart';
|
||||||
|
|
||||||
extension CachedArtistAdapter on CachedArtist {
|
extension CachedArtistAdapter on CachedArtist {
|
||||||
ArtistRef toRef() => ArtistRef(
|
/// `coverAlbumId` lets the caller pass a representative album id so
|
||||||
|
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
|
||||||
|
/// URL. cached_artists doesn't store this directly — the server
|
||||||
|
/// derives it from the most-recent album at query time — so drift
|
||||||
|
/// readers that want the cover URL join cached_albums and pass the
|
||||||
|
/// first album's id through. Empty string yields empty coverUrl,
|
||||||
|
/// matching the server's behavior for endpoints that don't carry
|
||||||
|
/// the lookup (artist detail, search, raw cached_artists row reads).
|
||||||
|
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
sortName: sortName,
|
sortName: sortName,
|
||||||
|
coverUrl: coverAlbumId.isNotEmpty
|
||||||
|
? '/api/albums/$coverAlbumId/cover'
|
||||||
|
: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
-58
@@ -8,15 +8,20 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import 'db.dart';
|
import 'db.dart';
|
||||||
|
|
||||||
|
/// Per-bucket cache usage (#427 S2). Rolling includes orphan files —
|
||||||
|
/// partials written by LockCachingAudioSource that were never indexed
|
||||||
|
/// (skip-before-fully-buffered) — so the rolling cap actually bounds
|
||||||
|
/// disk.
|
||||||
|
typedef BucketUsage = ({int liked, int rolling});
|
||||||
|
|
||||||
/// Owns the audio cache directory + drift index.
|
/// Owns the audio cache directory + drift index.
|
||||||
/// API:
|
///
|
||||||
/// - isCached(trackId)
|
/// #427 S2: two storage buckets keyed by liked-ness, not by
|
||||||
/// - pathFor(trackId)
|
/// CacheSource. A cached track currently in the user's liked set is
|
||||||
/// - pin(trackId, source) — downloads + indexes
|
/// charged to (and evicted under) the Liked budget; everything else
|
||||||
/// - unpin(trackId)
|
/// is Rolling. This dedup is storage/eviction-only — it never filters
|
||||||
/// - evict(targetBytes) — tiered LRU
|
/// what the offline surfaces can play (S4). `CacheSource` is retained
|
||||||
/// - usageBytes()
|
/// on the API for callers but is now informational.
|
||||||
/// - clearAll()
|
|
||||||
class AudioCacheManager {
|
class AudioCacheManager {
|
||||||
AudioCacheManager({
|
AudioCacheManager({
|
||||||
required AppDb db,
|
required AppDb db,
|
||||||
@@ -37,7 +42,6 @@ class AudioCacheManager {
|
|||||||
return d.path;
|
return d.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the trackId has a complete file on disk.
|
|
||||||
Future<bool> isCached(String trackId) async {
|
Future<bool> isCached(String trackId) async {
|
||||||
final row = await (_db.select(_db.audioCacheIndex)
|
final row = await (_db.select(_db.audioCacheIndex)
|
||||||
..where((t) => t.trackId.equals(trackId)))
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
@@ -46,7 +50,6 @@ class AudioCacheManager {
|
|||||||
return File(row.path).existsSync();
|
return File(row.path).existsSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the local file path if cached, else null.
|
|
||||||
Future<String?> pathFor(String trackId) async {
|
Future<String?> pathFor(String trackId) async {
|
||||||
final row = await (_db.select(_db.audioCacheIndex)
|
final row = await (_db.select(_db.audioCacheIndex)
|
||||||
..where((t) => t.trackId.equals(trackId)))
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
@@ -55,12 +58,14 @@ class AudioCacheManager {
|
|||||||
return File(row.path).existsSync() ? row.path : null;
|
return File(row.path).existsSync() ? row.path : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads the track's stream to disk and indexes it. Idempotent —
|
/// Downloads the track's stream to disk and indexes it. Idempotent.
|
||||||
/// returns the existing path if already cached.
|
/// lastPlayedAt is set now — pinning is play-intent.
|
||||||
Future<String?> pin(String trackId, {required CacheSource source}) async {
|
Future<String?> pin(String trackId, {required CacheSource source}) async {
|
||||||
final existing = await pathFor(trackId);
|
final existing = await pathFor(trackId);
|
||||||
if (existing != null) return existing;
|
if (existing != null) {
|
||||||
|
await touch(trackId);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
final dir = await _tracksDir();
|
final dir = await _tracksDir();
|
||||||
final path = '$dir/$trackId.mp3';
|
final path = '$dir/$trackId.mp3';
|
||||||
final dio = await _dioFactory();
|
final dio = await _dioFactory();
|
||||||
@@ -78,16 +83,15 @@ class AudioCacheManager {
|
|||||||
path: path,
|
path: path,
|
||||||
sizeBytes: size,
|
sizeBytes: size,
|
||||||
source: source,
|
source: source,
|
||||||
|
lastPlayedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a file already on disk in the cache index. Intended for
|
/// Registers a file LockCachingAudioSource wrote itself. Called
|
||||||
/// the streaming path (LockCachingAudioSource) which writes the file
|
/// once a track is fully buffered; it's being played, so stamp
|
||||||
/// itself; we need an index row so eviction can find and delete it
|
/// lastPlayedAt.
|
||||||
/// when usage exceeds the cap. `source` defaults to incidental so
|
|
||||||
/// stream-cached tracks are first to be evicted.
|
|
||||||
Future<void> registerStreamCache(
|
Future<void> registerStreamCache(
|
||||||
String trackId,
|
String trackId,
|
||||||
String path,
|
String path,
|
||||||
@@ -100,11 +104,20 @@ class AudioCacheManager {
|
|||||||
path: path,
|
path: path,
|
||||||
sizeBytes: sizeBytes,
|
sizeBytes: sizeBytes,
|
||||||
source: source,
|
source: source,
|
||||||
|
lastPlayedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a track from the index AND deletes the file.
|
/// Bumps lastPlayedAt for an already-indexed track so the rolling
|
||||||
|
/// LRU + the offline "Recently played" view reflect real plays
|
||||||
|
/// (not just download time). No-op if not indexed.
|
||||||
|
Future<void> touch(String trackId) async {
|
||||||
|
await (_db.update(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
|
.write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> unpin(String trackId) async {
|
Future<void> unpin(String trackId) async {
|
||||||
final row = await (_db.select(_db.audioCacheIndex)
|
final row = await (_db.select(_db.audioCacheIndex)
|
||||||
..where((t) => t.trackId.equals(trackId)))
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
@@ -117,66 +130,150 @@ class AudioCacheManager {
|
|||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total bytes used by the cache. Walks the cache directory directly
|
/// Total bytes on disk (directory walk — authoritative; catches
|
||||||
/// instead of summing the index, because the streaming-as-you-play
|
/// orphan partials the index misses).
|
||||||
/// 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 {
|
Future<int> usageBytes() async {
|
||||||
final dir = Directory(await _tracksDir());
|
final dir = Directory(await _tracksDir());
|
||||||
if (!await dir.exists()) return 0;
|
if (!await dir.exists()) return 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
await for (final entity in dir.list(followLinks: false)) {
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
if (entity is File) {
|
if (e is File) {
|
||||||
try {
|
try {
|
||||||
total += await entity.length();
|
total += await e.length();
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Race against concurrent writes / deletes — just skip.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
/// Bytes per bucket. Indexed rows split by liked-ness; on-disk
|
||||||
/// incidental → autoPrefetch → autoPlaylist → autoLiked.
|
/// files with no index row (orphan partials) count as Rolling so
|
||||||
/// `manual` never evicts via this path; only clearAll() removes them.
|
/// the rolling cap genuinely bounds disk.
|
||||||
Future<void> evict({required int targetBytes}) async {
|
Future<BucketUsage> bucketUsage(Set<String> liked) async {
|
||||||
final used = await usageBytes();
|
final rows = await _db.select(_db.audioCacheIndex).get();
|
||||||
if (used <= targetBytes) return;
|
final indexed = <String>{};
|
||||||
final order = [
|
var likedB = 0;
|
||||||
CacheSource.incidental,
|
var rollingB = 0;
|
||||||
CacheSource.autoPrefetch,
|
for (final r in rows) {
|
||||||
CacheSource.autoPlaylist,
|
indexed.add(r.trackId);
|
||||||
CacheSource.autoLiked,
|
if (liked.contains(r.trackId)) {
|
||||||
];
|
likedB += r.sizeBytes;
|
||||||
int remaining = used - targetBytes;
|
} else {
|
||||||
for (final src in order) {
|
rollingB += r.sizeBytes;
|
||||||
if (remaining <= 0) break;
|
}
|
||||||
final rows = await (_db.select(_db.audioCacheIndex)
|
}
|
||||||
..where((t) => t.source.equalsValue(src))
|
final dir = Directory(await _tracksDir());
|
||||||
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
if (await dir.exists()) {
|
||||||
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
|
if (e is! File) continue;
|
||||||
|
final name = e.uri.pathSegments.last;
|
||||||
|
final id = name.endsWith('.mp3')
|
||||||
|
? name.substring(0, name.length - 4)
|
||||||
|
: name;
|
||||||
|
if (indexed.contains(id)) continue;
|
||||||
|
try {
|
||||||
|
rollingB += await e.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (liked: likedB, rolling: rollingB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces both budgets independently (0 = unlimited).
|
||||||
|
///
|
||||||
|
/// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt
|
||||||
|
/// /cachedAt first), then sweep orphan files oldest-by-mtime, until
|
||||||
|
/// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU
|
||||||
|
/// until ≤ likedCap. Liked is only ever touched by its own (large)
|
||||||
|
/// cap, so normal use never evicts the user's liked library.
|
||||||
|
Future<void> evictBuckets({
|
||||||
|
required int likedCap,
|
||||||
|
required int rollingCap,
|
||||||
|
required Set<String> liked,
|
||||||
|
}) async {
|
||||||
|
final usage = await bucketUsage(liked);
|
||||||
|
|
||||||
|
if (rollingCap > 0 && usage.rolling > rollingCap) {
|
||||||
|
var over = usage.rolling - rollingCap;
|
||||||
|
final rolling = await (_db.select(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.isNotIn(liked.toList()))
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||||
|
(t) => OrderingTerm.asc(t.cachedAt),
|
||||||
|
]))
|
||||||
.get();
|
.get();
|
||||||
for (final row in rows) {
|
for (final r in rolling) {
|
||||||
if (remaining <= 0) break;
|
if (over <= 0) break;
|
||||||
final f = File(row.path);
|
final f = File(r.path);
|
||||||
if (f.existsSync()) await f.delete();
|
if (f.existsSync()) await f.delete();
|
||||||
await (_db.delete(_db.audioCacheIndex)
|
await (_db.delete(_db.audioCacheIndex)
|
||||||
..where((t) => t.trackId.equals(row.trackId)))
|
..where((t) => t.trackId.equals(r.trackId)))
|
||||||
.go();
|
.go();
|
||||||
remaining -= row.sizeBytes;
|
over -= r.sizeBytes;
|
||||||
|
}
|
||||||
|
if (over > 0) await _sweepOrphans(over);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (likedCap > 0 && usage.liked > likedCap) {
|
||||||
|
var over = usage.liked - likedCap;
|
||||||
|
final likedRows = await (_db.select(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.isIn(liked.toList()))
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||||
|
(t) => OrderingTerm.asc(t.cachedAt),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
for (final r in likedRows) {
|
||||||
|
if (over <= 0) break;
|
||||||
|
final f = File(r.path);
|
||||||
|
if (f.existsSync()) await f.delete();
|
||||||
|
await (_db.delete(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.equals(r.trackId)))
|
||||||
|
.go();
|
||||||
|
over -= r.sizeBytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
|
/// Deletes orphan files (on disk, no index row) oldest-mtime-first
|
||||||
/// button — `manual` source rows are removed here but only here.
|
/// until `over` bytes are reclaimed. These are unindexed partials,
|
||||||
|
/// always Rolling, always evict-first.
|
||||||
|
Future<void> _sweepOrphans(int over) async {
|
||||||
|
final indexed = {
|
||||||
|
for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId
|
||||||
|
};
|
||||||
|
final dir = Directory(await _tracksDir());
|
||||||
|
if (!await dir.exists()) return;
|
||||||
|
final orphans = <({File f, int size, DateTime mtime})>[];
|
||||||
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
|
if (e is! File) continue;
|
||||||
|
final name = e.uri.pathSegments.last;
|
||||||
|
final id =
|
||||||
|
name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name;
|
||||||
|
if (indexed.contains(id)) continue;
|
||||||
|
try {
|
||||||
|
final st = e.statSync();
|
||||||
|
orphans.add((f: e, size: st.size, mtime: st.modified));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
orphans.sort((a, b) => a.mtime.compareTo(b.mtime));
|
||||||
|
var remaining = over;
|
||||||
|
for (final o in orphans) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
try {
|
||||||
|
await o.f.delete();
|
||||||
|
remaining -= o.size;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears EVERY row + EVERY file. Wired to "Clear cache".
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
final dir = await _tracksDir();
|
final dir = await _tracksDir();
|
||||||
final d = Directory(dir);
|
final d = Directory(dir);
|
||||||
if (d.existsSync()) {
|
if (d.existsSync()) {
|
||||||
for (final entity in d.listSync()) {
|
for (final e in d.listSync()) {
|
||||||
if (entity is File) await entity.delete();
|
if (e is File) await e.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _db.delete(_db.audioCacheIndex).go();
|
await _db.delete(_db.audioCacheIndex).go();
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
// Background metadata sweeper that walks the drift cache for missing
|
||||||
|
// relations and fills them via /api/artists/:id + /api/albums/:id.
|
||||||
|
// Cover bytes for newly-filled albums are pre-warmed too so the
|
||||||
|
// per-tile display path doesn't have to wait on network.
|
||||||
|
//
|
||||||
|
// Why this layer on top of SyncController and HydrationQueue:
|
||||||
|
// - SyncController.sync only ingests entities the server emitted
|
||||||
|
// into the library_changes delta for this user. For a fresh
|
||||||
|
// install, that's everything; for an existing user, only recent
|
||||||
|
// changes. The per-artist album list and per-album track list
|
||||||
|
// are NOT in those deltas — they're derived at query time.
|
||||||
|
// - HydrationQueue fills these lazily on tile render. CacheFiller
|
||||||
|
// fills them proactively on a slow schedule so tapping an artist
|
||||||
|
// surfaces albums immediately instead of triggering a fresh
|
||||||
|
// /api/artists/:id at tap time.
|
||||||
|
//
|
||||||
|
// Pacing is conservative — 200ms between requests, max 200 entities
|
||||||
|
// per sweep, 5-minute interval. Designed to never compete with user
|
||||||
|
// activity. Wall time on a 1000-artist library: ~3-4 minutes spread
|
||||||
|
// over multiple sweeps.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show Variable;
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart'
|
||||||
|
show serverUrlProvider, sessionTokenProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
|
||||||
|
class CacheFiller {
|
||||||
|
CacheFiller(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Delay between launch and the first sweep. Long enough for
|
||||||
|
/// SyncController to land its initial /api/library/sync so the
|
||||||
|
/// CacheFiller's "unfilled relations" query has meaningful work.
|
||||||
|
static const _initialDelay = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Cadence between sweeps. Once a sweep finds nothing to do (steady
|
||||||
|
/// state) the interval becomes a cheap drift query + early exit.
|
||||||
|
static const _interval = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Throttle between per-entity REST requests so the filler doesn't
|
||||||
|
/// saturate the server or compete with user-initiated playback.
|
||||||
|
static const _requestThrottle = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
/// Per-sweep cap. Without this, a fresh install with thousands of
|
||||||
|
/// artists would tie up the network for many minutes in one go.
|
||||||
|
/// The next sweep continues where this one left off (the WHERE
|
||||||
|
/// NOT EXISTS query naturally skips already-filled rows).
|
||||||
|
static const _maxIdsPerSweep = 200;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
_initialTimer = Timer(_initialDelay, _sweep);
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => _sweep());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sweep() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
await _fillArtists();
|
||||||
|
if (_disposed) return;
|
||||||
|
await _fillAlbums();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('cache_filler: sweep failed: $e\n$st');
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find artists with no albums in cache and fetch /api/artists/:id
|
||||||
|
/// for each (single round-trip yields artist + albums via the new
|
||||||
|
/// getArtistDetail API method). Newly-discovered album covers
|
||||||
|
/// land in flutter_cache_manager's disk cache too so the next
|
||||||
|
/// visit to the artist's albums grid paints from disk.
|
||||||
|
Future<void> _fillArtists() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_artists a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final newAlbumIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final detail = await api.getArtistDetail(id);
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db
|
||||||
|
.into(db.cachedArtists)
|
||||||
|
.insertOnConflictUpdate(detail.artist.toDrift());
|
||||||
|
if (detail.albums.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
detail.albums.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
newAlbumIds.addAll(detail.albums.map((a) => a.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillArtist($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAlbumIds.isNotEmpty) {
|
||||||
|
await _prewarmCovers(newAlbumIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find albums with no tracks in cache and fetch /api/albums/:id
|
||||||
|
/// for each. The bulk response carries the album's track list which
|
||||||
|
/// we persist into cached_tracks for instant album detail render
|
||||||
|
/// on the next visit.
|
||||||
|
Future<void> _fillAlbums() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_albums a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final result = await api.getAlbum(id);
|
||||||
|
if (result.tracks.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks,
|
||||||
|
result.tracks.map((t) => t.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillAlbum($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-warm album cover bytes via flutter_cache_manager so the
|
||||||
|
/// per-tile ServerImage / CachedNetworkImage paints from disk on
|
||||||
|
/// the user's first visit. Mirrors SyncController's prewarm
|
||||||
|
/// helper — duplicated here intentionally to keep this file self-
|
||||||
|
/// contained; a shared helper can come out of #357 follow-up if
|
||||||
|
/// the duplication grows.
|
||||||
|
Future<void> _prewarmCovers(List<String> albumIds) async {
|
||||||
|
final baseUrl = await _ref.read(serverUrlProvider.future);
|
||||||
|
if (baseUrl == null || baseUrl.isEmpty) return;
|
||||||
|
final token = await _ref.read(sessionTokenProvider.future);
|
||||||
|
final headers = (token != null && token.isNotEmpty)
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: <String, String>{};
|
||||||
|
final base = baseUrl.endsWith('/')
|
||||||
|
? baseUrl.substring(0, baseUrl.length - 1)
|
||||||
|
: baseUrl;
|
||||||
|
final mgr = DefaultCacheManager();
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (idx < albumIds.length) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final i = idx++;
|
||||||
|
try {
|
||||||
|
await mgr.downloadFile(
|
||||||
|
'$base/api/albums/${albumIds[i]}/cover',
|
||||||
|
authHeaders: headers,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort prewarm — 404 (missing collage) and 401
|
||||||
|
// (token refresh races) shouldn't abort the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrency 3 — same as SyncController's prewarm. Covers are
|
||||||
|
// small enough that more parallelism doesn't help much and
|
||||||
|
// burns radio.
|
||||||
|
await Future.wait(List.generate(3, (_) => worker()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start (from app.dart's postFrameCallback) to
|
||||||
|
/// activate the filler. Disposed via ref.onDispose when the provider
|
||||||
|
/// scope tears down; in practice the scope lives for the app's
|
||||||
|
/// lifetime so dispose only fires on uninstall / process death.
|
||||||
|
final cacheFillerProvider = Provider<CacheFiller>((ref) {
|
||||||
|
final filler = CacheFiller(ref);
|
||||||
|
ref.onDispose(filler.dispose);
|
||||||
|
filler.start();
|
||||||
|
return filler;
|
||||||
|
});
|
||||||
+36
-11
@@ -19,6 +19,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
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:
|
/// Wraps the watch + cold-cache fallback pattern. Generic over:
|
||||||
/// D — the drift row type (or TypedResult for joins)
|
/// D — the drift row type (or TypedResult for joins)
|
||||||
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
||||||
@@ -43,14 +47,23 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
// refresh for this stream subscription, so we don't fire one on every
|
// refresh for this stream subscription, so we don't fire one on every
|
||||||
// drift re-emission (otherwise the populate cycles forever).
|
// drift re-emission (otherwise the populate cycles forever).
|
||||||
var revalidated = false;
|
var revalidated = false;
|
||||||
void log(String msg) {
|
|
||||||
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
|
// Tracks whether we've already tried a cold-cache fetch on this
|
||||||
}
|
// subscription. Without this, providers can spin forever in the
|
||||||
|
// rows-empty branch when fetchAndPopulate writes rows that don't
|
||||||
|
// match THIS filter — e.g. three liked-tab streams sharing one
|
||||||
|
// populate: the track populate writes track-typed rows, drift
|
||||||
|
// watch fires for the cached_likes table, the album stream
|
||||||
|
// re-emits with rows-empty (no album likes), the rows-empty
|
||||||
|
// branch re-fires populate, repeats forever. Setting this guard
|
||||||
|
// makes the first fetch attempt also the last for any given
|
||||||
|
// subscription.
|
||||||
|
var coldFetchAttempted = false;
|
||||||
|
|
||||||
await for (final rows in driftStream) {
|
await for (final rows in driftStream) {
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
log('drift hit (${rows.length} rows)');
|
|
||||||
yield toResult(rows);
|
yield toResult(rows);
|
||||||
|
coldFetchAttempted = true;
|
||||||
// Stale-while-revalidate: yield cache immediately, then kick off
|
// Stale-while-revalidate: yield cache immediately, then kick off
|
||||||
// a REST refresh in the background. Drift watch() picks up the
|
// a REST refresh in the background. Drift watch() picks up the
|
||||||
// resulting writes and re-emits via this same stream loop.
|
// resulting writes and re-emits via this same stream loop.
|
||||||
@@ -62,20 +75,32 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
log('drift miss; checking connectivity');
|
// rows is empty. If we've already attempted a cold fetch and
|
||||||
|
// drift is still empty for this filter, yield empty so the UI
|
||||||
|
// shows the no-content state instead of spinning forever.
|
||||||
|
if (coldFetchAttempted) {
|
||||||
|
yield toResult(rows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coldFetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
log('online; calling fetchAndPopulate');
|
|
||||||
try {
|
try {
|
||||||
await fetchAndPopulate();
|
await fetchAndPopulate();
|
||||||
log('fetchAndPopulate done; awaiting drift re-emit');
|
// Yield the current (still-empty) rows so the UI moves past
|
||||||
// The drift watch() stream re-emits with the populated rows on
|
// loading even if populate was a no-op for this filter
|
||||||
// the next loop iteration. Don't yield here.
|
// (server returned nothing matching, or all rows were
|
||||||
|
// already in drift via sync). If populate DID write rows
|
||||||
|
// matching this filter, the drift watch's next emission
|
||||||
|
// yields them via the rows.isNotEmpty branch — UI briefly
|
||||||
|
// shows empty then populates, instead of spinning.
|
||||||
|
yield toResult(rows);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log('fetchAndPopulate failed: $e\n$st');
|
if (tag != null) {
|
||||||
|
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||||
|
}
|
||||||
yield toResult(rows); // empty result; caller surfaces error
|
yield toResult(rows); // empty result; caller surfaces error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('offline; yielding empty');
|
|
||||||
yield toResult(rows); // empty result; offline
|
yield toResult(rows); // empty result; offline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-15
@@ -3,17 +3,29 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
|
|
||||||
import '../auth/auth_provider.dart' show secureStorageProvider;
|
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||||
|
|
||||||
/// Operator-tunable cache settings (#357 plan B). Persisted via
|
/// Operator-tunable cache settings. Persisted via
|
||||||
/// flutter_secure_storage on the same device.
|
/// flutter_secure_storage on the same device.
|
||||||
|
///
|
||||||
|
/// #427 S2: the single `capBytes` is replaced by two independent
|
||||||
|
/// budgets — Liked and Rolling-recent — each defaulting to 5GB.
|
||||||
|
/// Bucketing is by liked-ness (a cached track currently in the
|
||||||
|
/// user's liked set is charged to Liked; everything else to
|
||||||
|
/// Rolling), so the dedup is storage-only and never filters
|
||||||
|
/// playback. The old `cache_cap_bytes` key is intentionally not
|
||||||
|
/// migrated; defaults reapply (a one-time re-tune, not data loss).
|
||||||
class CacheSettings {
|
class CacheSettings {
|
||||||
const CacheSettings({
|
const CacheSettings({
|
||||||
required this.capBytes,
|
required this.likedCapBytes,
|
||||||
|
required this.rollingCapBytes,
|
||||||
required this.prefetchWindow,
|
required this.prefetchWindow,
|
||||||
required this.cacheLikedTracks,
|
required this.cacheLikedTracks,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 0 = unlimited.
|
/// Liked-bucket budget. 0 = unlimited.
|
||||||
final int capBytes;
|
final int likedCapBytes;
|
||||||
|
|
||||||
|
/// Rolling (recently-played) budget. 0 = unlimited.
|
||||||
|
final int rollingCapBytes;
|
||||||
|
|
||||||
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
|
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
|
||||||
final int prefetchWindow;
|
final int prefetchWindow;
|
||||||
@@ -22,25 +34,31 @@ class CacheSettings {
|
|||||||
final bool cacheLikedTracks;
|
final bool cacheLikedTracks;
|
||||||
|
|
||||||
CacheSettings copyWith({
|
CacheSettings copyWith({
|
||||||
int? capBytes,
|
int? likedCapBytes,
|
||||||
|
int? rollingCapBytes,
|
||||||
int? prefetchWindow,
|
int? prefetchWindow,
|
||||||
bool? cacheLikedTracks,
|
bool? cacheLikedTracks,
|
||||||
}) =>
|
}) =>
|
||||||
CacheSettings(
|
CacheSettings(
|
||||||
capBytes: capBytes ?? this.capBytes,
|
likedCapBytes: likedCapBytes ?? this.likedCapBytes,
|
||||||
|
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
|
||||||
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
|
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
|
||||||
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
|
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
static const defaults = CacheSettings(
|
static const defaults = CacheSettings(
|
||||||
capBytes: 5 * 1024 * 1024 * 1024,
|
likedCapBytes: _fiveGiB,
|
||||||
|
rollingCapBytes: _fiveGiB,
|
||||||
prefetchWindow: 5,
|
prefetchWindow: 5,
|
||||||
cacheLikedTracks: true,
|
cacheLikedTracks: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||||
static const _kCap = 'cache_cap_bytes';
|
static const _kLikedCap = 'cache_liked_cap_bytes';
|
||||||
|
static const _kRollingCap = 'cache_rolling_cap_bytes';
|
||||||
static const _kPrefetch = 'cache_prefetch_window';
|
static const _kPrefetch = 'cache_prefetch_window';
|
||||||
static const _kCacheLiked = 'cache_liked_tracks';
|
static const _kCacheLiked = 'cache_liked_tracks';
|
||||||
|
|
||||||
@@ -49,13 +67,17 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
|||||||
@override
|
@override
|
||||||
Future<CacheSettings> build() async {
|
Future<CacheSettings> build() async {
|
||||||
_storage = ref.read(secureStorageProvider);
|
_storage = ref.read(secureStorageProvider);
|
||||||
final cap = await _storage.read(key: _kCap);
|
final likedCap = await _storage.read(key: _kLikedCap);
|
||||||
|
final rollingCap = await _storage.read(key: _kRollingCap);
|
||||||
final pre = await _storage.read(key: _kPrefetch);
|
final pre = await _storage.read(key: _kPrefetch);
|
||||||
final liked = await _storage.read(key: _kCacheLiked);
|
final liked = await _storage.read(key: _kCacheLiked);
|
||||||
return CacheSettings(
|
return CacheSettings(
|
||||||
capBytes: cap == null
|
likedCapBytes: likedCap == null
|
||||||
? CacheSettings.defaults.capBytes
|
? CacheSettings.defaults.likedCapBytes
|
||||||
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
|
: int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
|
||||||
|
rollingCapBytes: rollingCap == null
|
||||||
|
? CacheSettings.defaults.rollingCapBytes
|
||||||
|
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
|
||||||
prefetchWindow: pre == null
|
prefetchWindow: pre == null
|
||||||
? CacheSettings.defaults.prefetchWindow
|
? CacheSettings.defaults.prefetchWindow
|
||||||
: (int.tryParse(pre) ?? 5).clamp(1, 10),
|
: (int.tryParse(pre) ?? 5).clamp(1, 10),
|
||||||
@@ -65,9 +87,14 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setCapBytes(int bytes) async {
|
Future<void> setLikedCapBytes(int bytes) async {
|
||||||
await _storage.write(key: _kCap, value: bytes.toString());
|
await _storage.write(key: _kLikedCap, value: bytes.toString());
|
||||||
state = AsyncData(state.value!.copyWith(capBytes: bytes));
|
state = AsyncData(state.value!.copyWith(likedCapBytes: bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRollingCapBytes(int bytes) async {
|
||||||
|
await _storage.write(key: _kRollingCap, value: bytes.toString());
|
||||||
|
state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setPrefetchWindow(int n) async {
|
Future<void> setPrefetchWindow(int n) async {
|
||||||
|
|||||||
Vendored
+170
-1
@@ -101,6 +101,11 @@ class AudioCacheIndex extends Table {
|
|||||||
TextColumn get path => text()();
|
TextColumn get path => text()();
|
||||||
IntColumn get sizeBytes => integer()();
|
IntColumn get sizeBytes => integer()();
|
||||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
/// When the track was last played. Drives the offline "Recently
|
||||||
|
/// played" ordering and rolling-bucket LRU eviction. Distinct from
|
||||||
|
/// cachedAt (download time). Nullable for pre-schema-9 rows until
|
||||||
|
/// the next play touches them (migration backfills to cachedAt).
|
||||||
|
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
|
||||||
TextColumn get source => textEnum<CacheSource>()();
|
TextColumn get source => textEnum<CacheSource>()();
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {trackId};
|
Set<Column> get primaryKey => {trackId};
|
||||||
@@ -115,6 +120,116 @@ class SyncMetadata extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
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};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-row cache of the /api/me/system-playlists-status response.
|
||||||
|
/// The home Playlists row reads this every render to pick between
|
||||||
|
/// real cards and placeholder cards for For-You / Discover / Songs-
|
||||||
|
/// like slots, so a fresh-mount cold fetch produces a visible
|
||||||
|
/// "building / pending / failed" flicker. Storing the last result
|
||||||
|
/// as a JSON blob means the home renders with the prior status
|
||||||
|
/// instantly, then SWR refreshes underneath. Schema 7+.
|
||||||
|
class CachedSystemPlaylistsStatus extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section→position→entity-id index for the home screen, populated
|
||||||
|
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||||
|
/// pins one tile slot to an entity; the actual entity data lives in
|
||||||
|
/// cached_albums / cached_artists / cached_tracks. The home screen
|
||||||
|
/// reads this table to know the layout, then hydrates each tile
|
||||||
|
/// against the entity tables (per-item rendering). Schema 6+.
|
||||||
|
class CachedHomeIndex extends Table {
|
||||||
|
/// One of: 'recently_added_albums', 'rediscover_albums',
|
||||||
|
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
|
||||||
|
/// Mirrors the keys /api/home and /api/home/index emit.
|
||||||
|
TextColumn get section => text()();
|
||||||
|
IntColumn get position => integer()();
|
||||||
|
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
|
||||||
|
/// to the right per-entity endpoint.
|
||||||
|
TextColumn get entityType => text()();
|
||||||
|
TextColumn get entityId => text()();
|
||||||
|
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {section, position};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbound mutation queue for offline-resilient REST calls. Likes,
|
||||||
|
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
|
||||||
|
/// create/cancel all enqueue here on REST failure so the user's
|
||||||
|
/// intent persists across network loss. MutationReplayer drains on
|
||||||
|
/// connectivity transitions and a 1-minute periodic tick; entries
|
||||||
|
/// with attempts >= 5 are skipped (the server treats them as
|
||||||
|
/// permanent failures and library_changes sync will correct any
|
||||||
|
/// resulting drift drift on next sync). Schema 8+.
|
||||||
|
class CachedMutations extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
/// Stable kind string (see mutation_queue.dart constants). The
|
||||||
|
/// replayer looks up the handler in a kind→Function map; unknown
|
||||||
|
/// kinds are dropped to avoid getting stuck.
|
||||||
|
TextColumn get kind => text()();
|
||||||
|
/// JSON-encoded payload — args needed to replay the REST call.
|
||||||
|
/// Shape depends on kind; see mutation_queue.dart.
|
||||||
|
TextColumn get payload => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
/// When the last drain attempt fired against this row. Null until
|
||||||
|
/// the first attempt. Useful for diagnostic queries.
|
||||||
|
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
||||||
|
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
||||||
|
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
||||||
|
/// fields inline) because the Hidden tab renders straight from this row
|
||||||
|
/// without joining other tables. Columnar (vs JSON blob) so flag/unflag
|
||||||
|
/// can do row-level INSERT/DELETE; the drift watch() emission feeds
|
||||||
|
/// MyQuarantineController state directly. Schema 5+.
|
||||||
|
class CachedQuarantineMine extends Table {
|
||||||
|
TextColumn get trackId => text()();
|
||||||
|
TextColumn get reason => text()();
|
||||||
|
TextColumn get notes => text().nullable()();
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
TextColumn get trackTitle => text()();
|
||||||
|
IntColumn get trackDurationMs => integer().withDefault(const Constant(0))();
|
||||||
|
TextColumn get albumId => text()();
|
||||||
|
TextColumn get albumTitle => text()();
|
||||||
|
TextColumn get albumCoverArtPath => text().nullable()();
|
||||||
|
TextColumn get artistId => text()();
|
||||||
|
TextColumn get artistName => text()();
|
||||||
|
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {trackId};
|
||||||
|
}
|
||||||
|
|
||||||
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||||
|
|
||||||
@DriftDatabase(tables: [
|
@DriftDatabase(tables: [
|
||||||
@@ -126,12 +241,18 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
|||||||
CachedPlaylistTracks,
|
CachedPlaylistTracks,
|
||||||
AudioCacheIndex,
|
AudioCacheIndex,
|
||||||
SyncMetadata,
|
SyncMetadata,
|
||||||
|
CachedHomeSnapshot,
|
||||||
|
CachedHistorySnapshot,
|
||||||
|
CachedQuarantineMine,
|
||||||
|
CachedHomeIndex,
|
||||||
|
CachedSystemPlaylistsStatus,
|
||||||
|
CachedMutations,
|
||||||
])
|
])
|
||||||
class AppDb extends _$AppDb {
|
class AppDb extends _$AppDb {
|
||||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 2;
|
int get schemaVersion => 9;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -147,6 +268,54 @@ class AppDb extends _$AppDb {
|
|||||||
// pre-existing rows until they happen to change server-side.
|
// pre-existing rows until they happen to change server-side.
|
||||||
await customStatement('UPDATE sync_metadata SET cursor = 0');
|
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);
|
||||||
|
}
|
||||||
|
if (from < 7) {
|
||||||
|
// Schema 7: cached_system_playlists_status. Single-row
|
||||||
|
// snapshot of /api/me/system-playlists-status used by the
|
||||||
|
// home Playlists row. Empty on upgrade; first fetch
|
||||||
|
// populates.
|
||||||
|
await m.createTable(cachedSystemPlaylistsStatus);
|
||||||
|
}
|
||||||
|
if (from < 8) {
|
||||||
|
// Schema 8: cached_mutations outbound queue. Empty on
|
||||||
|
// upgrade; populated by controllers when REST calls fail.
|
||||||
|
await m.createTable(cachedMutations);
|
||||||
|
}
|
||||||
|
if (from < 9) {
|
||||||
|
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
|
||||||
|
// gives the offline "Recently played" view a real
|
||||||
|
// recency signal (cachedAt is download time, not play
|
||||||
|
// time). Nullable — backfilled to cachedAt so existing
|
||||||
|
// rows order sensibly until next play touches them.
|
||||||
|
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
|
||||||
|
await customStatement(
|
||||||
|
'UPDATE audio_cache_index SET last_played_at = cached_at',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../library/library_providers.dart';
|
import '../library/library_providers.dart';
|
||||||
@@ -38,7 +37,6 @@ class MetadataPrefetcher {
|
|||||||
if (n++ >= _topN) break;
|
if (n++ >= _topN) break;
|
||||||
_swallow(_ref.read(artistProvider(id).future));
|
_swallow(_ref.read(artistProvider(id).future));
|
||||||
}
|
}
|
||||||
if (n > 0) debugPrint('metadataPrefetcher: warming $n artists');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _warmHome(HomeData h) {
|
void _warmHome(HomeData h) {
|
||||||
|
|||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
// Outbound mutation queue for offline-resilient REST calls.
|
||||||
|
//
|
||||||
|
// Controllers (LikesController, MyQuarantineController, the add-to-
|
||||||
|
// playlist sheet, the Discover request flow, the Requests cancel
|
||||||
|
// action) write their optimistic local state to drift first and try
|
||||||
|
// the corresponding REST call. On failure they enqueue here. The
|
||||||
|
// replayer drains the queue when connectivity comes back; the user's
|
||||||
|
// intent persists across network loss without rolling back their
|
||||||
|
// visible action.
|
||||||
|
//
|
||||||
|
// Why this is separate from SyncController: sync ingests AUTHORITATIVE
|
||||||
|
// SERVER state into drift. The mutation queue carries USER INTENT
|
||||||
|
// outbound to the server. They flow in opposite directions and
|
||||||
|
// shouldn't be conflated — sync wins on conflict (server is the
|
||||||
|
// source of truth), and a mutation that fails forever (5 attempts)
|
||||||
|
// is dropped on the trust that next sync will reconcile drift to
|
||||||
|
// match the server's actual state.
|
||||||
|
//
|
||||||
|
// Drop semantics: 5 attempts then drop. The drop is intentional —
|
||||||
|
// holding onto a forever-failing mutation just delays sync's
|
||||||
|
// reconciliation. The user's optimistic drift state will diverge
|
||||||
|
// from server, and the next library_changes delta corrects it.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart' show DioException, DioExceptionType;
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/discover.dart';
|
||||||
|
import '../api/endpoints/events.dart';
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likesApiProvider;
|
||||||
|
import '../models/lidarr.dart' show LidarrRequestKind;
|
||||||
|
import '../playlists/playlists_provider.dart' show playlistsApiProvider;
|
||||||
|
import '../quarantine/quarantine_provider.dart' show quarantineApiProvider;
|
||||||
|
import '../requests/requests_provider.dart' show requestsApiProvider;
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'connectivity_provider.dart';
|
||||||
|
import 'db.dart';
|
||||||
|
|
||||||
|
/// Stable kind constants. Each controller emits one of these via
|
||||||
|
/// MutationQueue.enqueue; the replayer dispatches by kind.
|
||||||
|
class MutationKinds {
|
||||||
|
MutationKinds._();
|
||||||
|
static const likeAdd = 'like.add';
|
||||||
|
static const likeRemove = 'like.remove';
|
||||||
|
static const quarantineFlag = 'quarantine.flag';
|
||||||
|
static const quarantineUnflag = 'quarantine.unflag';
|
||||||
|
static const playlistAppend = 'playlist.append';
|
||||||
|
static const requestCreate = 'request.create';
|
||||||
|
static const requestCancel = 'request.cancel';
|
||||||
|
static const playOffline = 'play.offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MutationQueue {
|
||||||
|
MutationQueue(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Persist a mutation for replay. Caller has already done the
|
||||||
|
/// optimistic local mutation (drift write, in-memory state update)
|
||||||
|
/// — this only records the REST call that needs to fire eventually.
|
||||||
|
Future<void> enqueue(String kind, Map<String, dynamic> payload) async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedMutations).insert(
|
||||||
|
CachedMutationsCompanion.insert(
|
||||||
|
kind: kind,
|
||||||
|
payload: jsonEncode(payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Nudge the replayer in case we're online right now.
|
||||||
|
unawaited(_ref.read(mutationReplayerProvider).drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of pending mutations (attempts < 5). Diagnostic / future
|
||||||
|
/// UI surface for a "syncing N pending changes" indicator.
|
||||||
|
Future<int> pendingCount() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5',
|
||||||
|
).get();
|
||||||
|
return rows.first.read<int>('c');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationQueueProvider =
|
||||||
|
Provider<MutationQueue>((ref) => MutationQueue(ref));
|
||||||
|
|
||||||
|
class MutationReplayer {
|
||||||
|
MutationReplayer(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Periodic poll cadence. The connectivity listener catches most
|
||||||
|
/// transitions; this is the belt-and-suspenders for cases where
|
||||||
|
/// connectivity_plus misses a state change (e.g. flaky Wi-Fi).
|
||||||
|
static const _interval = Duration(minutes: 1);
|
||||||
|
|
||||||
|
/// Max replay attempts before we drop a mutation. 5 is enough to
|
||||||
|
/// span a few reconnection cycles without holding onto a forever-
|
||||||
|
/// broken request. See file header for the drop rationale.
|
||||||
|
static const _maxAttempts = 5;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
// Drain shortly after launch in case there are queued mutations
|
||||||
|
// from a prior session that died offline. 3s lets the auth +
|
||||||
|
// server-url + dio providers finish their async warmup.
|
||||||
|
_initialTimer = Timer(const Duration(seconds: 3), drain);
|
||||||
|
// Periodic ticker is the only reconnect signal. We previously had
|
||||||
|
// a ref.listen(connectivityProvider, …) edge trigger here, but
|
||||||
|
// subscribing at start-time eagerly mounted the connectivity
|
||||||
|
// StreamProvider and leaked its initial-timeout Timer through
|
||||||
|
// tests that never reach the auth state. The drain() loop itself
|
||||||
|
// gates on connectivity via the .future read below.
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the queue oldest-first, replaying each mutation. Stops on
|
||||||
|
/// any transient network error (we'll retry on the next tick); a
|
||||||
|
/// permanent error (404 etc.) increments the attempt counter and
|
||||||
|
/// moves on to the next mutation. This is public so MutationQueue.
|
||||||
|
/// enqueue can trigger an immediate attempt after writing.
|
||||||
|
Future<void> drain() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
while (!_disposed) {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final next = await (db.select(db.cachedMutations)
|
||||||
|
..where((t) => t.attempts.isSmallerThanValue(_maxAttempts))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (next == null) break;
|
||||||
|
|
||||||
|
final handler = _handlers[next.kind];
|
||||||
|
if (handler == null) {
|
||||||
|
debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> payload;
|
||||||
|
try {
|
||||||
|
payload = jsonDecode(next.payload) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('mutation_replayer: bad payload for ${next.kind}: $e');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(_ref, payload);
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
if (_isTransient(e)) {
|
||||||
|
// Network is flaky again — bail and try the whole queue
|
||||||
|
// later. The current mutation will be retried next tick.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Permanent failure (4xx/5xx with a real response). Leave
|
||||||
|
// the row with bumped attempts and move on to the next so
|
||||||
|
// one bad mutation doesn't block the others.
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('mutation_replayer: unexpected error: $e\n$st');
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTransient(DioException e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationReplayerProvider = Provider<MutationReplayer>((ref) {
|
||||||
|
final r = MutationReplayer(ref);
|
||||||
|
ref.onDispose(r.dispose);
|
||||||
|
r.start();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Type signature for kind-specific replay handlers.
|
||||||
|
typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
|
||||||
|
|
||||||
|
/// Kind → handler dispatch. Each handler decodes its payload and
|
||||||
|
/// re-fires the original REST call. Throws on failure (caught by the
|
||||||
|
/// replayer's drain loop above).
|
||||||
|
///
|
||||||
|
/// Payload shapes (see also enqueue call sites for the writer side):
|
||||||
|
/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid}
|
||||||
|
/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str}
|
||||||
|
/// * quarantine.unflag: {'trackId': uuid}
|
||||||
|
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
||||||
|
/// * request.create: {full createRequest args; see DiscoverApi}
|
||||||
|
/// * request.cancel: {'id': uuid}
|
||||||
|
/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601,
|
||||||
|
/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'}
|
||||||
|
final Map<String, _Handler> _handlers = {
|
||||||
|
MutationKinds.likeAdd: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.likeRemove: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.unlike(
|
||||||
|
_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineFlag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.flag(
|
||||||
|
p['trackId'] as String,
|
||||||
|
p['reason'] as String,
|
||||||
|
notes: (p['notes'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineUnflag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.unflag(p['trackId'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.playlistAppend: (ref, p) async {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
final ids = (p['trackIds'] as List).cast<String>();
|
||||||
|
await api.appendTracks(p['playlistId'] as String, ids);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCreate: (ref, p) async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final api = DiscoverApi(dio);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: _lidarrKindFromString(p['kind'] as String),
|
||||||
|
artistMbid: p['artistMbid'] as String,
|
||||||
|
artistName: p['artistName'] as String,
|
||||||
|
albumMbid: p['albumMbid'] as String?,
|
||||||
|
albumTitle: p['albumTitle'] as String?,
|
||||||
|
trackMbid: p['trackMbid'] as String?,
|
||||||
|
trackTitle: p['trackTitle'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCancel: (ref, p) async {
|
||||||
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
|
await api.cancel(p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.playOffline: (ref, p) async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final api = EventsApi(dio);
|
||||||
|
await api.playOffline(
|
||||||
|
trackId: p['trackId'] as String,
|
||||||
|
clientId: p['clientId'] as String,
|
||||||
|
atIso: p['at'] as String,
|
||||||
|
durationPlayedMs: (p['durationPlayedMs'] as num).toInt(),
|
||||||
|
source: p['source'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
LikeKind _likeKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LikeKind.album,
|
||||||
|
'artist' => LikeKind.artist,
|
||||||
|
_ => LikeKind.track,
|
||||||
|
};
|
||||||
|
|
||||||
|
LidarrRequestKind _lidarrKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LidarrRequestKind.album,
|
||||||
|
'track' => LidarrRequestKind.track,
|
||||||
|
_ => LidarrRequestKind.artist,
|
||||||
|
};
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
// Reachability-based offline marker (#427 S1).
|
||||||
|
//
|
||||||
|
// `connectivityProvider` only knows whether a network *interface* is
|
||||||
|
// up — not whether the Minstrel server is actually reachable (captive
|
||||||
|
// portals, server down, DNS, VPN-only routes all read "online"). This
|
||||||
|
// is the single source of truth other features gate on: offline =
|
||||||
|
// the server failed its /healthz probe N times in a row; recovery on
|
||||||
|
// the first success.
|
||||||
|
//
|
||||||
|
// Deliberately NOT coupled to connectivityProvider: subscribing to
|
||||||
|
// that StreamProvider eagerly mounts its 2s checkConnectivity timeout
|
||||||
|
// and leaks a pending Timer through widget tests that never reach the
|
||||||
|
// auth state (the bug fixed for MutationReplayer). /healthz failing
|
||||||
|
// already covers interface-down — it just fails the probe.
|
||||||
|
//
|
||||||
|
// Optimistic: assume online until proven offline, so a cold launch
|
||||||
|
// behaves normally and only flips after sustained unreachability.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/health.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
|
||||||
|
class OfflineMonitor extends Notifier<bool> {
|
||||||
|
Timer? _initial;
|
||||||
|
Timer? _periodic;
|
||||||
|
int _consecutiveFails = 0;
|
||||||
|
|
||||||
|
/// Consecutive failed probes before we declare offline. Small
|
||||||
|
/// enough to react within ~half a minute, large enough that a
|
||||||
|
/// single dropped request doesn't flip the whole UI.
|
||||||
|
static const _threshold = 3;
|
||||||
|
|
||||||
|
/// Probe cadence. Slower when believed-online (cheap heartbeat);
|
||||||
|
/// faster when offline so recovery is noticed quickly.
|
||||||
|
static const _onlineInterval = Duration(seconds: 30);
|
||||||
|
static const _offlineInterval = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Let auth + server-url + dio providers finish async warmup
|
||||||
|
/// before the first probe so a cold start doesn't false-positive.
|
||||||
|
static const _initialDelay = Duration(seconds: 5);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_initial?.cancel();
|
||||||
|
_periodic?.cancel();
|
||||||
|
});
|
||||||
|
_initial = Timer(_initialDelay, () {
|
||||||
|
_check();
|
||||||
|
_arm();
|
||||||
|
});
|
||||||
|
return false; // optimistic until a probe says otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arm() {
|
||||||
|
_periodic?.cancel();
|
||||||
|
_periodic = Timer.periodic(
|
||||||
|
state ? _offlineInterval : _onlineInterval,
|
||||||
|
(_) => _check(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _check() async {
|
||||||
|
final Dio dio;
|
||||||
|
try {
|
||||||
|
// Not configured yet (no server URL) → don't flip; the app is
|
||||||
|
// still on the connect screen and "offline" is meaningless.
|
||||||
|
dio = await ref.read(dioProvider.future);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// dio's own connect/receive timeouts bound this — no extra
|
||||||
|
// Timer (which would leak in widget tests).
|
||||||
|
await HealthApi(dio).check();
|
||||||
|
_consecutiveFails = 0;
|
||||||
|
_set(false);
|
||||||
|
} catch (_) {
|
||||||
|
_consecutiveFails++;
|
||||||
|
if (_consecutiveFails >= _threshold) {
|
||||||
|
_set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _set(bool offline) {
|
||||||
|
if (state == offline) return;
|
||||||
|
state = offline;
|
||||||
|
_arm(); // cadence follows the new state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force an immediate probe — e.g. a user-initiated retry. Public
|
||||||
|
/// so S4's offline surfaces can offer a "try again" affordance.
|
||||||
|
Future<void> recheck() => _check();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the server is unreachable. Watch this to gate
|
||||||
|
/// online-only affordances; read it once at app start to activate
|
||||||
|
/// the poller.
|
||||||
|
final offlineProvider =
|
||||||
|
NotifierProvider<OfflineMonitor, bool>(OfflineMonitor.new);
|
||||||
+55
-10
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
|
import '../player/album_color_extractor.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
import 'audio_cache_manager.dart';
|
import 'audio_cache_manager.dart';
|
||||||
import 'cache_settings_provider.dart';
|
import 'cache_settings_provider.dart';
|
||||||
@@ -39,20 +43,61 @@ class Prefetcher {
|
|||||||
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
|
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
|
||||||
|
|
||||||
final mgr = _ref.read(audioCacheManagerProvider);
|
final mgr = _ref.read(audioCacheManagerProvider);
|
||||||
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final colorCache = _ref.read(albumColorCacheProvider);
|
||||||
|
|
||||||
|
// Walk the window. For each upcoming track we want THREE things
|
||||||
|
// ready when the player transitions into it:
|
||||||
|
//
|
||||||
|
// 1. Audio file on disk (otherwise playback stalls on stream
|
||||||
|
// load — the original prefetcher concern).
|
||||||
|
// 2. Cover bytes on disk under AlbumCoverCache (otherwise
|
||||||
|
// _toMediaItem's peekCached returns null, mediaItem
|
||||||
|
// broadcasts with artUri=null, and the now-playing screen
|
||||||
|
// stalls in _scheduleSwap awaiting precacheImage of a file
|
||||||
|
// that doesn't exist yet).
|
||||||
|
// 3. Palette color extracted and memoized in AlbumColorCache
|
||||||
|
// (otherwise the gradient backdrop has to wait for
|
||||||
|
// PaletteGenerator to run after the cover lands —
|
||||||
|
// visible as the cover snapping in before the gradient).
|
||||||
|
//
|
||||||
|
// Each call is idempotent (cache-aware): if already cached, it's
|
||||||
|
// a no-op. Everything fire-and-forget so the reconcile completes
|
||||||
|
// quickly even on a fresh queue.
|
||||||
for (var i = currentIdx; i <= endIdx; i++) {
|
for (var i = currentIdx; i <= endIdx; i++) {
|
||||||
final trackId = queue[i].id;
|
final media = queue[i];
|
||||||
if (await mgr.isCached(trackId)) continue;
|
final trackId = media.id;
|
||||||
// Fire-and-forget; downloads happen in the background. Errors
|
final albumId = media.extras?['album_id'] as String?;
|
||||||
// inside pin() are caught + cleaned up internally.
|
|
||||||
// ignore: unawaited_futures
|
if (!await mgr.isCached(trackId)) {
|
||||||
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
// ignore: unawaited_futures
|
||||||
|
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
// Cover bytes: getOrFetch returns the file path; the side
|
||||||
|
// effect (writing to disk) is what we care about.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
coverCache.getOrFetch(albumId);
|
||||||
|
// Palette: getOrExtract chains off coverCache.getOrFetch so
|
||||||
|
// it'll wait for the cover before sampling — safe to call
|
||||||
|
// in parallel here.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
colorCache.getOrExtract(albumId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eviction pass after pinning new files.
|
// Eviction pass after pinning new files. Per-bucket (#427 S2):
|
||||||
if (settings.capBytes > 0) {
|
// liked-ness decides the bucket, so a track that's both liked
|
||||||
await mgr.evict(targetBytes: settings.capBytes);
|
// and recently played is protected by the Liked cap, not the
|
||||||
}
|
// rolling LRU.
|
||||||
|
final liked =
|
||||||
|
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
await mgr.evictBuckets(
|
||||||
|
likedCap: settings.likedCapBytes,
|
||||||
|
rollingCap: settings.rollingCapBytes,
|
||||||
|
liked: liked,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Offline play sources over the local cache index (#427 S4).
|
||||||
|
//
|
||||||
|
// "Shuffle all" is always-present and degrades with reachability:
|
||||||
|
// online → GET /api/library/shuffle (random over the whole library)
|
||||||
|
// offline → a client shuffle over the entire local cache index
|
||||||
|
//
|
||||||
|
// Offline-only pools surfaced on Home beside the (disabled) system
|
||||||
|
// playlists: "Recently played" (cache by lastPlayedAt desc) and
|
||||||
|
// "Liked" (cache ∩ liked set). All of these are UNIONs over the
|
||||||
|
// cache regardless of storage bucket — liked AND recently-played
|
||||||
|
// both included. The two-bucket split (S2) is storage/eviction-only
|
||||||
|
// and never filters playback, which is exactly what this relies on.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'adapters.dart';
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'offline_provider.dart';
|
||||||
|
|
||||||
|
class ShuffleSource {
|
||||||
|
ShuffleSource(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Shuffle-all. Online: server-random. Offline: every cached
|
||||||
|
/// track, shuffled. Empty offline → nothing cached yet.
|
||||||
|
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||||
|
if (_ref.read(offlineProvider)) {
|
||||||
|
final ids = await _cachedIdsByRecency();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
list.shuffle(Random());
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
return api.shuffle(limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached tracks, most-recently-played first (liked included).
|
||||||
|
/// Cache-only — surfaced on Home only when offline.
|
||||||
|
Future<List<TrackRef>> recentlyPlayed({int limit = 100}) async {
|
||||||
|
final ids = await _cachedIdsByRecency();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached tracks that are in the user's liked set. Cache-only —
|
||||||
|
/// surfaced on Home only when offline.
|
||||||
|
Future<List<TrackRef>> liked({int limit = 100}) async {
|
||||||
|
final likedSet =
|
||||||
|
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
final ids =
|
||||||
|
(await _cachedIdsByRecency()).where(likedSet.contains).toList();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached track ids ordered by lastPlayedAt desc (nulls last so
|
||||||
|
/// never-touched downloads sort after real plays).
|
||||||
|
Future<List<String>> _cachedIdsByRecency() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.select(db.audioCacheIndex).get();
|
||||||
|
rows.sort((a, b) {
|
||||||
|
final av = a.lastPlayedAt, bv = b.lastPlayedAt;
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
return bv.compareTo(av);
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.trackId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializes ordered track ids into TrackRefs from the cached
|
||||||
|
/// metadata tables, preserving the given order.
|
||||||
|
Future<List<TrackRef>> _refs(List<String> orderedIds) async {
|
||||||
|
if (orderedIds.isEmpty) return const [];
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final meta = await (db.select(db.cachedTracks)
|
||||||
|
..where((t) => t.id.isIn(orderedIds)))
|
||||||
|
.get();
|
||||||
|
if (meta.isEmpty) return const [];
|
||||||
|
final byId = {for (final t in meta) t.id: t};
|
||||||
|
final artistName = {
|
||||||
|
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
||||||
|
};
|
||||||
|
final albumTitle = {
|
||||||
|
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
for (final id in orderedIds)
|
||||||
|
if (byId[id] case final t?)
|
||||||
|
t.toRef(
|
||||||
|
artistName: artistName[t.artistId] ?? '',
|
||||||
|
albumTitle: albumTitle[t.albumId] ?? '',
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final shuffleSourceProvider =
|
||||||
|
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|
||||||
+72
-2
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider;
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import 'audio_cache_manager.dart' show appDbProvider;
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
import 'db.dart';
|
import 'db.dart';
|
||||||
@@ -75,6 +79,14 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
|||||||
|
|
||||||
var upsertCount = 0;
|
var upsertCount = 0;
|
||||||
var deleteCount = 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 {
|
await db.transaction(() async {
|
||||||
// ---- Upserts ----
|
// ---- Upserts ----
|
||||||
@@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
|||||||
upsertCount++;
|
upsertCount++;
|
||||||
}
|
}
|
||||||
for (final a in (upserts['album'] as List? ?? const [])) {
|
for (final a in (upserts['album'] as List? ?? const [])) {
|
||||||
|
final m = a as Map<String, dynamic>;
|
||||||
await db.into(db.cachedAlbums).insertOnConflictUpdate(
|
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++;
|
upsertCount++;
|
||||||
}
|
}
|
||||||
for (final t in (upserts['track'] as List? ?? const [])) {
|
for (final t in (upserts['track'] as List? ?? const [])) {
|
||||||
@@ -130,9 +145,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
|||||||
upsertCount++;
|
upsertCount++;
|
||||||
}
|
}
|
||||||
for (final p in (upserts['playlist'] as List? ?? const [])) {
|
for (final p in (upserts['playlist'] as List? ?? const [])) {
|
||||||
|
final m = p as Map<String, dynamic>;
|
||||||
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
|
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++;
|
upsertCount++;
|
||||||
}
|
}
|
||||||
for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
|
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(
|
final result = SyncResult(
|
||||||
upserts: upsertCount,
|
upserts: upsertCount,
|
||||||
deletes: deleteCount,
|
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 _artistFromJson(Map<String, dynamic> j) =>
|
||||||
CachedArtistsCompanion.insert(
|
CachedArtistsCompanion.insert(
|
||||||
id: j['id'] as String,
|
id: j['id'] as String,
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
// Per-entity tile providers for the per-item rendering architecture
|
||||||
|
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
|
||||||
|
//
|
||||||
|
// Each provider is a StreamProvider.family<EntityRef?, String> keyed
|
||||||
|
// by entity id. The stream:
|
||||||
|
// 1. Watches the entity's drift row
|
||||||
|
// 2. Yields the populated row on every emission
|
||||||
|
// 3. Yields null while the row is absent (UI shows skeleton)
|
||||||
|
// 4. On the first missing-row emission, enqueues a hydration via
|
||||||
|
// HydrationQueue so the row eventually lands
|
||||||
|
//
|
||||||
|
// The tile widget reacts to AsyncValue<EntityRef?>:
|
||||||
|
// - loading or data:null → skeleton
|
||||||
|
// - data:non-null → real card
|
||||||
|
// - error → error placeholder
|
||||||
|
//
|
||||||
|
// Subscriptions are per-tile. A 50-tile home screen creates 50 drift
|
||||||
|
// subscriptions; drift handles this fine in practice but worth
|
||||||
|
// measuring if a future surface scales to hundreds.
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift show OrderingTerm;
|
||||||
|
import 'package:drift/drift.dart' show leftOuterJoin;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'hydration_queue.dart';
|
||||||
|
|
||||||
|
/// Watches the cached_albums row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final albumTileProvider =
|
||||||
|
StreamProvider.family<AlbumRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'album', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final album = r.readTable(db.cachedAlbums);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
yield album.toRef(artistName: artist?.name ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_artists row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
///
|
||||||
|
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
|
||||||
|
/// artist carries a representative album id; toRef() reconstructs
|
||||||
|
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
|
||||||
|
/// has an empty coverUrl and tiles fall back to the music-notes
|
||||||
|
/// placeholder even though the server has the cover available.
|
||||||
|
final artistTileProvider =
|
||||||
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'artist', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// First row has the alphabetically-first album (or null if artist
|
||||||
|
// has no albums in drift yet — yields a coverless ArtistRef which
|
||||||
|
// the UI falls back to the placeholder icon for).
|
||||||
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_tracks row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final trackTileProvider =
|
||||||
|
StreamProvider.family<TrackRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||||
|
]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'track', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final t = r.readTable(db.cachedTracks);
|
||||||
|
final a = r.readTableOrNull(db.cachedArtists);
|
||||||
|
final al = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield t.toRef(
|
||||||
|
artistName: a?.name ?? '',
|
||||||
|
albumTitle: al?.title ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -5,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../api/endpoints/discover.dart';
|
import '../api/endpoints/discover.dart';
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/lidarr.dart';
|
import '../models/lidarr.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
@@ -47,14 +49,23 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
|
|
||||||
Future<void> _request(LidarrSearchResult row) async {
|
Future<void> _request(LidarrSearchResult row) async {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final args = {
|
||||||
|
'kind': _kind.wire,
|
||||||
|
'artistMbid':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
||||||
|
'artistName':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
||||||
|
'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null,
|
||||||
|
'albumTitle': _kind == LidarrRequestKind.album ? row.name : null,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(_discoverApiProvider.future);
|
final api = await ref.read(_discoverApiProvider.future);
|
||||||
await api.createRequest(
|
await api.createRequest(
|
||||||
kind: _kind,
|
kind: _kind,
|
||||||
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
artistMbid: args['artistMbid'] as String,
|
||||||
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
artistName: args['artistName'] as String,
|
||||||
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
|
albumMbid: args['albumMbid'],
|
||||||
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
|
albumTitle: args['albumTitle'],
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
@@ -64,12 +75,18 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
// Re-run search to refresh the `requested` flag on the row.
|
// Re-run search to refresh the `requested` flag on the row.
|
||||||
_runSearch();
|
_runSearch();
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (_) {
|
||||||
|
// Queue for replay so the user's request persists across
|
||||||
|
// network loss. We don't have a drift table for in-flight
|
||||||
|
// requests yet, so the row won't show on the Requests screen
|
||||||
|
// until replay succeeds — accept that for v1.
|
||||||
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCreate, args);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final code = ApiError.fromDio(e).code;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
content: Text('Request failed: $code'),
|
content: Text('Request queued: ${row.name}'),
|
||||||
backgroundColor: fs.error,
|
backgroundColor: fs.iron,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,9 +221,14 @@ class _ResultTile extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: row.imageUrl.isEmpty
|
child: row.imageUrl.isEmpty
|
||||||
? Icon(Icons.album, color: fs.ash)
|
? Icon(Icons.album, color: fs.ash)
|
||||||
: Image.network(row.imageUrl, fit: BoxFit.cover,
|
: CachedNetworkImage(
|
||||||
errorBuilder: (_, __, ___) =>
|
imageUrl: row.imageUrl,
|
||||||
Icon(Icons.album, color: fs.ash)),
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(Icons.album, color: fs.ash),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
|
||||||
import '../cache/db.dart';
|
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
import '../shared/widgets/server_image.dart';
|
import '../shared/widgets/server_image.dart';
|
||||||
@@ -113,21 +111,6 @@ class AlbumDetailScreen extends ConsumerWidget {
|
|||||||
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
IconButton(
|
|
||||||
key: const Key('download_album_button'),
|
|
||||||
icon: Icon(Icons.download, color: fs.ash),
|
|
||||||
tooltip: 'Download album',
|
|
||||||
onPressed: () {
|
|
||||||
final mgr = ref.read(audioCacheManagerProvider);
|
|
||||||
for (final t in r.tracks) {
|
|
||||||
// ignore: unawaited_futures
|
|
||||||
mgr.pin(t.id, source: CacheSource.autoPlaylist);
|
|
||||||
}
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Container(
|
Container(
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final tracks =
|
final tracks =
|
||||||
await ref.read(artistTracksProvider(id).future);
|
await ref.read(artistTracksProvider(id).future);
|
||||||
debugPrint(
|
|
||||||
'artist_detail: play tapped — ${tracks.length} tracks for $id');
|
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
import '../models/album.dart';
|
import '../cache/offline_provider.dart';
|
||||||
import '../models/artist.dart';
|
import '../cache/shuffle_source.dart';
|
||||||
|
import '../cache/tile_providers.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../models/system_playlists_status.dart';
|
import '../models/system_playlists_status.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
|
import '../player/player_provider.dart';
|
||||||
import '../playlists/playlists_provider.dart';
|
import '../playlists/playlists_provider.dart';
|
||||||
import '../playlists/widgets/playlist_card.dart';
|
import '../playlists/widgets/playlist_card.dart';
|
||||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||||
import '../shared/widgets/connection_error_banner.dart';
|
import '../shared/widgets/connection_error_banner.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/skeletons.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
@@ -21,13 +24,18 @@ import 'widgets/artist_card.dart';
|
|||||||
import 'widgets/compact_track_card.dart';
|
import 'widgets/compact_track_card.dart';
|
||||||
import 'widgets/horizontal_scroll_row.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 {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final home = ref.watch(homeProvider);
|
final index = ref.watch(homeIndexProvider);
|
||||||
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
||||||
final status = ref.watch(systemPlaylistsStatusProvider);
|
final status = ref.watch(systemPlaylistsStatusProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -39,40 +47,34 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: home.when(
|
child: index.when(
|
||||||
error: (e, _) {
|
error: (e, _) {
|
||||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||||
if (code == 'connection_refused') {
|
if (code == 'connection_refused') {
|
||||||
return ConnectionErrorBanner(
|
return ConnectionErrorBanner(
|
||||||
onRetry: () => ref.refresh(homeProvider),
|
onRetry: () => ref.refresh(homeIndexProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||||
},
|
},
|
||||||
loading: () => _HomeSkeleton(fs: fs),
|
loading: () => _HomeSkeleton(fs: fs),
|
||||||
data: (h) => RefreshIndicator(
|
data: (h) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
onRefresh: () async => ref.refresh(homeIndexProvider.future),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
// ClampingScrollPhysics: no bounce overscroll past the
|
|
||||||
// content. Combined with the bottom padding below, this
|
|
||||||
// makes "scroll to end" land the last item just above the
|
|
||||||
// player bar — no empty void.
|
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
_PlaylistsSection(
|
_PlaylistsSection(
|
||||||
playlists: allPlaylists.value?.owned ?? const [],
|
playlists: allPlaylists.value?.owned ?? const [],
|
||||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||||
|
offline: ref.watch(offlineProvider),
|
||||||
),
|
),
|
||||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
||||||
_RediscoverSection(
|
_RediscoverSection(
|
||||||
albums: h.rediscoverAlbums,
|
albumIds: h.rediscoverAlbums,
|
||||||
artists: h.rediscoverArtists,
|
artistIds: h.rediscoverArtists,
|
||||||
),
|
),
|
||||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
_MostPlayedSection(ids: h.mostPlayedTracks),
|
||||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
_LastPlayedSection(ids: h.lastPlayedArtists),
|
||||||
// Bottom padding ≈ player bar height (top row 80 + seek
|
|
||||||
// 30 + padding 16 ≈ ~140) so the last section's bottom
|
|
||||||
// edge lands right above the player when scrolled.
|
|
||||||
const SizedBox(height: 140),
|
const SizedBox(height: 140),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -83,24 +85,265 @@ 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 {
|
class _PlaylistsSection extends StatelessWidget {
|
||||||
const _PlaylistsSection({required this.playlists, required this.status});
|
const _PlaylistsSection({
|
||||||
|
required this.playlists,
|
||||||
|
required this.status,
|
||||||
|
required this.offline,
|
||||||
|
});
|
||||||
final List<Playlist> playlists;
|
final List<Playlist> playlists;
|
||||||
final SystemPlaylistsStatus status;
|
final SystemPlaylistsStatus status;
|
||||||
|
final bool offline;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = _buildPlaylistsRow(playlists, status);
|
final items = _buildPlaylistsRow(playlists, status);
|
||||||
|
final children = <Widget>[
|
||||||
|
// Offline: surface the cache-backed pools where the (now
|
||||||
|
// play-disabled) system playlists sit, so there's something
|
||||||
|
// to play. #427 S4b.
|
||||||
|
if (offline) ...const [
|
||||||
|
_OfflinePoolCard(
|
||||||
|
label: 'Recently played',
|
||||||
|
icon: Icons.history,
|
||||||
|
kind: _OfflinePoolKind.recentlyPlayed,
|
||||||
|
),
|
||||||
|
_OfflinePoolCard(
|
||||||
|
label: 'Liked',
|
||||||
|
icon: Icons.favorite,
|
||||||
|
kind: _OfflinePoolKind.liked,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
for (final item in items)
|
||||||
|
if (item is _RealPlaylist)
|
||||||
|
PlaylistCard(playlist: item.playlist)
|
||||||
|
else
|
||||||
|
PlaylistPlaceholderCard(
|
||||||
|
label: (item as _PlaceholderPlaylist).label,
|
||||||
|
variant: item.variant,
|
||||||
|
),
|
||||||
|
];
|
||||||
return HorizontalScrollRow(
|
return HorizontalScrollRow(
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
height: 220,
|
height: 220,
|
||||||
children: items.map((item) {
|
children: children,
|
||||||
if (item is _RealPlaylist) {
|
);
|
||||||
return PlaylistCard(playlist: item.playlist);
|
}
|
||||||
}
|
}
|
||||||
final ph = item as _PlaceholderPlaylist;
|
|
||||||
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
enum _OfflinePoolKind { recentlyPlayed, liked }
|
||||||
}).toList(),
|
|
||||||
|
/// Home tile for an offline cache-backed pool. Tapping shuffles +
|
||||||
|
/// plays that pool from the local cache. Sized to match
|
||||||
|
/// PlaylistCard so the row stays visually consistent.
|
||||||
|
class _OfflinePoolCard extends ConsumerWidget {
|
||||||
|
const _OfflinePoolCard({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.kind,
|
||||||
|
});
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final _OfflinePoolKind kind;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return SizedBox(
|
||||||
|
width: 176,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final src = ref.read(shuffleSourceProvider);
|
||||||
|
final refs = await switch (kind) {
|
||||||
|
_OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(),
|
||||||
|
_OfflinePoolKind.liked => src.liked(),
|
||||||
|
};
|
||||||
|
if (refs.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('No cached $label tracks yet')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(refs, shuffle: true);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 144,
|
||||||
|
height: 144,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.slate,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: fs.accent, size: 56),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
'Offline',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,13 +372,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slot 1: For-You.
|
|
||||||
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
||||||
out.add(forYou != null
|
out.add(forYou != null
|
||||||
? _RealPlaylist(forYou)
|
? _RealPlaylist(forYou)
|
||||||
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
: _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
|
final songsLike = ownedAll
|
||||||
.where((p) => p.systemVariant == 'songs_like_artist')
|
.where((p) => p.systemVariant == 'songs_like_artist')
|
||||||
.take(3)
|
.take(3)
|
||||||
@@ -146,7 +392,6 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
|
|||||||
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
|
: _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)) {
|
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
|
||||||
out.add(_RealPlaylist(p));
|
out.add(_RealPlaylist(p));
|
||||||
}
|
}
|
||||||
@@ -162,44 +407,38 @@ String _variantFor(String slot, SystemPlaylistsStatus s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecentlyAddedSection extends StatelessWidget {
|
class _RecentlyAddedSection extends StatelessWidget {
|
||||||
const _RecentlyAddedSection({required this.albums});
|
const _RecentlyAddedSection({required this.ids});
|
||||||
final List<AlbumRef> albums;
|
final List<String> ids;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (albums.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return const _EmptySection(
|
return const _EmptySection(
|
||||||
title: 'Recently added',
|
title: 'Recently added',
|
||||||
message: "Nothing added yet. Scan a folder via the server's config.",
|
message: "Nothing added yet. Scan a folder via the server's config.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final controller = ScrollController();
|
final controller = ScrollController();
|
||||||
final rows = _chunk(albums, 25);
|
final rows = _chunk(ids, 25);
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
for (var i = 0; i < rows.length; i++)
|
for (var i = 0; i < rows.length; i++)
|
||||||
HorizontalScrollRow(
|
HorizontalScrollRow(
|
||||||
title: i == 0 ? 'Recently added' : '',
|
title: i == 0 ? 'Recently added' : '',
|
||||||
controller: controller,
|
controller: controller,
|
||||||
children: rows[i]
|
children: rows[i].map((id) => _AlbumTile(id: id)).toList(),
|
||||||
.map((a) => AlbumCard(
|
|
||||||
album: a,
|
|
||||||
onTap: () =>
|
|
||||||
context.push('/albums/${a.id}', extra: a),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RediscoverSection extends StatelessWidget {
|
class _RediscoverSection extends StatelessWidget {
|
||||||
const _RediscoverSection({required this.albums, required this.artists});
|
const _RediscoverSection({required this.albumIds, required this.artistIds});
|
||||||
final List<AlbumRef> albums;
|
final List<String> albumIds;
|
||||||
final List<ArtistRef> artists;
|
final List<String> artistIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (albums.isEmpty && artists.isEmpty) {
|
if (albumIds.isEmpty && artistIds.isEmpty) {
|
||||||
return const _EmptySection(
|
return const _EmptySection(
|
||||||
title: 'Rediscover',
|
title: 'Rediscover',
|
||||||
message:
|
message:
|
||||||
@@ -207,47 +446,35 @@ class _RediscoverSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
if (albums.isNotEmpty)
|
if (albumIds.isNotEmpty)
|
||||||
HorizontalScrollRow(
|
HorizontalScrollRow(
|
||||||
title: 'Rediscover',
|
title: 'Rediscover',
|
||||||
children: albums
|
children: albumIds.map((id) => _AlbumTile(id: id)).toList(),
|
||||||
.map((a) => AlbumCard(
|
|
||||||
album: a,
|
|
||||||
onTap: () =>
|
|
||||||
context.push('/albums/${a.id}', extra: a),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
if (artists.isNotEmpty)
|
if (artistIds.isNotEmpty)
|
||||||
HorizontalScrollRow(
|
HorizontalScrollRow(
|
||||||
title: albums.isEmpty ? 'Rediscover' : '',
|
title: albumIds.isEmpty ? 'Rediscover' : '',
|
||||||
height: 168,
|
height: 168,
|
||||||
children: artists
|
children: artistIds.map((id) => _ArtistTile(id: id)).toList(),
|
||||||
.map((ar) => ArtistCard(
|
|
||||||
artist: ar,
|
|
||||||
onTap: () =>
|
|
||||||
context.push('/artists/${ar.id}', extra: ar),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MostPlayedSection extends StatelessWidget {
|
class _MostPlayedSection extends StatelessWidget {
|
||||||
const _MostPlayedSection({required this.tracks});
|
const _MostPlayedSection({required this.ids});
|
||||||
final List<TrackRef> tracks;
|
final List<String> ids;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (tracks.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return const _EmptySection(
|
return const _EmptySection(
|
||||||
title: 'Most played',
|
title: 'Most played',
|
||||||
message: 'No plays to draw from. Listen to something.',
|
message: 'No plays to draw from. Listen to something.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final controller = ScrollController();
|
final controller = ScrollController();
|
||||||
final rows = _chunk(tracks, 25);
|
final rows = _chunk(ids, 25);
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
for (var i = 0; i < rows.length; i++)
|
for (var i = 0; i < rows.length; i++)
|
||||||
HorizontalScrollRow(
|
HorizontalScrollRow(
|
||||||
@@ -255,12 +482,7 @@ class _MostPlayedSection extends StatelessWidget {
|
|||||||
height: 64,
|
height: 64,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
children: [
|
children: [
|
||||||
for (var j = 0; j < rows[i].length; j++)
|
for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids),
|
||||||
CompactTrackCard(
|
|
||||||
track: rows[i][j],
|
|
||||||
sectionTracks: tracks,
|
|
||||||
index: i * 25 + j,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -268,12 +490,12 @@ class _MostPlayedSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LastPlayedSection extends StatelessWidget {
|
class _LastPlayedSection extends StatelessWidget {
|
||||||
const _LastPlayedSection({required this.artists});
|
const _LastPlayedSection({required this.ids});
|
||||||
final List<ArtistRef> artists;
|
final List<String> ids;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (artists.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return const _EmptySection(
|
return const _EmptySection(
|
||||||
title: 'Last played',
|
title: 'Last played',
|
||||||
message: 'No recent plays.',
|
message: 'No recent plays.',
|
||||||
@@ -282,13 +504,7 @@ class _LastPlayedSection extends StatelessWidget {
|
|||||||
return HorizontalScrollRow(
|
return HorizontalScrollRow(
|
||||||
title: 'Last played',
|
title: 'Last played',
|
||||||
height: 168,
|
height: 168,
|
||||||
children: artists
|
children: ids.map((id) => _ArtistTile(id: id)).toList(),
|
||||||
.map((ar) => ArtistCard(
|
|
||||||
artist: ar,
|
|
||||||
onTap: () =>
|
|
||||||
context.push('/artists/${ar.id}', extra: ar),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,11 +535,11 @@ class _EmptySection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
/// Cold-start skeleton — shown only between mount and the first
|
||||||
/// section titles + card-sized placeholders) so the page feels alive
|
/// homeIndexProvider emission (typically a single drift query + small
|
||||||
/// while /api/home is in flight, instead of a 30-second blank spinner.
|
/// REST round-trip). Each section then renders its own per-tile
|
||||||
/// Each section drops in independently as data arrives via the
|
/// skeletons internally, so this widget's role is just the very first
|
||||||
/// individual providers.
|
/// pre-discovery frame.
|
||||||
class _HomeSkeleton extends StatelessWidget {
|
class _HomeSkeleton extends StatelessWidget {
|
||||||
const _HomeSkeleton({required this.fs});
|
const _HomeSkeleton({required this.fs});
|
||||||
final FabledSwordTheme fs;
|
final FabledSwordTheme fs;
|
||||||
@@ -376,26 +592,7 @@ class _SkeletonSection extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
itemBuilder: (_, __) => Padding(
|
itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: coverSize,
|
|
||||||
height: coverSize,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: fs.slate,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
@@ -16,6 +17,7 @@ import '../cache/db.dart';
|
|||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/home_data.dart';
|
import '../models/home_data.dart';
|
||||||
|
import '../models/home_index.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
|
|
||||||
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
||||||
@@ -42,27 +44,217 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
|||||||
return LibraryApi(await ref.watch(dioProvider.future));
|
return LibraryApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
final homeProvider = FutureProvider<HomeData>((ref) async {
|
/// Drift-first home data (#357 follow-up). The home screen is the first
|
||||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
/// 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;
|
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
||||||
/// when empty + online, fetches via REST + populates drift, which
|
/// when empty + online, fetches via REST + populates drift, which
|
||||||
/// re-emits via watch().
|
/// re-emits via watch().
|
||||||
final artistProvider =
|
final artistProvider =
|
||||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
return cacheFirst<CachedArtist, ArtistRef>(
|
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
||||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
||||||
.watch(),
|
// first album.
|
||||||
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
return cacheFirst<drift.TypedResult, ArtistRef>(
|
||||||
|
driftStream: query.watch(),
|
||||||
fetchAndPopulate: () async {
|
fetchAndPopulate: () async {
|
||||||
final api = await ref.read(libraryApiProvider.future);
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
final fresh = await api.getArtist(id);
|
final fresh = await api.getArtist(id);
|
||||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||||
},
|
},
|
||||||
toResult: (rows) => rows.isEmpty
|
toResult: (rows) {
|
||||||
? const ArtistRef(id: '', name: '')
|
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
||||||
: rows.first.toRef(),
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
},
|
||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
@@ -101,6 +293,14 @@ final artistAlbumsProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.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)',
|
tag: 'artistAlbums($artistId)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -187,11 +387,9 @@ final albumProvider = StreamProvider.family<
|
|||||||
Future<bool> fetchAndPopulate() async {
|
Future<bool> fetchAndPopulate() async {
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(libraryApiProvider.future);
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
debugPrint('albumProvider($albumId): calling getAlbum');
|
|
||||||
final fresh = await api
|
final fresh = await api
|
||||||
.getAlbum(albumId)
|
.getAlbum(albumId)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
|
|
||||||
// Collect every artist mentioned by the album + its tracks so
|
// Collect every artist mentioned by the album + its tracks so
|
||||||
// the JOINs that drive both the album header and the track rows
|
// the JOINs that drive both the album header and the track rows
|
||||||
// have something to bind to. Without this, drift returns null
|
// have something to bind to. Without this, drift returns null
|
||||||
@@ -224,7 +422,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
b.insertAllOnConflictUpdate(db.cachedTracks,
|
b.insertAllOnConflictUpdate(db.cachedTracks,
|
||||||
fresh.tracks.map((t) => t.toDrift()).toList());
|
fresh.tracks.map((t) => t.toDrift()).toList());
|
||||||
});
|
});
|
||||||
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
||||||
@@ -235,7 +432,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
await for (final albumRows in albumQuery.watch()) {
|
await for (final albumRows in albumQuery.watch()) {
|
||||||
// Case 1: no album row at all → cold-fetch.
|
// Case 1: no album row at all → cold-fetch.
|
||||||
if (albumRows.isEmpty) {
|
if (albumRows.isEmpty) {
|
||||||
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
|
|
||||||
if (fetchAttempted) {
|
if (fetchAttempted) {
|
||||||
// Already tried and got nothing back.
|
// Already tried and got nothing back.
|
||||||
yield (
|
yield (
|
||||||
@@ -245,10 +441,7 @@ final albumProvider = StreamProvider.family<
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
final online = await isOnline();
|
if (!await isOnline()) {
|
||||||
debugPrint('albumProvider($albumId): online=$online');
|
|
||||||
if (!online) {
|
|
||||||
debugPrint('albumProvider($albumId): offline, yielding empty');
|
|
||||||
yield (
|
yield (
|
||||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
tracks: const <TrackRef>[],
|
tracks: const <TrackRef>[],
|
||||||
@@ -265,7 +458,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
// On success, drift watch re-emits with rows; loop continues.
|
// On success, drift watch re-emits with rows; loop continues.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
|
|
||||||
|
|
||||||
final albumRow = albumRows.first;
|
final albumRow = albumRows.first;
|
||||||
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
||||||
@@ -280,7 +472,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
// fetchAndPopulate so the album becomes complete; drift watch
|
// fetchAndPopulate so the album becomes complete; drift watch
|
||||||
// re-emits and we land in the populated branch on the next pass.
|
// re-emits and we land in the populated branch on the next pass.
|
||||||
if (trackRows.isEmpty && !fetchAttempted) {
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
|
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
final ok = await fetchAndPopulate();
|
final ok = await fetchAndPopulate();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -5,18 +8,27 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../api/endpoints/library_lists.dart';
|
import '../api/endpoints/library_lists.dart';
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../auth/auth_provider.dart' show authControllerProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/cache_first.dart';
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../cache/db.dart';
|
||||||
import '../cache/metadata_prefetcher.dart';
|
import '../cache/metadata_prefetcher.dart';
|
||||||
|
import '../cache/shuffle_source.dart';
|
||||||
|
import '../cache/tile_providers.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/history_event.dart';
|
import '../models/history_event.dart';
|
||||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
|
||||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
|
||||||
import '../models/page.dart' as wire;
|
|
||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.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/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/skeletons.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
import 'widgets/artist_card.dart';
|
import 'widgets/artist_card.dart';
|
||||||
@@ -38,109 +50,294 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
/// Drift-first all-artists list. Reads cached_artists ordered by
|
||||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
/// sortName; GridView.builder takes care of lazy widget construction
|
||||||
/// pages append to the existing items rather than replacing them.
|
/// so loading the full set up front is fine for typical library sizes.
|
||||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
/// SWR refresh on every subscription hits /api/artists with a generous
|
||||||
static const _pageSize = 50;
|
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
||||||
bool _loadingMore = false;
|
///
|
||||||
|
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
||||||
@override
|
/// previous "fetch one page at a time as you scroll" felt like the
|
||||||
Future<wire.Paged<ArtistRef>> build() async {
|
/// rest of the app was buffering when this tab was the slow surface,
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
/// and SyncController already populates the full set of artist rows
|
||||||
return api.listArtists(limit: _pageSize, offset: 0);
|
/// via /api/library/sync.
|
||||||
}
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
Future<void> loadMore() async {
|
// LEFT JOIN cached_albums so each artist row carries a representative
|
||||||
if (_loadingMore) return;
|
// album id for cover-URL reconstruction. Sorted by artist sortName
|
||||||
final cur = state.value;
|
// then album sortTitle: the first row per artist gets the
|
||||||
if (cur == null) return;
|
// alphabetically-first album. Dedup happens in toResult.
|
||||||
if (cur.items.length >= cur.total) return;
|
final query = db.select(db.cachedArtists).join([
|
||||||
_loadingMore = true;
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
try {
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([
|
||||||
|
drift.OrderingTerm.asc(db.cachedArtists.sortName),
|
||||||
|
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
|
||||||
|
]);
|
||||||
|
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
// Cold-cache fallback: sync should have populated drift already,
|
||||||
|
// but a fresh install + first Library visit before sync completes
|
||||||
|
// will be empty. Fetch a generous chunk via /api/artists and
|
||||||
|
// persist; subsequent SWR refreshes keep things current.
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
final next = await api.listArtists(
|
final fresh = await api.listArtists(limit: 1000, offset: 0);
|
||||||
limit: _pageSize,
|
await db.batch((b) {
|
||||||
offset: cur.items.length,
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedArtists,
|
||||||
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) {
|
||||||
|
// The join multiplies rows by album count; dedup by artist id
|
||||||
|
// keeping the first occurrence so we get one ArtistRef per
|
||||||
|
// artist with the alphabetically-first album's id for the
|
||||||
|
// cover-URL projection. Artists with no albums in drift yet
|
||||||
|
// come through as a single row with null cachedAlbums and an
|
||||||
|
// empty coverUrl — UI falls back to the placeholder icon.
|
||||||
|
final seen = <String>{};
|
||||||
|
final out = <ArtistRef>[];
|
||||||
|
for (final r in rows) {
|
||||||
|
final a = r.readTable(db.cachedArtists);
|
||||||
|
if (!seen.add(a.id)) continue;
|
||||||
|
final album = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryArtists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
||||||
|
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
||||||
|
/// pattern as libraryArtistsProvider.
|
||||||
|
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = db.select(db.cachedAlbums).join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.map((r) {
|
||||||
|
final album = r.readTable(db.cachedAlbums);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
return album.toRef(artistName: artist?.name ?? '');
|
||||||
|
}).toList(),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryAlbums',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
||||||
|
// last /api/me/history page as JSON in a single-row drift table, yield
|
||||||
|
// it immediately on subscribe (so the tab paints from disk on cold
|
||||||
|
// open), then SWR-refresh in the background. Also gives basic offline
|
||||||
|
// scrollback — the last fetched page survives connectivity loss.
|
||||||
|
//
|
||||||
|
// JSON blob (vs columnar) because the page is small, always read whole,
|
||||||
|
// and the HistoryPage.fromJson constructor already accepts the wire
|
||||||
|
// shape — no schema-evolution pain when server-side fields change.
|
||||||
|
final _historyProvider = StreamProvider<HistoryPage>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
|
||||||
|
driftStream: db.select(db.cachedHistorySnapshot).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(_meApiProvider.future);
|
||||||
|
final fresh = await api.history();
|
||||||
|
await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate(
|
||||||
|
CachedHistorySnapshotCompanion.insert(
|
||||||
|
json: _encodeHistoryPage(fresh),
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.isEmpty
|
||||||
|
? const HistoryPage(events: [], hasMore: false)
|
||||||
|
: HistoryPage.fromJson(
|
||||||
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: yield cache instantly, then refresh in the background so the
|
||||||
|
// tab reflects the freshest plays. Matches homeProvider behavior.
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'history',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Encodes HistoryPage back to the wire-format JSON shape
|
||||||
|
/// /api/me/history emits, so HistoryPage.fromJson can round-trip
|
||||||
|
/// through the drift cache.
|
||||||
|
String _encodeHistoryPage(HistoryPage h) => jsonEncode({
|
||||||
|
'events': h.events
|
||||||
|
.map((e) => {
|
||||||
|
'id': e.id,
|
||||||
|
'played_at': e.playedAt,
|
||||||
|
'track': {
|
||||||
|
'id': e.track.id,
|
||||||
|
'title': e.track.title,
|
||||||
|
'album_id': e.track.albumId,
|
||||||
|
'album_title': e.track.albumTitle,
|
||||||
|
'artist_id': e.track.artistId,
|
||||||
|
'artist_name': e.track.artistName,
|
||||||
|
'track_number': e.track.trackNumber,
|
||||||
|
'disc_number': e.track.discNumber,
|
||||||
|
'duration_sec': e.track.durationSec,
|
||||||
|
'stream_url': e.track.streamUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
'has_more': h.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-item Liked tabs (Slice E of the per-item rendering pass).
|
||||||
|
// Each provider yields just the ordered list of entity IDs; the UI
|
||||||
|
// then renders per-tile widgets that hydrate each entity individually
|
||||||
|
// via albumTileProvider / artistTileProvider / trackTileProvider.
|
||||||
|
//
|
||||||
|
// Reads come from cached_likes (sync- and optimistic-write-populated),
|
||||||
|
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
|
||||||
|
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
|
||||||
|
// that returned fully denormalized entities are no longer needed for
|
||||||
|
// this path since tile providers handle entity hydration themselves.
|
||||||
|
//
|
||||||
|
// likedAt ordering note: cached_likes.likedAt is whatever drift
|
||||||
|
// assigned via currentDateAndTime when the row was first inserted
|
||||||
|
// (either by sync or LikesController). insertOrIgnore on subsequent
|
||||||
|
// fetches preserves the existing likedAt so ordering stays stable.
|
||||||
|
// Approximate but acceptable — matches prior Slice 3 behavior.
|
||||||
|
|
||||||
|
List<String> _idsForEntity(List<CachedLike> rows) =>
|
||||||
|
rows.map((r) => r.entityId).toList(growable: false);
|
||||||
|
|
||||||
|
final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('track'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedTrackIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('album'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedAlbumIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('artist'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedArtistIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shared cold-cache populator: hits /api/likes/ids once and writes
|
||||||
|
/// rows for all three entity types via insertOrIgnore. The three
|
||||||
|
/// providers above all trigger this on their first empty-drift
|
||||||
|
/// emission; the dedup in cacheFirst's revalidate state plus drift's
|
||||||
|
/// insertOrIgnore semantics make the multiple-trigger case cheap.
|
||||||
|
Future<void> _populateLikeIds(Ref ref) async {
|
||||||
|
final api = await ref.read(_likesApiProvider.future);
|
||||||
|
final user = ref.read(authControllerProvider).value;
|
||||||
|
if (user == null) return;
|
||||||
|
final fresh = await api.ids();
|
||||||
|
final db = ref.read(appDbProvider);
|
||||||
|
await db.batch((b) {
|
||||||
|
for (final id in fresh.tracks) {
|
||||||
|
b.insert(
|
||||||
|
db.cachedLikes,
|
||||||
|
CachedLikesCompanion.insert(
|
||||||
|
userId: user.id,
|
||||||
|
entityType: 'track',
|
||||||
|
entityId: id,
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
);
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
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 _libraryArtistsProvider = AsyncNotifierProvider<
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
||||||
_LibraryArtistsNotifier,
|
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
||||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
// the Hidden tab keep one source of truth.
|
||||||
|
|
||||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
|
||||||
static const _pageSize = 50;
|
|
||||||
bool _loadingMore = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<wire.Paged<AlbumRef>> build() async {
|
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
|
||||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadMore() async {
|
|
||||||
if (_loadingMore) return;
|
|
||||||
final cur = state.value;
|
|
||||||
if (cur == null) return;
|
|
||||||
if (cur.items.length >= cur.total) return;
|
|
||||||
_loadingMore = true;
|
|
||||||
try {
|
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
|
||||||
final next = await api.listAlbums(
|
|
||||||
limit: _pageSize,
|
|
||||||
offset: cur.items.length,
|
|
||||||
);
|
|
||||||
state = AsyncData(wire.Paged(
|
|
||||||
items: [...cur.items, ...next.items],
|
|
||||||
total: next.total,
|
|
||||||
limit: next.limit,
|
|
||||||
offset: 0,
|
|
||||||
));
|
|
||||||
} catch (_) {
|
|
||||||
// Swallow — next scroll event will retry.
|
|
||||||
} finally {
|
|
||||||
_loadingMore = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
|
||||||
_LibraryAlbumsNotifier,
|
|
||||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
|
||||||
|
|
||||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
|
||||||
return (await ref.watch(_meApiProvider.future)).history();
|
|
||||||
});
|
|
||||||
|
|
||||||
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
|
||||||
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
|
||||||
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
|
||||||
});
|
|
||||||
|
|
||||||
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
|
||||||
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
|
||||||
});
|
|
||||||
|
|
||||||
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
|
|
||||||
return (await ref.watch(_meApiProvider.future)).quarantineMine();
|
|
||||||
});
|
|
||||||
|
|
||||||
class LibraryScreen extends ConsumerStatefulWidget {
|
class LibraryScreen extends ConsumerStatefulWidget {
|
||||||
const LibraryScreen({super.key});
|
const LibraryScreen({super.key});
|
||||||
@@ -162,13 +359,46 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// Pre-warm every tab's provider on Library mount so swiping
|
||||||
|
// between tabs feels instant rather than each tab paying its
|
||||||
|
// own cold-cache cost on first visit. ref.listen subscribes
|
||||||
|
// without rebuilding LibraryScreen on emit; subscriptions stay
|
||||||
|
// alive for the lifetime of this widget. cacheFirst handles
|
||||||
|
// dedupe of concurrent fetchAndPopulate triggers.
|
||||||
|
ref.listen(_libraryArtistsProvider, (_, __) {});
|
||||||
|
ref.listen(_libraryAlbumsProvider, (_, __) {});
|
||||||
|
ref.listen(_historyProvider, (_, __) {});
|
||||||
|
ref.listen(_likedTrackIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedAlbumIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedArtistIdsProvider, (_, __) {});
|
||||||
|
ref.listen(myQuarantineProvider, (_, __) {});
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||||
actions: const [MainAppBarActions(currentRoute: '/library')],
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
key: const Key('shuffle_all_button'),
|
||||||
|
tooltip: 'Shuffle all',
|
||||||
|
icon: Icon(Icons.shuffle, color: fs.parchment),
|
||||||
|
onPressed: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final refs = await ref.read(shuffleSourceProvider).tracks();
|
||||||
|
if (refs.isEmpty) {
|
||||||
|
messenger.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Nothing to shuffle yet'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(refs, shuffle: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const MainAppBarActions(currentRoute: '/library'),
|
||||||
|
],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
controller: _ctrl,
|
controller: _ctrl,
|
||||||
isScrollable: true,
|
isScrollable: true,
|
||||||
@@ -208,51 +438,56 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryArtistsProvider).when(
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (artists) {
|
||||||
// Warm details for the first screenful so taps are instant.
|
// Warm details for the first screenful so taps are instant.
|
||||||
ref
|
ref
|
||||||
.read(metadataPrefetcherProvider)
|
.read(metadataPrefetcherProvider)
|
||||||
.warmArtists(page.items.map((a) => a.id));
|
.warmArtists(artists.map((a) => a.id));
|
||||||
return page.items.isEmpty
|
return artists.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||||
ref.invalidate(_libraryArtistsProvider);
|
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
||||||
await ref.read(_libraryArtistsProvider.future);
|
// the AlbumsTab pattern so the circular avatar stays
|
||||||
},
|
// a true circle on narrow grid cells.
|
||||||
// Listen for scroll-near-bottom and ask the notifier
|
//
|
||||||
// to fetch the next page. 800px lookahead so the next
|
// Drift-first: GridView holds the full sorted list;
|
||||||
// page lands before the user reaches the visible end.
|
// .builder lazily realizes only visible cells, so
|
||||||
child: NotificationListener<ScrollNotification>(
|
// even on large libraries the up-front cost is just
|
||||||
onNotification: (n) {
|
// a sort over cached_artists, not N network round
|
||||||
if (n.metrics.pixels >=
|
// trips.
|
||||||
n.metrics.maxScrollExtent - 800) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
ref
|
const cols = 3;
|
||||||
.read(_libraryArtistsProvider.notifier)
|
const sidePad = 8.0;
|
||||||
.loadMore();
|
const gap = 8.0;
|
||||||
}
|
final cellW = (constraints.maxWidth -
|
||||||
return false;
|
sidePad * 2 -
|
||||||
},
|
gap * (cols - 1)) /
|
||||||
child: GridView.builder(
|
cols;
|
||||||
padding: const EdgeInsets.all(8),
|
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
||||||
|
// + slack matched to AlbumsTab's overflow guard.
|
||||||
|
final cellH = (cellW - 16) + 8 + 18 + 8;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount: cols,
|
||||||
mainAxisSpacing: 8,
|
mainAxisExtent: cellH,
|
||||||
crossAxisSpacing: 8,
|
mainAxisSpacing: gap,
|
||||||
childAspectRatio: 0.78,
|
crossAxisSpacing: gap,
|
||||||
),
|
),
|
||||||
itemCount: page.items.length,
|
itemCount: artists.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final artist = page.items[i];
|
final artist = artists[i];
|
||||||
return ArtistCard(
|
return ArtistCard(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
|
width: cellW,
|
||||||
onTap: () => ctx.push('/artists/${artist.id}',
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
extra: artist),
|
extra: artist),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -268,17 +503,16 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryAlbumsProvider).when(
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (albums) {
|
||||||
return page.items.isEmpty
|
return albums.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||||
ref.invalidate(_libraryAlbumsProvider);
|
|
||||||
await ref.read(_libraryAlbumsProvider.future);
|
|
||||||
},
|
|
||||||
// Same responsive 3-up grid as the artist detail
|
// Same responsive 3-up grid as the artist detail
|
||||||
// album list — LayoutBuilder + AlbumCard sized to the
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
// cell, mainAxisExtent matched to actual card height.
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
|
// Drift-first; full sorted list, lazy realization via
|
||||||
|
// GridView.builder.
|
||||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
const cols = 3;
|
const cols = 3;
|
||||||
const sidePad = 8.0;
|
const sidePad = 8.0;
|
||||||
@@ -293,37 +527,26 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
// would otherwise overflow the cell by a pixel
|
// would otherwise overflow the cell by a pixel
|
||||||
// (logged as a noisy RenderFlex warning).
|
// (logged as a noisy RenderFlex warning).
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||||
return NotificationListener<ScrollNotification>(
|
return GridView.builder(
|
||||||
onNotification: (n) {
|
padding: const EdgeInsets.all(sidePad),
|
||||||
if (n.metrics.pixels >=
|
gridDelegate:
|
||||||
n.metrics.maxScrollExtent - 800) {
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
ref
|
crossAxisCount: cols,
|
||||||
.read(_libraryAlbumsProvider.notifier)
|
mainAxisExtent: cellH,
|
||||||
.loadMore();
|
mainAxisSpacing: gap,
|
||||||
}
|
crossAxisSpacing: gap,
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = albums[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -373,79 +596,82 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final tracksA = ref.watch(_likedTracksProvider);
|
// SSE wire-up: any cross-device like / unlike triggers a refresh
|
||||||
final albumsA = ref.watch(_likedAlbumsProvider);
|
// of the discovery providers. LikesController handles local
|
||||||
final artistsA = ref.watch(_likedArtistsProvider);
|
// 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) {
|
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
final t = tracksA.value;
|
final trackIds = tracksA.value;
|
||||||
final al = albumsA.value;
|
final albumIds = albumsA.value;
|
||||||
final ar = artistsA.value;
|
final artistIds = artistsA.value;
|
||||||
if (t == null || al == null || ar == null) {
|
if (trackIds == null || albumIds == null || artistIds == null) {
|
||||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||||
}
|
}
|
||||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
if (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 Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
ref.refresh(_likedTracksProvider.future),
|
ref.refresh(_likedTrackIdsProvider.future),
|
||||||
ref.refresh(_likedAlbumsProvider.future),
|
ref.refresh(_likedAlbumIdsProvider.future),
|
||||||
ref.refresh(_likedArtistsProvider.future),
|
ref.refresh(_likedArtistIdsProvider.future),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
child: ListView(children: [
|
child: ListView(children: [
|
||||||
if (ar.items.isNotEmpty) ...[
|
if (artistIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Artists', count: ar.total),
|
_SectionHeader(label: 'Artists', count: artistIds.length),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 168,
|
height: 168,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: ar.items.length,
|
itemCount: artistIds.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) =>
|
||||||
final artist = ar.items[i];
|
_LikedArtistTile(id: artistIds[i]),
|
||||||
return ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
onTap: () =>
|
|
||||||
ctx.push('/artists/${artist.id}', extra: artist),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (al.items.isNotEmpty) ...[
|
if (albumIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Albums', count: al.total),
|
_SectionHeader(label: 'Albums', count: albumIds.length),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: al.items.length,
|
itemCount: albumIds.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) =>
|
||||||
final album = al.items[i];
|
_LikedAlbumTile(id: albumIds[i]),
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
onTap: () =>
|
|
||||||
ctx.push('/albums/${album.id}', extra: album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (t.items.isNotEmpty) ...[
|
if (trackIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Tracks', count: t.total),
|
_SectionHeader(label: 'Tracks', count: trackIds.length),
|
||||||
...t.items.asMap().entries.map((e) {
|
...trackIds.map(
|
||||||
return TrackRow(
|
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
|
||||||
track: e.value,
|
),
|
||||||
onTap: () => ref
|
|
||||||
.read(playerActionsProvider)
|
|
||||||
.playTracks(t.items, initialIndex: e.key),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 96),
|
const SizedBox(height: 96),
|
||||||
]),
|
]),
|
||||||
@@ -453,13 +679,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 {
|
class _HiddenTab extends ConsumerWidget {
|
||||||
const _HiddenTab();
|
const _HiddenTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return ref.watch(_quarantineProvider).when(
|
return ref.watch(myQuarantineProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (rows) => rows.isEmpty
|
data: (rows) => rows.isEmpty
|
||||||
@@ -471,7 +786,7 @@ class _HiddenTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_quarantineProvider.future),
|
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||||
@@ -482,12 +797,12 @@ class _HiddenTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuarantineTile extends StatelessWidget {
|
class _QuarantineTile extends ConsumerWidget {
|
||||||
const _QuarantineTile({required this.row});
|
const _QuarantineTile({required this.row});
|
||||||
final QuarantineMineRow row;
|
final QuarantineMineRow row;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final reasonLabel = switch (row.reason) {
|
final reasonLabel = switch (row.reason) {
|
||||||
'bad_rip' => 'Bad rip',
|
'bad_rip' => 'Bad rip',
|
||||||
@@ -496,9 +811,25 @@ class _QuarantineTile extends StatelessWidget {
|
|||||||
'duplicate' => 'Duplicate',
|
'duplicate' => 'Duplicate',
|
||||||
_ => 'Other',
|
_ => 'Other',
|
||||||
};
|
};
|
||||||
|
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
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(
|
Expanded(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -515,17 +846,41 @@ class _QuarantineTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Container(
|
child: Row(children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
color: fs.iron,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
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,11 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
import '../../shared/widgets/server_image.dart';
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import '../library_providers.dart';
|
||||||
|
import 'play_circle_button.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends ConsumerWidget {
|
||||||
const AlbumCard({
|
const AlbumCard({
|
||||||
required this.album,
|
required this.album,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -32,7 +36,7 @@ class AlbumCard extends StatelessWidget {
|
|||||||
final bool showArtist;
|
final bool showArtist;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final coverSize = width - 16;
|
final coverSize = width - 16;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -47,17 +51,29 @@ class AlbumCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
// Stack: cover image + overlaid play button at bottom-right.
|
||||||
borderRadius: BorderRadius.circular(6),
|
Stack(
|
||||||
child: Container(
|
children: [
|
||||||
width: coverSize,
|
ClipRRect(
|
||||||
height: coverSize,
|
borderRadius: BorderRadius.circular(6),
|
||||||
color: fs.slate,
|
child: Container(
|
||||||
child: album.coverUrl.isEmpty
|
width: coverSize,
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
height: coverSize,
|
||||||
fit: BoxFit.cover)
|
color: fs.slate,
|
||||||
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -80,4 +96,17 @@ class AlbumCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,20 +1,38 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
import '../../shared/widgets/server_image.dart';
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import '../library_providers.dart';
|
||||||
|
import 'play_circle_button.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends ConsumerWidget {
|
||||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
const ArtistCard({
|
||||||
|
required this.artist,
|
||||||
|
required this.onTap,
|
||||||
|
this.width = 140,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
final ArtistRef artist;
|
final ArtistRef artist;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Outer width of the card. Avatar is a circle of width-16 (8dp
|
||||||
|
/// horizontal padding either side). Default suits horizontal lists;
|
||||||
|
/// grids should pass the cell width so the avatar shrinks
|
||||||
|
/// proportionally and stays a true circle.
|
||||||
|
final double width;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final coverSize = width - 16;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 140,
|
width: width,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -22,16 +40,36 @@ class ArtistCard extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
ClipOval(
|
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||||
child: Container(
|
// Avatar size derives from the [width] parameter so the
|
||||||
width: 124,
|
// Library Artists 3-column grid can pass its cell width
|
||||||
height: 124,
|
// (~109dp on typical phones) and the avatar stays a
|
||||||
color: fs.slate,
|
// perfect circle. Previous hardcoded 124×124 got squeezed
|
||||||
child: artist.coverUrl.isEmpty
|
// horizontally in the grid (cell narrower than 124+16
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
// padding) but kept its 124dp height — ClipOval produced
|
||||||
fit: BoxFit.cover)
|
// a visible vertical ellipse. Mirrors AlbumCard's width-
|
||||||
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
// parameter convention.
|
||||||
),
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
color: fs.slate,
|
||||||
|
child: artist.coverUrl.isEmpty
|
||||||
|
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||||
|
fit: BoxFit.cover)
|
||||||
|
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
child: PlayCircleButton(
|
||||||
|
onPressed: () => _playArtistShuffle(ref),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -46,4 +84,18 @@ class ArtistCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../models/track.dart';
|
import '../../models/track.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
import 'cached_indicator.dart';
|
import 'cached_indicator.dart';
|
||||||
|
|
||||||
class TrackRow extends StatelessWidget {
|
class TrackRow extends ConsumerWidget {
|
||||||
const TrackRow({
|
const TrackRow({
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -23,56 +25,75 @@ class TrackRow extends StatelessWidget {
|
|||||||
final bool actions;
|
final bool actions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
|
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||||
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
|
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
|
||||||
return InkWell(
|
// Watch the currently-playing media item so the row's accent
|
||||||
onTap: onTap,
|
// highlight tracks playback state. Matches _QueueRow's visual
|
||||||
child: Padding(
|
// treatment in queue_screen.dart so the "you are here" cue is
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
// consistent across album / playlist / queue surfaces.
|
||||||
child: Row(children: [
|
final currentId = ref.watch(mediaItemProvider).value?.id;
|
||||||
if (track.trackNumber != null)
|
final isCurrent = currentId != null && currentId == track.id;
|
||||||
SizedBox(
|
return Container(
|
||||||
width: 22,
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: isCurrent ? fs.iron : null,
|
||||||
track.trackNumber.toString(),
|
border: isCurrent
|
||||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||||
),
|
: null,
|
||||||
),
|
),
|
||||||
Expanded(
|
child: InkWell(
|
||||||
child: Column(
|
onTap: onTap,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
children: [
|
child: Row(children: [
|
||||||
Text(
|
if (track.trackNumber != null)
|
||||||
track.title,
|
SizedBox(
|
||||||
maxLines: 1,
|
width: 22,
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
track.trackNumber.toString(),
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
),
|
),
|
||||||
// Skip the artist line entirely when empty so the row
|
),
|
||||||
// height collapses to a single line of title — keeps
|
Expanded(
|
||||||
// dense album views from looking padded.
|
child: Column(
|
||||||
if (track.artistName.isNotEmpty)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
track.artistName,
|
track.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
CachedIndicator(trackId: track.id),
|
||||||
CachedIndicator(trackId: track.id),
|
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
if (trailing != null)
|
||||||
if (trailing != null)
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.only(left: 8),
|
||||||
padding: const EdgeInsets.only(left: 8),
|
child: trailing!,
|
||||||
child: trailing!,
|
),
|
||||||
),
|
if (actions) TrackActionsButton(track: track),
|
||||||
if (actions) TrackActionsButton(track: track),
|
]),
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../cache/audio_cache_manager.dart' show appDbProvider;
|
|||||||
import '../cache/cache_first.dart';
|
import '../cache/cache_first.dart';
|
||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart';
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
@@ -147,26 +148,18 @@ class LikesController {
|
|||||||
} else {
|
} else {
|
||||||
await api.like(kind, id);
|
await api.like(kind, id);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
// Rollback drift
|
// REST failed (network or HTTP). Don't roll back drift — the
|
||||||
if (wasLiked) {
|
// user's intent is to like/unlike, and we want that visible to
|
||||||
await db.into(db.cachedLikes).insert(
|
// them even when offline. Queue the call for replay; the
|
||||||
CachedLikesCompanion.insert(
|
// MutationReplayer will retry on next connectivity transition.
|
||||||
userId: user.id,
|
// If retries exhaust (5 attempts), the row drops and the next
|
||||||
entityType: entityType,
|
// SyncController.sync brings drift back in line with the
|
||||||
entityId: id,
|
// server's authoritative state.
|
||||||
),
|
await _ref.read(mutationQueueProvider).enqueue(
|
||||||
mode: drift.InsertMode.insertOrIgnore,
|
wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd,
|
||||||
);
|
{'kind': entityType, 'id': id},
|
||||||
} else {
|
);
|
||||||
await (db.delete(db.cachedLikes)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(user.id) &
|
|
||||||
t.entityType.equals(entityType) &
|
|
||||||
t.entityId.equals(id)))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
Error.throwWithStackTrace(e, st);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,16 @@ class Playlist {
|
|||||||
|
|
||||||
bool get isSystem => systemVariant != null;
|
bool get isSystem => systemVariant != null;
|
||||||
|
|
||||||
|
/// Whether this playlist supports the generic by-kind refresh/
|
||||||
|
/// shuffle endpoints (#411 R2) — i.e. a singleton system kind.
|
||||||
|
/// The server exposes a `refreshable` flag for JSON-sourced
|
||||||
|
/// playlists, but the list tiles are drift-cache-sourced (no
|
||||||
|
/// migration just for this), so derive it: every system kind is a
|
||||||
|
/// singleton except songs_like_artist (multi-per-user). This rule
|
||||||
|
/// holds for For-You/Discover and all planned discovery mixes; if
|
||||||
|
/// a future non-singleton kind appears, extend the exclusion.
|
||||||
|
bool get refreshable => isSystem && systemVariant != 'songs_like_artist';
|
||||||
|
|
||||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||||
id: j['id'] as String,
|
id: j['id'] as String,
|
||||||
userId: j['user_id'] as String? ?? '',
|
userId: j['user_id'] as String? ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Dominant-color extraction from album cover art (#396 item 1).
|
||||||
|
// The full-screen Now Playing screen uses this to paint a top-to-bottom
|
||||||
|
// gradient backdrop that grounds each track in its album's palette.
|
||||||
|
//
|
||||||
|
// Implementation: reuses AlbumCoverCache to get a local file path for
|
||||||
|
// the cover, then runs PaletteGenerator over it. Results are cached
|
||||||
|
// in-memory keyed by album_id so back-to-back plays of the same album
|
||||||
|
// don't repeat the work. Cache is process-lifetime; album-art changes
|
||||||
|
// are rare enough that LRU eviction isn't worth the complexity.
|
||||||
|
//
|
||||||
|
// Returns null on any failure (no cover, empty album_id, palette
|
||||||
|
// extraction returned nothing). Callers fall back to the FabledSword
|
||||||
|
// obsidian color for the gradient when null.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
|
||||||
|
import 'player_provider.dart';
|
||||||
|
|
||||||
|
/// Caches dominant colors keyed by album_id. Process-lifetime; refilled
|
||||||
|
/// on app restart.
|
||||||
|
class AlbumColorCache {
|
||||||
|
AlbumColorCache(this._ref);
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
final Map<String, Color?> _cache = {};
|
||||||
|
|
||||||
|
/// Returns the dominant color for the album's cover, or null if no
|
||||||
|
/// cover is available or extraction fails. Same call for the same
|
||||||
|
/// album_id is cached after the first successful resolution.
|
||||||
|
Future<Color?> getOrExtract(String albumId) async {
|
||||||
|
if (albumId.isEmpty) return null;
|
||||||
|
if (_cache.containsKey(albumId)) return _cache[albumId];
|
||||||
|
final color = await _extract(albumId);
|
||||||
|
_cache[albumId] = color;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous peek. Returns the previously-extracted color if this
|
||||||
|
/// album has been resolved this process; otherwise null. Used by
|
||||||
|
/// the now-playing fast-path swap so a warm cache transitions the
|
||||||
|
/// gradient in lockstep with the audio, instead of awaiting the
|
||||||
|
/// async [getOrExtract] future. A null return means "either no
|
||||||
|
/// extraction yet or extraction returned null" — caller falls back
|
||||||
|
/// to the async path.
|
||||||
|
Color? peekColor(String albumId) => _cache[albumId];
|
||||||
|
|
||||||
|
Future<Color?> _extract(String albumId) async {
|
||||||
|
try {
|
||||||
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final path = await coverCache.getOrFetch(albumId);
|
||||||
|
if (path == null) return null;
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
|
FileImage(file),
|
||||||
|
// Small target size: palette extraction is CPU-bound and the
|
||||||
|
// gradient only needs a single dominant color, so we don't
|
||||||
|
// need full-resolution sampling.
|
||||||
|
size: const Size(80, 80),
|
||||||
|
maximumColorCount: 8,
|
||||||
|
);
|
||||||
|
// Prefer the explicit dominant color; fall back to the strongest
|
||||||
|
// muted swatch (which tends to read better as a background than
|
||||||
|
// a hot vibrant pick), then any populated swatch.
|
||||||
|
final swatch = palette.dominantColor ??
|
||||||
|
palette.darkMutedColor ??
|
||||||
|
palette.darkVibrantColor ??
|
||||||
|
palette.mutedColor;
|
||||||
|
return swatch?.color;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumColorCacheProvider = Provider<AlbumColorCache>(
|
||||||
|
(ref) => AlbumColorCache(ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Family provider: dominant color for the given album_id, or null if
|
||||||
|
/// no cover / extraction failed. The Now Playing screen watches this
|
||||||
|
/// keyed by the current MediaItem's album_id.
|
||||||
|
final albumColorProvider = FutureProvider.family<Color?, String>(
|
||||||
|
(ref, albumId) async {
|
||||||
|
final cache = ref.watch(albumColorCacheProvider);
|
||||||
|
return cache.getOrExtract(albumId);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -27,6 +27,32 @@ class AlbumCoverCache {
|
|||||||
/// same album dedupe to one fetch.
|
/// same album dedupe to one fetch.
|
||||||
final Map<String, Future<String?>> _inflight = {};
|
final Map<String, Future<String?>> _inflight = {};
|
||||||
|
|
||||||
|
/// Cached covers directory path resolved once on the first
|
||||||
|
/// async cacheDir call, then reused for sync existsSync() checks
|
||||||
|
/// in [peekCached]. Without this every "is the cover already on
|
||||||
|
/// disk?" check would have to await path_provider, blocking the
|
||||||
|
/// MediaItem broadcast on every track change.
|
||||||
|
String? _coversDirPath;
|
||||||
|
|
||||||
|
/// Returns the local file path for [albumId]'s cover if it's
|
||||||
|
/// already cached on disk, or null otherwise. Synchronous — uses
|
||||||
|
/// File.existsSync() against a path computed from the directory
|
||||||
|
/// resolved by an earlier async [getOrFetch] call. Returns null
|
||||||
|
/// until at least one getOrFetch has populated _coversDirPath.
|
||||||
|
///
|
||||||
|
/// The audio handler uses this to seed MediaItem.artUri on the
|
||||||
|
/// initial broadcast so external media controllers (Wear, Android
|
||||||
|
/// Auto, Bluetooth) see the cover on the first frame for warm-cache
|
||||||
|
/// tracks. Cold-cache tracks still fall back to the async
|
||||||
|
/// _loadArtForCurrentItem path.
|
||||||
|
String? peekCached(String albumId) {
|
||||||
|
if (albumId.isEmpty) return null;
|
||||||
|
final dir = _coversDirPath;
|
||||||
|
if (dir == null) return null;
|
||||||
|
final path = '$dir/$albumId.jpg';
|
||||||
|
return File(path).existsSync() ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns local file path to the album cover, or null on any
|
/// Returns local file path to the album cover, or null on any
|
||||||
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
||||||
Future<String?> getOrFetch(String albumId) {
|
Future<String?> getOrFetch(String albumId) {
|
||||||
@@ -44,6 +70,9 @@ class AlbumCoverCache {
|
|||||||
final dir = await _cacheDirFactory();
|
final dir = await _cacheDirFactory();
|
||||||
final coversDir = Directory('${dir.path}/album_covers');
|
final coversDir = Directory('${dir.path}/album_covers');
|
||||||
await coversDir.create(recursive: true);
|
await coversDir.create(recursive: true);
|
||||||
|
// Cache the resolved directory so [peekCached] can do its
|
||||||
|
// existsSync() check without re-awaiting path_provider.
|
||||||
|
_coversDirPath = coversDir.path;
|
||||||
final filePath = '${coversDir.path}/$albumId.jpg';
|
final filePath = '${coversDir.path}/$albumId.jpg';
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
if (await file.exists()) return filePath;
|
if (await file.exists()) return filePath;
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
_player.playbackEventStream.listen(
|
_player.playbackEventStream.listen(
|
||||||
_broadcastState,
|
_broadcastState,
|
||||||
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||||
// failure, network drop) here. Without an error sink, the
|
// failure, network drop) here. Logging alone leaves the UI
|
||||||
// player just goes quiet — exactly the "starts then stops"
|
// claiming "now playing X" while audio is silent — the user-
|
||||||
// symptom we hit.
|
// observable mismatch between visual and audio state. Skip
|
||||||
|
// forward so the queue advances past the failed track and
|
||||||
|
// mediaItem updates to whatever's actually playing. If the
|
||||||
|
// failure is at the queue tail, just_audio will go idle on its
|
||||||
|
// own and _broadcastState reflects that.
|
||||||
onError: (Object e, StackTrace st) {
|
onError: (Object e, StackTrace st) {
|
||||||
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||||
|
unawaited(_handlePlaybackError());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||||
@@ -33,15 +38,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
// the index and the eviction loop can't reclaim them.
|
// the index and the eviction loop can't reclaim them.
|
||||||
_player.bufferedPositionStream
|
_player.bufferedPositionStream
|
||||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||||
// Diagnostic: log every player-state transition so silent stops
|
|
||||||
// surface as something we can read in the log.
|
|
||||||
_player.playerStateStream.listen((s) {
|
|
||||||
debugPrint(
|
|
||||||
'audio_handler: state playing=${s.playing} processing=${s.processingState}');
|
|
||||||
});
|
|
||||||
_player.processingStateStream.listen((ps) {
|
|
||||||
debugPrint('audio_handler: processingState=$ps');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -49,6 +45,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
String? _token;
|
String? _token;
|
||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
AudioCacheManager? _audioCacheManager;
|
||||||
|
LikeBridge? _likeBridge;
|
||||||
|
|
||||||
/// Trackers to dedupe registration — once we've inserted an index row
|
/// Trackers to dedupe registration — once we've inserted an index row
|
||||||
/// for a trackId, don't repeat the work on every buffered-position
|
/// for a trackId, don't repeat the work on every buffered-position
|
||||||
@@ -60,6 +57,40 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
/// time the buffered-position stream emits (~200ms cadence).
|
/// time the buffered-position stream emits (~200ms cadence).
|
||||||
String? _cacheDirPath;
|
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;
|
||||||
|
|
||||||
|
/// Tracks the most recent setQueueFromTracks() input so
|
||||||
|
/// skipToQueueItem can reconstruct the source list. just_audio
|
||||||
|
/// requires every source to be built before it can be a skip
|
||||||
|
/// target, but setQueueFromTracks only builds the initial source
|
||||||
|
/// and fills the rest in the background — so a skip to an index
|
||||||
|
/// past the fill front needs to rebuild from the stored tracks.
|
||||||
|
List<TrackRef> _lastTracks = const [];
|
||||||
|
|
||||||
|
/// #415: which system playlist this queue was seeded from
|
||||||
|
/// ('for_you' | 'discover'), or null for library / user-playlist /
|
||||||
|
/// radio. The play-events reporter reads this so play_started
|
||||||
|
/// carries `source` and the server advances that rotation. A fresh
|
||||||
|
/// setQueueFromTracks from a non-system surface clears it; internal
|
||||||
|
/// rebuilds (skipToQueueItem) preserve it.
|
||||||
|
String? _queueSource;
|
||||||
|
String? get queueSource => _queueSource;
|
||||||
|
|
||||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||||
/// volume directly; set via setVolume(double).
|
/// volume directly; set via setVolume(double).
|
||||||
Stream<double> get volumeStream => _player.volumeStream;
|
Stream<double> get volumeStream => _player.volumeStream;
|
||||||
@@ -78,43 +109,148 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
required String? token,
|
required String? token,
|
||||||
AlbumCoverCache? coverCache,
|
AlbumCoverCache? coverCache,
|
||||||
AudioCacheManager? audioCacheManager,
|
AudioCacheManager? audioCacheManager,
|
||||||
|
LikeBridge? likeBridge,
|
||||||
}) {
|
}) {
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
_token = token;
|
_token = token;
|
||||||
if (coverCache != null) _coverCache = coverCache;
|
if (coverCache != null) _coverCache = coverCache;
|
||||||
|
if (likeBridge != null) _likeBridge = likeBridge;
|
||||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||||
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
|
||||||
'tokenPresent=${token != null && token.isNotEmpty} '
|
|
||||||
'coverCachePresent=${_coverCache != null} '
|
|
||||||
'audioCachePresent=${_audioCacheManager != null}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> setQueueFromTracks(
|
||||||
final items = tracks.map(_toMediaItem).toList();
|
List<TrackRef> tracks, {
|
||||||
queue.add(items);
|
int initialIndex = 0,
|
||||||
if (items.isNotEmpty) {
|
String? source,
|
||||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
}) async {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
_queueSource = source;
|
||||||
|
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;
|
||||||
|
_lastTracks = tracks;
|
||||||
|
|
||||||
|
// Pause the old source immediately so the previous track stops
|
||||||
|
// audibly the moment the user taps, instead of bleeding through
|
||||||
|
// until the new source finishes building. setAudioSources below
|
||||||
|
// will swap the source list cleanly; pause is the simplest way
|
||||||
|
// to silence the player during the (possibly multi-100ms) build.
|
||||||
|
if (_player.playing) {
|
||||||
|
await _player.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('audio_handler.setQueueFromTracks: '
|
// Build MediaItems up front (pure — no side effects); we'll
|
||||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
// broadcast queue/mediaItem only AFTER setAudioSources resolves
|
||||||
final sw = Stopwatch()..start();
|
// so the audio engine and the UI flip together. If the build
|
||||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
// throws, the UI stays on the previous track (correct: audio
|
||||||
debugPrint('audio_handler: built ${sources.length} sources in '
|
// also stays on the previous track since setAudioSources never
|
||||||
'${sw.elapsedMilliseconds}ms');
|
// ran).
|
||||||
sw.reset();
|
final items = tracks.map(_toMediaItem).toList();
|
||||||
sw.start();
|
|
||||||
|
|
||||||
await _player.setAudioSources(
|
// Build only the initial source for fast start. Remaining
|
||||||
sources,
|
// sources stream in via _fillRemainingSources() — addAudioSource
|
||||||
initialIndex: initialIndex,
|
// for next/auto-advance tracks, insertAudioSource for skipPrev.
|
||||||
);
|
final AudioSource initial;
|
||||||
debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms');
|
try {
|
||||||
|
initial = await _buildAudioSource(tracks[clampedInitial]);
|
||||||
|
} catch (e, st) {
|
||||||
|
// Source build failed (bad URL, missing baseUrl, etc.). Don't
|
||||||
|
// broadcast the new state — leaving queue/mediaItem on the
|
||||||
|
// previous track keeps UI in sync with what the player is
|
||||||
|
// actually doing (which is "still on the previous track,
|
||||||
|
// paused").
|
||||||
|
debugPrint('audio_handler: _buildAudioSource failed: $e\n$st');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (myGen != _queueGeneration) {
|
||||||
|
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress _onCurrentIndexChanged side effects while the source
|
||||||
|
// list is being swapped — without this, a transient currentIndex
|
||||||
|
// emission during setAudioSources could broadcast the OLD queue's
|
||||||
|
// entry at the NEW index. Re-enabled after the broadcasts land.
|
||||||
|
_suppressIndexUpdates = true;
|
||||||
|
try {
|
||||||
|
await _player.setAudioSources([initial], initialIndex: 0);
|
||||||
|
// Broadcast in this order: queue first (so any consumer that
|
||||||
|
// reacts to mediaItem and reads queue.value sees the consistent
|
||||||
|
// pair), then mediaItem.
|
||||||
|
queue.add(items);
|
||||||
|
mediaItem.add(items[clampedInitial]);
|
||||||
|
} finally {
|
||||||
|
_suppressIndexUpdates = false;
|
||||||
|
}
|
||||||
|
|
||||||
// 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(_loadArtForCurrentItem());
|
||||||
|
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches playback to the [index]th queue item. The full
|
||||||
|
/// just_audio source list isn't necessarily built yet
|
||||||
|
/// (_fillRemainingSources runs in the background), so we
|
||||||
|
/// reconstruct from the stored TrackRef list rather than calling
|
||||||
|
/// _player.seek(index: ...) on a possibly-missing source. Calling
|
||||||
|
/// setQueueFromTracks again is the safe path: it bumps the
|
||||||
|
/// generation, cancels any in-flight fill, rebuilds source[0] as
|
||||||
|
/// the target, and re-fills around. play() restarts playback so
|
||||||
|
/// queue taps feel like "jump to this song" rather than "set the
|
||||||
|
/// pointer and wait for me to press play."
|
||||||
|
@override
|
||||||
|
Future<void> skipToQueueItem(int index) async {
|
||||||
|
if (index < 0 || index >= _lastTracks.length) return;
|
||||||
|
// Preserve the system-playlist source across an internal rebuild
|
||||||
|
// — a queue-item skip is still playing from the same playlist.
|
||||||
|
await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource);
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
String _resolveStreamUrl(TrackRef t) {
|
||||||
@@ -143,7 +279,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
if (mgr != null) {
|
if (mgr != null) {
|
||||||
final path = await mgr.pathFor(t.id);
|
final path = await mgr.pathFor(t.id);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
|
|
||||||
return AudioSource.uri(Uri.file(path));
|
return AudioSource.uri(Uri.file(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,15 +304,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
if (mgr != null) {
|
if (mgr != null) {
|
||||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||||
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
||||||
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
|
||||||
'using LockCachingAudioSource → ${cacheFile.path}');
|
|
||||||
// ignore: experimental_member_use
|
// ignore: experimental_member_use
|
||||||
return LockCachingAudioSource(parsed,
|
return LockCachingAudioSource(parsed,
|
||||||
headers: headers, cacheFile: cacheFile);
|
headers: headers, cacheFile: cacheFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No manager configured: plain network source (legacy path).
|
// 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);
|
return AudioSource.uri(parsed, headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +346,32 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
final extras = <String, dynamic>{};
|
final extras = <String, dynamic>{};
|
||||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||||
|
// Sync-peek the album cover cache so warm-cache tracks broadcast
|
||||||
|
// with artUri populated on the first frame. External media
|
||||||
|
// controllers (Android Wear, Bluetooth dashes, Auto, lock screen)
|
||||||
|
// can only render the cover bytes that audio_service hands them
|
||||||
|
// at MediaItem broadcast time; the later async _loadArtForCurrent
|
||||||
|
// Item path repopulates for cold-cache tracks. Without this seed,
|
||||||
|
// every track change starts with a generic icon on the watch and
|
||||||
|
// only gets the real cover after one or two seconds.
|
||||||
|
final coverPath = (t.albumId.isNotEmpty && _coverCache != null)
|
||||||
|
? _coverCache!.peekCached(t.albumId)
|
||||||
|
: null;
|
||||||
|
// MediaItem.rating intentionally NOT set: audio_service propagates
|
||||||
|
// it to MediaSession.setRating(), but the Android session also
|
||||||
|
// needs setRatingType(RATING_HEART) configured to expose that to
|
||||||
|
// controllers — audio_service doesn't surface that config knob,
|
||||||
|
// and broadcasting an unanchored rating made Wear OS reject the
|
||||||
|
// session entirely. The LikeBridge wiring stays in place so
|
||||||
|
// setRating can still fire from any surface that DOES route it,
|
||||||
|
// we just don't advertise it.
|
||||||
return MediaItem(
|
return MediaItem(
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artistName,
|
artist: t.artistName,
|
||||||
album: t.albumTitle,
|
album: t.albumTitle,
|
||||||
duration: Duration(seconds: t.durationSec),
|
duration: Duration(seconds: t.durationSec),
|
||||||
|
artUri: coverPath != null ? Uri.file(coverPath) : null,
|
||||||
extras: extras.isEmpty ? null : extras,
|
extras: extras.isEmpty ? null : extras,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,12 +405,42 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
|
|
||||||
_streamCacheRegistered.add(trackId);
|
_streamCacheRegistered.add(trackId);
|
||||||
await mgr.registerStreamCache(trackId, path, size);
|
await mgr.registerStreamCache(trackId, path, size);
|
||||||
debugPrint(
|
}
|
||||||
'audio_handler: registered stream cache for $trackId ($size bytes)');
|
|
||||||
|
/// Called when playbackEventStream emits an error. Skips past the
|
||||||
|
/// failing track so the UI and audio re-converge — without this,
|
||||||
|
/// _player goes silent on a 404 / decoder failure / EOS but
|
||||||
|
/// mediaItem stays on the failed track and the user sees a "now
|
||||||
|
/// playing" header for something that isn't.
|
||||||
|
///
|
||||||
|
/// If we're at the last track, seekToNext is a no-op; the state
|
||||||
|
/// drops to idle and _broadcastState reflects that.
|
||||||
|
Future<void> _handlePlaybackError() async {
|
||||||
|
final currentIdx = _player.currentIndex;
|
||||||
|
final queueLen = queue.value.length;
|
||||||
|
if (currentIdx == null || currentIdx + 1 >= queueLen) {
|
||||||
|
// Last track or no queue context — just pause; _broadcastState
|
||||||
|
// already reflects the idle/error processingState.
|
||||||
|
try {
|
||||||
|
await _player.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _player.seekToNext();
|
||||||
|
} catch (_) {
|
||||||
|
// If seekToNext fails too (next source not built yet, etc.),
|
||||||
|
// there's nothing safe to do — pause and let the user recover
|
||||||
|
// manually.
|
||||||
|
try {
|
||||||
|
await _player.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCurrentIndexChanged(int? idx) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
|
if (_suppressIndexUpdates) return;
|
||||||
// Push the new track's MediaItem onto the mediaItem stream so
|
// Push the new track's MediaItem onto the mediaItem stream so
|
||||||
// the player UI rebuilds with the new title/artist/album/cover.
|
// the player UI rebuilds with the new title/artist/album/cover.
|
||||||
// Without this, the bar and full player stayed pinned to whichever
|
// Without this, the bar and full player stayed pinned to whichever
|
||||||
@@ -305,6 +487,45 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||||
|
|
||||||
|
/// Heart rating from external surfaces (Wear's favorite button,
|
||||||
|
/// lock-screen like) → LikesController.toggle(track). We only
|
||||||
|
/// route through the bridge when the rating actually flips relative
|
||||||
|
/// to the current state, so repeated taps from a flaky controller
|
||||||
|
/// don't ping-pong the like.
|
||||||
|
@override
|
||||||
|
Future<void> setRating(Rating rating, [Map<String, dynamic>? extras]) async {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final currentlyLiked = bridge.isTrackLiked(media.id);
|
||||||
|
final wantLiked = rating.hasHeart();
|
||||||
|
if (currentlyLiked == wantLiked) return;
|
||||||
|
try {
|
||||||
|
await bridge.toggleTrackLike(media.id);
|
||||||
|
} catch (_) {
|
||||||
|
// LikesController already rolls back on REST failure; nothing
|
||||||
|
// to do here beyond letting the broadcast skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-emit so the watch's heart icon updates immediately.
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-emits the current mediaItem with a fresh rating pulled from
|
||||||
|
/// the LikeBridge. Called by PlayerActions on likedIdsProvider
|
||||||
|
/// changes so the watch / lock-screen heart icon updates when the
|
||||||
|
/// user toggles a like from TrackRow, the kebab menu, or another
|
||||||
|
/// device's playback (SSE-routed). No-op if no track is playing
|
||||||
|
/// or the like state didn't change.
|
||||||
|
void refreshCurrentRating() {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final liked = bridge.isTrackLiked(media.id);
|
||||||
|
if (media.rating?.hasHeart() == liked) return;
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||||
await _player
|
await _player
|
||||||
@@ -347,6 +568,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
// systemActions enumerates which actions the system can invoke
|
// systemActions enumerates which actions the system can invoke
|
||||||
// on us — without play/pause/skip in here, taps on lock-screen
|
// on us — without play/pause/skip in here, taps on lock-screen
|
||||||
// controls don't route back to the handler on Android 13+.
|
// controls don't route back to the handler on Android 13+.
|
||||||
|
//
|
||||||
|
// v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode,
|
||||||
|
// setRepeatMode, and setRating to this set; reverted because
|
||||||
|
// Pixel Watch 2 stopped showing controls entirely after that
|
||||||
|
// change. The MediaSession contract on Wear OS requires more
|
||||||
|
// setup than just advertising the action (e.g. setRatingType
|
||||||
|
// for setRating) and audio_service doesn't expose those knobs.
|
||||||
|
// Keep the override methods themselves (skipToQueueItem is
|
||||||
|
// still routed via QueueScreen's direct handler call;
|
||||||
|
// setRating is harmless if never invoked) so we don't lose
|
||||||
|
// the underlying functionality — just don't tell the system
|
||||||
|
// we support them.
|
||||||
systemActions: const {
|
systemActions: const {
|
||||||
MediaAction.play,
|
MediaAction.play,
|
||||||
MediaAction.pause,
|
MediaAction.pause,
|
||||||
@@ -377,3 +610,27 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adapter that lets the audio handler call into the app's
|
||||||
|
/// LikesController without depending on Riverpod directly. Constructed
|
||||||
|
/// in PlayerActions where ref is available; consumed inside the audio
|
||||||
|
/// handler's setRating override and MediaItem builder to keep external
|
||||||
|
/// media controllers (Wear, lock screen, Auto) in sync with the
|
||||||
|
/// likedIds drift cache.
|
||||||
|
class LikeBridge {
|
||||||
|
const LikeBridge({
|
||||||
|
required this.toggleTrackLike,
|
||||||
|
required this.isTrackLiked,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Flip the like state for [trackId]. Returns a Future that resolves
|
||||||
|
/// once the underlying LikesController has both updated drift
|
||||||
|
/// optimistically and rolled the change through the REST API
|
||||||
|
/// (errors result in drift rollback inside LikesController).
|
||||||
|
final Future<void> Function(String trackId) toggleTrackLike;
|
||||||
|
|
||||||
|
/// Read the current like state for [trackId] from the drift-backed
|
||||||
|
/// likedIdsProvider. Synchronous because the audio handler needs
|
||||||
|
/// to populate MediaItem.rating on the broadcast hot path.
|
||||||
|
final bool Function(String trackId) isTrackLiked;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,20 @@ import '../models/track.dart';
|
|||||||
import '../shared/widgets/server_image.dart';
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'album_color_extractor.dart';
|
||||||
import 'player_provider.dart';
|
import 'player_provider.dart';
|
||||||
|
|
||||||
|
/// 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
|
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||||
/// transition (see routing.dart). Dismisses three ways:
|
/// transition (see routing.dart). Dismisses three ways:
|
||||||
///
|
///
|
||||||
@@ -33,6 +45,133 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
// dismiss. Reset on each drag start.
|
// dismiss. Reset on each drag start.
|
||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
|
|
||||||
|
/// The MediaItem currently displayed on the screen. Held separately
|
||||||
|
/// from the live mediaItemProvider so we can preload the new track's
|
||||||
|
/// cover bytes + dominant color BEFORE flipping the visible state.
|
||||||
|
/// Without this gate the full-player UI swapped to the new track's
|
||||||
|
/// title/cover slot immediately while the image was still decoding,
|
||||||
|
/// producing a visible "pop in" once the bytes arrived.
|
||||||
|
MediaItem? _displayedMedia;
|
||||||
|
|
||||||
|
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
|
||||||
|
/// backdrop AnimatedContainer when it changes.
|
||||||
|
Color? _displayedDominant;
|
||||||
|
|
||||||
|
/// The id of the most-recent track we kicked a preload for. Used to
|
||||||
|
/// drop stale preload completions when the user skips rapidly past
|
||||||
|
/// a track whose cover hadn't finished decoding yet.
|
||||||
|
String? _pendingPreloadId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Seed displayed state from the current mediaItem if a track is
|
||||||
|
// already playing when the full player is opened. ref.listen
|
||||||
|
// below only fires on CHANGES after subscription, so without
|
||||||
|
// this seed the screen sits on "Nothing playing." even though
|
||||||
|
// the mini bar shows a live track — the listener never gets a
|
||||||
|
// mediaItem emission because the stream's current value hasn't
|
||||||
|
// changed.
|
||||||
|
final current = ref.read(mediaItemProvider).value;
|
||||||
|
if (current == null) return;
|
||||||
|
_displayedMedia = current;
|
||||||
|
// Best-effort synchronous color seed: if albumColorProvider has
|
||||||
|
// already extracted this album's dominant color earlier in the
|
||||||
|
// session, pull it now so the backdrop renders correctly on
|
||||||
|
// first frame. Otherwise the post-frame _scheduleSwap below
|
||||||
|
// resolves it asynchronously and AnimatedContainer tweens.
|
||||||
|
final albumId = current.extras?['album_id'] as String?;
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
_displayedDominant =
|
||||||
|
ref.read(albumColorProvider(albumId)).asData?.value;
|
||||||
|
}
|
||||||
|
// Kick the preload pipeline post-frame so the cover bytes are
|
||||||
|
// decoded + the dominant color is resolved even when the user
|
||||||
|
// opens the player to a track that hasn't yet flowed through
|
||||||
|
// ref.listen.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_scheduleSwap(current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preload the new track's cover bytes + dominant color, then
|
||||||
|
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
||||||
|
/// resolve, the screen continues to render the previous track —
|
||||||
|
/// the new title/cover/gradient appear together in one frame.
|
||||||
|
///
|
||||||
|
/// Concurrency: if a second track change arrives while the first
|
||||||
|
/// preload is still in flight, the older preload's
|
||||||
|
/// _pendingPreloadId check fails and its completion is dropped.
|
||||||
|
Future<void> _scheduleSwap(MediaItem newMedia) async {
|
||||||
|
_pendingPreloadId = newMedia.id;
|
||||||
|
|
||||||
|
// Fast path: when Prefetcher has already warmed both the cover
|
||||||
|
// bytes (file:// artUri populated via _toMediaItem's peekCached)
|
||||||
|
// AND the palette color (AlbumColorCache.peekColor returns
|
||||||
|
// non-null), we can commit synchronously. The slow async path
|
||||||
|
// exists for genuine cold-cache moments (first play of an album
|
||||||
|
// that sync hasn't seen yet); the fast path is what makes the
|
||||||
|
// common in-queue auto-advance flip the visual in lockstep with
|
||||||
|
// the audio transition instead of lagging a tick behind.
|
||||||
|
final artUri = newMedia.artUri;
|
||||||
|
final albumId = newMedia.extras?['album_id'] as String?;
|
||||||
|
if (artUri != null &&
|
||||||
|
artUri.isScheme('file') &&
|
||||||
|
albumId != null &&
|
||||||
|
albumId.isNotEmpty) {
|
||||||
|
final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
|
||||||
|
if (cachedColor != null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pendingPreloadId != newMedia.id) return;
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = newMedia;
|
||||||
|
_displayedDominant = cachedColor;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: cover and/or color are not yet cached. Hold the
|
||||||
|
// current displayed state, preload, then atomic-commit.
|
||||||
|
//
|
||||||
|
// 1. Precache the cover image bytes so when _AlbumArt mounts with
|
||||||
|
// the new media, FileImage paints synchronously. Non-file
|
||||||
|
// artUris fall through to ServerImage which handles its own
|
||||||
|
// network load + 120ms fade — they won't snap.
|
||||||
|
if (artUri != null && artUri.isScheme('file') && context.mounted) {
|
||||||
|
try {
|
||||||
|
await precacheImage(FileImage(File.fromUri(artUri)), context);
|
||||||
|
} catch (_) {
|
||||||
|
// Decode failed (missing file, corrupt bytes) — proceed
|
||||||
|
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait for the dominant-color extraction so the gradient is
|
||||||
|
// populated when we swap. PaletteGenerator runs against the
|
||||||
|
// same FileImage, typically resolving within ~50ms once the
|
||||||
|
// decode completes.
|
||||||
|
Color? newDominant;
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
newDominant = await ref.read(albumColorProvider(albumId).future);
|
||||||
|
} catch (_) {
|
||||||
|
// Color extraction failed — keep the previous dominant so the
|
||||||
|
// backdrop doesn't drop to obsidian on the swap.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Atomic flip. Bail if a newer swap superseded us, or the
|
||||||
|
// screen was disposed mid-preload.
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pendingPreloadId != newMedia.id) return;
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = newMedia;
|
||||||
|
if (newDominant != null) _displayedDominant = newDominant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
_dragOffset = 0;
|
_dragOffset = 0;
|
||||||
}
|
}
|
||||||
@@ -55,10 +194,61 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
|
||||||
|
|
||||||
if (media == null) {
|
// Listen for mediaItem changes and schedule a preload-then-swap.
|
||||||
|
// Build renders _displayedMedia / _displayedDominant; the live
|
||||||
|
// mediaItemProvider only drives the swap pipeline.
|
||||||
|
//
|
||||||
|
// Decision process:
|
||||||
|
// - On the first non-null mediaItem after mount, seed
|
||||||
|
// _displayedMedia immediately so the screen has something to
|
||||||
|
// paint.
|
||||||
|
// - On a track id change, kick off a preload. The new track's
|
||||||
|
// cover bytes + dominant color must both resolve before we
|
||||||
|
// flip the displayed state. The user sees the previous track
|
||||||
|
// in full until the new one is fully ready, then a clean
|
||||||
|
// atomic swap (no fade, no placeholder flash).
|
||||||
|
// - On the same track id but the artUri-bearing rebroadcast
|
||||||
|
// (audio_handler sends MediaItem twice on track change),
|
||||||
|
// refresh through the same preload pipeline so the displayed
|
||||||
|
// cover reflects the freshly-written AlbumCoverCache file.
|
||||||
|
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
|
||||||
|
final newMedia = next.asData?.value;
|
||||||
|
if (newMedia == null) {
|
||||||
|
if (_displayedMedia != null) {
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = null;
|
||||||
|
_displayedDominant = null;
|
||||||
|
_pendingPreloadId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_displayedMedia == null) {
|
||||||
|
// First mount with a track playing — seed immediately, then
|
||||||
|
// kick the preload pipeline to update the dominant color and
|
||||||
|
// ensure the cover is decoded.
|
||||||
|
setState(() => _displayedMedia = newMedia);
|
||||||
|
_scheduleSwap(newMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final isNewTrack = newMedia.id != _displayedMedia!.id;
|
||||||
|
final coverGotPopulated =
|
||||||
|
newMedia.id == _displayedMedia!.id &&
|
||||||
|
newMedia.artUri != null &&
|
||||||
|
_displayedMedia!.artUri == null;
|
||||||
|
if (isNewTrack || coverGotPopulated) {
|
||||||
|
_scheduleSwap(newMedia);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
|
final displayedMedia = _displayedMedia;
|
||||||
|
|
||||||
|
if (displayedMedia == null) {
|
||||||
|
// Either nothing playing, or the very first mount before a
|
||||||
|
// mediaItem has been emitted. The ref.listen above will seed
|
||||||
|
// _displayedMedia as soon as a non-null MediaItem arrives.
|
||||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +257,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
// only fires on state transitions and would leave the bar frozen
|
// only fires on state transitions and would leave the bar frozen
|
||||||
// between them.
|
// between them.
|
||||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||||
final dur = media.duration ?? Duration.zero;
|
final dur = displayedMedia.duration ?? Duration.zero;
|
||||||
final isPlaying = playback?.playing == true;
|
final isPlaying = playback?.playing == true;
|
||||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||||
final actions = ref.read(playerActionsProvider);
|
final actions = ref.read(playerActionsProvider);
|
||||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
|
||||||
|
|
||||||
|
// Backdrop color: render the dominant we've already committed to
|
||||||
|
// _displayedDominant. AnimatedContainer tweens between successive
|
||||||
|
// values, so the only color changes the user sees are the
|
||||||
|
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
|
||||||
|
// the gradient present without overwhelming the title/artist
|
||||||
|
// text below.
|
||||||
|
final dominant = _displayedDominant ?? fs.obsidian;
|
||||||
|
final gradientTop = dominant.withValues(alpha: 0.55);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
@@ -84,7 +283,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
onVerticalDragUpdate: _onDragUpdate,
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
onVerticalDragEnd: _onDragEnd,
|
onVerticalDragEnd: _onDragEnd,
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: SafeArea(
|
child: AnimatedContainer(
|
||||||
|
duration: _trackChangeDuration,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [gradientTop, fs.obsidian],
|
||||||
|
stops: const [0.0, 0.7],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_TopBar(fs: fs),
|
_TopBar(fs: fs),
|
||||||
@@ -94,32 +303,44 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_AlbumArt(media: media, albumId: albumId, fs: fs),
|
// No AnimatedSwitcher: _displayedMedia only
|
||||||
|
// advances after the preload pipeline has the
|
||||||
|
// new cover + color ready, so a track change
|
||||||
|
// is an atomic swap. Fading would just smear a
|
||||||
|
// transition the user explicitly asked us not
|
||||||
|
// to add.
|
||||||
|
_AlbumArt(
|
||||||
|
media: displayedMedia, albumId: albumId, fs: fs),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_TitleRow(media: media, fs: fs),
|
_TitleRow(media: displayedMedia, fs: fs),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Column(
|
||||||
media.artist ?? '',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
children: [
|
||||||
maxLines: 1,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
displayedMedia.artist ?? '',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if ((displayedMedia.album ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
displayedMedia.album!,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
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),
|
const SizedBox(height: 24),
|
||||||
_SecondaryControls(
|
_SecondaryControls(
|
||||||
fs: fs,
|
fs: fs,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
shuffleOn: shuffleOn,
|
shuffleOn: shuffleOn,
|
||||||
repeatMode: repeatMode,
|
repeatMode: repeatMode,
|
||||||
media: media,
|
media: displayedMedia,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||||
@@ -136,6 +357,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -204,9 +426,18 @@ class _AlbumArt extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||||
child: ClipRRect(
|
child: Hero(
|
||||||
borderRadius: BorderRadius.circular(8),
|
tag: kPlayerCoverHeroTag,
|
||||||
child: cover,
|
// 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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -397,6 +628,17 @@ class _SecondaryControls extends StatelessWidget {
|
|||||||
streamUrl: '',
|
streamUrl: '',
|
||||||
),
|
),
|
||||||
hideQueueActions: true,
|
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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// Flutter play-event lifecycle reporter (#415 stage 3).
|
||||||
|
//
|
||||||
|
// The Flutter client previously reported NO plays — listening on
|
||||||
|
// mobile never reached the server's play_events, so history,
|
||||||
|
// recommendation scoring, ListenBrainz scrobbles, and (since #415)
|
||||||
|
// system-playlist rotation all missed mobile activity entirely. This
|
||||||
|
// closes that gap and is the path that carries the #415 `source` tag.
|
||||||
|
//
|
||||||
|
// State machine over (current track id, playing). Mirrors the web
|
||||||
|
// events dispatcher, with one deliberate divergence: when a track
|
||||||
|
// changes inside a queue we classify ended-vs-skipped by whether the
|
||||||
|
// prior track reached (near) its duration, instead of the web
|
||||||
|
// dispatcher's blanket "track change = skip". Blanket-skip would mark
|
||||||
|
// every naturally-finished in-queue track as a skip and dilute the
|
||||||
|
// recommendation skip-ratio — the exact failure mode that motivated
|
||||||
|
// doing this properly. (The web dispatcher likely has the same
|
||||||
|
// false-skip issue; flagged separately, not fixed here.)
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/events.dart';
|
||||||
|
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||||
|
import '../cache/mutation_queue.dart'
|
||||||
|
show MutationKinds, mutationQueueProvider;
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import 'audio_handler.dart' show MinstrelAudioHandler;
|
||||||
|
import 'player_provider.dart' show audioHandlerProvider;
|
||||||
|
|
||||||
|
const _clientIdKey = 'play_events_client_id';
|
||||||
|
|
||||||
|
/// Tolerance for "the track basically finished": within 3s of the
|
||||||
|
/// known duration counts as a natural completion, not a skip.
|
||||||
|
const _completionToleranceMs = 3000;
|
||||||
|
|
||||||
|
class PlayEventsReporter with WidgetsBindingObserver {
|
||||||
|
PlayEventsReporter(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
final _subs = <StreamSubscription<dynamic>>[];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
String? _clientId;
|
||||||
|
EventsApi? _api;
|
||||||
|
|
||||||
|
// Server play_event_id when the live play_started succeeded; null
|
||||||
|
// if start failed / offline — then the close is captured into the
|
||||||
|
// offline mutation queue instead of a live ended/skipped call.
|
||||||
|
String? _openPlayEventId;
|
||||||
|
|
||||||
|
// The play currently being tracked, captured independently of
|
||||||
|
// connectivity so an offline play is still a complete record.
|
||||||
|
String? _curTrackId;
|
||||||
|
DateTime? _curStartedAt;
|
||||||
|
String? _curSource;
|
||||||
|
int _curLastPositionMs = 0;
|
||||||
|
int _curDurationMs = 0;
|
||||||
|
bool _curReachedEnd = false;
|
||||||
|
|
||||||
|
String? _prevTrackId;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
final MinstrelAudioHandler handler;
|
||||||
|
try {
|
||||||
|
// audioHandlerProvider throws until main() overrides it (real
|
||||||
|
// app always does). In tests / no-audio environments there's
|
||||||
|
// nothing to report — fail safe and stay inert rather than
|
||||||
|
// surfacing an unhandled async error.
|
||||||
|
handler = _ref.read(audioHandlerProvider);
|
||||||
|
_clientId = await _resolveClientId();
|
||||||
|
final dio = await _ref.read(dioProvider.future);
|
||||||
|
_api = EventsApi(dio);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
|
_subs.add(handler.positionStream.listen((p) {
|
||||||
|
final ms = p.inMilliseconds;
|
||||||
|
// Advance the tracked play's progress ONLY while its track is
|
||||||
|
// the current one. A track change resets position to 0; gating
|
||||||
|
// on _curTrackId keeps the finishing track's last-known values
|
||||||
|
// intact for the close branch.
|
||||||
|
final mi = handler.mediaItem.value;
|
||||||
|
if (mi != null && mi.id == _curTrackId) {
|
||||||
|
_curLastPositionMs = ms;
|
||||||
|
final d = mi.duration;
|
||||||
|
if (d != null && d.inMilliseconds > 0) {
|
||||||
|
_curDurationMs = d.inMilliseconds;
|
||||||
|
if (ms >= _curDurationMs - _completionToleranceMs) {
|
||||||
|
_curReachedEnd = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
_subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
|
||||||
|
_subs.add(handler.playbackState.listen((_) => _evaluate(handler)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evaluate(MinstrelAudioHandler handler) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final mi = handler.mediaItem.value;
|
||||||
|
final st = handler.playbackState.value;
|
||||||
|
final tid = mi?.id;
|
||||||
|
final playing = st.playing;
|
||||||
|
final completed = st.processingState == AudioProcessingState.completed;
|
||||||
|
|
||||||
|
// Track changed → close the prior tracked play.
|
||||||
|
if (tid != _prevTrackId && _curTrackId != null) {
|
||||||
|
_closeCurrent(viaOffline: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entered playing for a new track → begin tracking it.
|
||||||
|
if (tid != null && playing && _curTrackId != tid) {
|
||||||
|
_beginTrack(handler, tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-queue natural end (just_audio only emits `completed` at
|
||||||
|
// the end of the sequence, not between items) → close it.
|
||||||
|
if (completed && _curTrackId != null) {
|
||||||
|
_curReachedEnd = true;
|
||||||
|
_closeCurrent(viaOffline: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_prevTrackId = tid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _beginTrack(MinstrelAudioHandler handler, String trackId) {
|
||||||
|
_curTrackId = trackId;
|
||||||
|
_curStartedAt = DateTime.now().toUtc();
|
||||||
|
_curSource = handler.queueSource;
|
||||||
|
_curLastPositionMs = 0;
|
||||||
|
_curReachedEnd = false;
|
||||||
|
final d = handler.mediaItem.value?.duration;
|
||||||
|
_curDurationMs = d?.inMilliseconds ?? 0;
|
||||||
|
_openPlayEventId = null;
|
||||||
|
// Fire the live play_started; adopt the server id only if we're
|
||||||
|
// still on this track when the response lands. Failure is fine —
|
||||||
|
// the close path captures the whole play into the offline queue.
|
||||||
|
_startLive(handler, trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startLive(MinstrelAudioHandler handler, String trackId) async {
|
||||||
|
final api = _api;
|
||||||
|
final cid = _clientId;
|
||||||
|
if (api == null || cid == null) return;
|
||||||
|
try {
|
||||||
|
final id = await api.playStarted(
|
||||||
|
trackId: trackId,
|
||||||
|
clientId: cid,
|
||||||
|
source: _curSource,
|
||||||
|
);
|
||||||
|
if (id != null && _curTrackId == trackId) {
|
||||||
|
_openPlayEventId = id;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Offline / flaky — _openPlayEventId stays null; the close path
|
||||||
|
// enqueues the completed play for replay.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the currently-tracked play. `finished` is derived from
|
||||||
|
/// whether it reached ~its duration. If the live start registered a
|
||||||
|
/// server id we attempt the live ended/skipped close and fall back
|
||||||
|
/// to the offline queue on failure; with no server id (offline
|
||||||
|
/// start) — or viaOffline (app teardown, must be durable) — the
|
||||||
|
/// completed play is enqueued directly. The server's RecordOffline
|
||||||
|
/// Play applies the canonical skip rule, so the offline payload
|
||||||
|
/// only needs duration, not our finished/skipped guess.
|
||||||
|
void _closeCurrent({required bool viaOffline}) {
|
||||||
|
final trackId = _curTrackId;
|
||||||
|
final startedAt = _curStartedAt;
|
||||||
|
if (trackId == null || startedAt == null) {
|
||||||
|
_resetCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final reached = _curReachedEnd;
|
||||||
|
final lastPos = _curLastPositionMs;
|
||||||
|
final durationMs = (reached && _curDurationMs > 0)
|
||||||
|
? _curDurationMs
|
||||||
|
: lastPos;
|
||||||
|
final source = _curSource;
|
||||||
|
final id = _openPlayEventId;
|
||||||
|
|
||||||
|
if (!viaOffline && id != null) {
|
||||||
|
// Live close; on failure, fall back to the durable offline path
|
||||||
|
// so a transient blip at close time doesn't lose the play.
|
||||||
|
final fut = reached
|
||||||
|
? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs)
|
||||||
|
: _api?.playSkipped(playEventId: id, positionMs: lastPos);
|
||||||
|
fut?.catchError((_) {
|
||||||
|
_enqueueOffline(trackId, startedAt, source, durationMs);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_enqueueOffline(trackId, startedAt, source, durationMs);
|
||||||
|
}
|
||||||
|
_resetCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetCurrent() {
|
||||||
|
_curTrackId = null;
|
||||||
|
_curStartedAt = null;
|
||||||
|
_curSource = null;
|
||||||
|
_curLastPositionMs = 0;
|
||||||
|
_curDurationMs = 0;
|
||||||
|
_curReachedEnd = false;
|
||||||
|
_openPlayEventId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enqueueOffline(
|
||||||
|
String trackId,
|
||||||
|
DateTime startedAt,
|
||||||
|
String? source,
|
||||||
|
int durationPlayedMs,
|
||||||
|
) {
|
||||||
|
final cid = _clientId;
|
||||||
|
if (cid == null) return;
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
_ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, {
|
||||||
|
'trackId': trackId,
|
||||||
|
'clientId': cid,
|
||||||
|
'at': startedAt.toIso8601String(),
|
||||||
|
'durationPlayedMs': durationPlayedMs,
|
||||||
|
if (source != null && source.isNotEmpty) 'source': source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// App backgrounded / killed mid-play: close durably via the
|
||||||
|
// offline queue (a fire-and-forget POST during teardown is
|
||||||
|
// unreliable; the queue survives a process kill and drains on
|
||||||
|
// next launch). Mirrors the intent of web's pagehide beacon.
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
if (_curTrackId != null) {
|
||||||
|
_closeCurrent(viaOffline: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _resolveClientId() async {
|
||||||
|
final storage = _ref.read(secureStorageProvider);
|
||||||
|
final existing = await storage.read(key: _clientIdKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
final rnd = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||||
|
final id =
|
||||||
|
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
await storage.write(key: _clientIdKey, value: id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
for (final s in _subs) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
|
_subs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start (app.dart postFrame) to activate reporting.
|
||||||
|
/// Disposed via ref.onDispose when the scope tears down.
|
||||||
|
final playEventsReporterProvider = Provider<PlayEventsReporter>((ref) {
|
||||||
|
final r = PlayEventsReporter(ref);
|
||||||
|
ref.onDispose(r.dispose);
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
r.start();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -10,6 +11,7 @@ import '../likes/like_button.dart';
|
|||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'now_playing_screen.dart' show kPlayerCoverHeroTag;
|
||||||
import 'player_provider.dart';
|
import 'player_provider.dart';
|
||||||
|
|
||||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||||
@@ -26,16 +28,37 @@ import 'player_provider.dart';
|
|||||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||||
/// the drag-down dismissal on the full screen.
|
/// the drag-down dismissal on the full screen.
|
||||||
class PlayerBar extends ConsumerWidget {
|
class PlayerBar extends ConsumerStatefulWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlayerBar> createState() => _PlayerBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerBarState extends ConsumerState<PlayerBar> {
|
||||||
|
/// Last non-null artUri we've seen from the mediaItem stream. Held
|
||||||
|
/// across the audio_handler's two-broadcast track-change pattern
|
||||||
|
/// (bare MediaItem first, then with artUri once AlbumCoverCache
|
||||||
|
/// resolves) so the mini bar keeps showing the previous track's
|
||||||
|
/// cover until the new one is known. Without this hold the bar
|
||||||
|
/// flickered to the slate placeholder for ~100ms on every track
|
||||||
|
/// change.
|
||||||
|
Uri? _lastArtUri;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
if (media == null) return const SizedBox.shrink();
|
if (media == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Capture the latest non-null artUri so _TrackInfo's cover stays
|
||||||
|
// continuous across track changes.
|
||||||
|
if (media.artUri != null) _lastArtUri = media.artUri;
|
||||||
|
final displayMedia = media.artUri == null && _lastArtUri != null
|
||||||
|
? media.copyWith(artUri: _lastArtUri)
|
||||||
|
: media;
|
||||||
|
|
||||||
// positionProvider is just_audio's positionStream (~200ms cadence)
|
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||||
// only updates on event transitions and would leave it frozen.
|
// only updates on event transitions and would leave it frozen.
|
||||||
@@ -63,7 +86,7 @@ class PlayerBar extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _TrackInfo(media: media)),
|
Expanded(child: _TrackInfo(media: displayMedia)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_PlayControls(playback: playback, ref: ref),
|
_PlayControls(playback: playback, ref: ref),
|
||||||
],
|
],
|
||||||
@@ -88,29 +111,56 @@ class _TrackInfo extends StatelessWidget {
|
|||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artistName = (media.artist ?? '').trim();
|
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.
|
||||||
|
//
|
||||||
|
// Why no transition / placeholder handling: the audio_handler
|
||||||
|
// broadcasts MediaItem twice on track change — once with artUri
|
||||||
|
// null (the new track's bare metadata), then with artUri set once
|
||||||
|
// AlbumCoverCache resolves. The widget tree above hands us the
|
||||||
|
// most-recent non-null artUri (`displayArtUri`), so the previous
|
||||||
|
// track's cover stays visible across that null gap and the new
|
||||||
|
// cover snaps in the moment its artUri arrives. Rapid change is
|
||||||
|
// fine here per operator preference; AnimatedSwitcher previously
|
||||||
|
// smeared the swap and made the slate placeholder visible.
|
||||||
|
final displayArtUri = media.artUri;
|
||||||
|
final Widget cover;
|
||||||
|
if (displayArtUri != 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: displayArtUri.isScheme('file')
|
||||||
|
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
|
||||||
|
: CachedNetworkImageProvider(displayArtUri.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(
|
return Row(
|
||||||
// Default centering vertically aligns the like/kebab buttons
|
// Default centering vertically aligns the like/kebab buttons
|
||||||
// against the album art (48dp) — visually they span the title +
|
// against the album art (48dp) — visually they span the title +
|
||||||
// artist block instead of sitting on the title baseline.
|
// artist block instead of sitting on the title baseline.
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (media.artUri != null)
|
Hero(
|
||||||
Image(
|
tag: kPlayerCoverHeroTag,
|
||||||
image: media.artUri!.isScheme('file')
|
child: cover,
|
||||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
),
|
||||||
: NetworkImage(media.artUri.toString()),
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
// Without a fit, Image paints the source at its intrinsic
|
|
||||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
|
||||||
// would render as a tiny inset. Cover stretches/crops to
|
|
||||||
// fill the box uniformly.
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) =>
|
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import 'dart:math' show Random;
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart' show LikeKind;
|
||||||
import '../api/endpoints/radio.dart';
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import 'album_cover_cache.dart';
|
import 'album_cover_cache.dart';
|
||||||
import 'audio_handler.dart';
|
import 'audio_handler.dart';
|
||||||
@@ -50,26 +53,37 @@ final positionProvider = StreamProvider<Duration>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
class PlayerActions {
|
class PlayerActions {
|
||||||
PlayerActions(this._ref);
|
PlayerActions(this._ref) {
|
||||||
|
// Keep MediaItem.rating in sync with the drift-backed likedIds
|
||||||
|
// cache so external media surfaces (Wear's heart button, lock-
|
||||||
|
// screen favorite, Auto's like icon) reflect the current state
|
||||||
|
// even when the user toggles a like from somewhere other than
|
||||||
|
// the watch itself — e.g. tapping the heart on a TrackRow, the
|
||||||
|
// kebab menu, or another logged-in device propagating via SSE.
|
||||||
|
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
||||||
|
if (next.value == null) return;
|
||||||
|
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
||||||
|
});
|
||||||
|
}
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> playTracks(
|
||||||
// Stage-by-stage timing so we can see exactly where the
|
List<TrackRef> tracks, {
|
||||||
// "tap → audio" delay lives. Look for "playTracks: ..." lines
|
int initialIndex = 0,
|
||||||
// in the next log capture; each stage label is followed by its
|
bool shuffle = false,
|
||||||
// elapsed millis since the previous stage.
|
String? source,
|
||||||
final sw = Stopwatch()..start();
|
}) async {
|
||||||
int lap() {
|
// shuffle=true means "play this pool randomly, starting at a
|
||||||
final ms = sw.elapsedMilliseconds;
|
// random track" (client-side; used for non-system surfaces that
|
||||||
sw.reset();
|
// want shuffle). System playlists instead fetch a server-ordered
|
||||||
sw.start();
|
// list and pass source — no client shuffle, the order is already
|
||||||
return ms;
|
// rotation-aware (#415).
|
||||||
|
var startAt = initialIndex;
|
||||||
|
if (shuffle && tracks.length > 1) {
|
||||||
|
startAt = Random().nextInt(tracks.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
final url = await _ref.read(serverUrlProvider.future);
|
final url = await _ref.read(serverUrlProvider.future);
|
||||||
debugPrint('playTracks: serverUrl ${lap()}ms');
|
|
||||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||||
debugPrint('playTracks: token ${lap()}ms');
|
|
||||||
final cache = _ref.read(albumCoverCacheProvider);
|
final cache = _ref.read(albumCoverCacheProvider);
|
||||||
final audioCache = _ref.read(audioCacheManagerProvider);
|
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||||
final h = _ref.read(audioHandlerProvider)
|
final h = _ref.read(audioHandlerProvider)
|
||||||
@@ -78,12 +92,29 @@ class PlayerActions {
|
|||||||
token: token,
|
token: token,
|
||||||
coverCache: cache,
|
coverCache: cache,
|
||||||
audioCacheManager: audioCache,
|
audioCacheManager: audioCache,
|
||||||
|
likeBridge: _buildLikeBridge(),
|
||||||
);
|
);
|
||||||
debugPrint('playTracks: configure ${lap()}ms');
|
await h.setQueueFromTracks(tracks, initialIndex: startAt, source: source);
|
||||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
if (shuffle) {
|
||||||
debugPrint('playTracks: setQueue (${tracks.length} tracks) ${lap()}ms');
|
await h.setShuffleMode(AudioServiceShuffleMode.all);
|
||||||
|
}
|
||||||
await h.play();
|
await h.play();
|
||||||
debugPrint('playTracks: play() returned ${lap()}ms');
|
}
|
||||||
|
|
||||||
|
/// Builds the adapter the audio handler uses to read + flip the
|
||||||
|
/// current track's like state in response to external media-
|
||||||
|
/// controller events (Wear's heart button, lock-screen favorite).
|
||||||
|
/// Constructed here because the audio handler is initialized in
|
||||||
|
/// main.dart before the ProviderScope exists; passing the bridge
|
||||||
|
/// in via configure() keeps the handler Riverpod-agnostic while
|
||||||
|
/// still letting it call into LikesController + likedIdsProvider.
|
||||||
|
LikeBridge _buildLikeBridge() {
|
||||||
|
return LikeBridge(
|
||||||
|
toggleTrackLike: (id) =>
|
||||||
|
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
|
||||||
|
isTrackLiked: (id) =>
|
||||||
|
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playNext(TrackRef track) async {
|
Future<void> playNext(TrackRef track) async {
|
||||||
|
|||||||
@@ -2,23 +2,52 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../cache/audio_cache_manager.dart';
|
|
||||||
import '../cache/db.dart';
|
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/live_events_provider.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
class PlaylistDetailScreen extends ConsumerWidget {
|
class PlaylistDetailScreen extends ConsumerWidget {
|
||||||
const PlaylistDetailScreen({required this.id, super.key});
|
const PlaylistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// #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));
|
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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -28,27 +57,118 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
|||||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: detail.maybeWhen(
|
title: headerName.isEmpty
|
||||||
data: (d) => Text(
|
? const SizedBox.shrink()
|
||||||
d.playlist.name,
|
: Text(
|
||||||
style: TextStyle(color: fs.parchment),
|
headerName,
|
||||||
overflow: TextOverflow.ellipsis,
|
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 {
|
class _Body extends ConsumerWidget {
|
||||||
const _Body({required this.detail});
|
const _Body({super.key, required this.detail});
|
||||||
final PlaylistDetail detail;
|
final PlaylistDetail detail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,6 +210,9 @@ class _Body extends ConsumerWidget {
|
|||||||
ref.read(playerActionsProvider).playTracks(
|
ref.read(playerActionsProvider).playTracks(
|
||||||
playableRefs,
|
playableRefs,
|
||||||
initialIndex: startIdx >= 0 ? startIdx : 0,
|
initialIndex: startIdx >= 0 ? startIdx : 0,
|
||||||
|
source: detail.playlist.refreshable
|
||||||
|
? detail.playlist.systemVariant
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
// Keep liveTrack referenced to avoid an unused-variable
|
// Keep liveTrack referenced to avoid an unused-variable
|
||||||
// warning while we leave hooks for menu wiring later.
|
// warning while we leave hooks for menu wiring later.
|
||||||
@@ -141,29 +264,22 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (playable.isNotEmpty) ...[
|
if (playable.isNotEmpty) ...[
|
||||||
OutlinedButton.icon(
|
if (p.refreshable) ...[
|
||||||
key: const Key('download_playlist_button'),
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
key: const Key('regenerate_playlist_button'),
|
||||||
final mgr = ref.read(audioCacheManagerProvider);
|
onPressed: () => _regenerate(context, ref),
|
||||||
for (final t in playable) {
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
final id = t.trackId ?? '';
|
label: const Text('Regenerate'),
|
||||||
if (id.isEmpty) continue;
|
),
|
||||||
// Fire-and-forget; downloads happen in background.
|
const SizedBox(width: 8),
|
||||||
// ignore: unawaited_futures
|
],
|
||||||
mgr.pin(id, source: CacheSource.autoPlaylist);
|
|
||||||
}
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.download, size: 16),
|
|
||||||
label: const Text('Download'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||||
ref.read(playerActionsProvider).playTracks(refs);
|
ref.read(playerActionsProvider).playTracks(
|
||||||
|
refs,
|
||||||
|
source: p.refreshable ? p.systemVariant : null,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
label: const Text('Play'),
|
label: const Text('Play'),
|
||||||
@@ -177,53 +293,102 @@ class _Header extends ConsumerWidget {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forces a server-side rebuild of this system playlist. The
|
||||||
|
/// rebuild rotates the playlist UUID, so the old detail route now
|
||||||
|
/// 404s — rebind by pushReplacement-ing to the new id. The
|
||||||
|
/// aggregate list is invalidated too so the home row's tile points
|
||||||
|
/// at the fresh UUID. ScaffoldMessenger + router captured before
|
||||||
|
/// the await so we don't touch a stale BuildContext after.
|
||||||
|
Future<void> _regenerate(BuildContext context, WidgetRef ref) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
final p = detail.playlist;
|
||||||
|
try {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
final newId = await api.refreshSystem(p.systemVariant!);
|
||||||
|
ref.invalidate(playlistsListProvider);
|
||||||
|
if (newId == null) {
|
||||||
|
messenger.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Nothing to build yet — library is empty.'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('${p.name} regenerated')),
|
||||||
|
);
|
||||||
|
router.pushReplacement('/playlists/$newId');
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't regenerate: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistTrackRow extends StatelessWidget {
|
class _PlaylistTrackRow extends ConsumerWidget {
|
||||||
const _PlaylistTrackRow({required this.row, required this.onTap});
|
const _PlaylistTrackRow({required this.row, required this.onTap});
|
||||||
final PlaylistTrack row;
|
final PlaylistTrack row;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
|
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||||
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
|
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
|
||||||
final color = row.isAvailable ? fs.parchment : fs.ash;
|
// "Now playing" highlight — matches _QueueRow and TrackRow so the
|
||||||
return InkWell(
|
// user sees which playlist row is current without reading the
|
||||||
onTap: onTap,
|
// player bar. Unavailable rows never match.
|
||||||
child: Padding(
|
final currentId = ref.watch(mediaItemProvider).value?.id;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
final isCurrent = row.isAvailable &&
|
||||||
child: Row(children: [
|
currentId != null &&
|
||||||
Expanded(
|
currentId == row.trackId;
|
||||||
child: Column(
|
final baseColor = row.isAvailable ? fs.parchment : fs.ash;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final titleColor = isCurrent ? fs.accent : baseColor;
|
||||||
children: [
|
return Container(
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
row.title,
|
color: isCurrent ? fs.iron : null,
|
||||||
maxLines: 1,
|
border: isCurrent
|
||||||
overflow: TextOverflow.ellipsis,
|
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||||
style: TextStyle(
|
: null,
|
||||||
color: color,
|
),
|
||||||
fontSize: 14,
|
child: InkWell(
|
||||||
decoration: row.isAvailable
|
onTap: onTap,
|
||||||
? null
|
child: Padding(
|
||||||
: TextDecoration.lineThrough,
|
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(
|
||||||
Text(
|
'${row.artistName} · ${row.albumTitle}',
|
||||||
'${row.artistName} · ${row.albumTitle}',
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
if (row.trackId != null)
|
||||||
if (row.trackId != null)
|
TrackActionsButton(track: _toTrackRef(row)),
|
||||||
TrackActionsButton(track: _toTrackRef(row)),
|
]),
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget {
|
|||||||
final p = items[i];
|
final p = items[i];
|
||||||
return _PlaylistTile(
|
return _PlaylistTile(
|
||||||
playlist: p,
|
playlist: p,
|
||||||
onTap: () => ctx.push('/playlists/${p.id}'),
|
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
@@ -40,11 +41,6 @@ final playlistsListProvider =
|
|||||||
fetchAndPopulate: () async {
|
fetchAndPopulate: () async {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
final fresh = await api.list(kind: kind);
|
final fresh = await api.list(kind: kind);
|
||||||
final ownedSysCount =
|
|
||||||
fresh.owned.where((p) => p.systemVariant != null).length;
|
|
||||||
debugPrint(
|
|
||||||
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
|
|
||||||
'(system=$ownedSysCount) public=${fresh.public.length}');
|
|
||||||
|
|
||||||
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
||||||
// every rebuild, so insertOrReplace alone leaves stale rows in
|
// every rebuild, so insertOrReplace alone leaves stale rows in
|
||||||
@@ -81,13 +77,6 @@ final playlistsListProvider =
|
|||||||
.where((r) => r.userId != user.id && r.isPublic)
|
.where((r) => r.userId != user.id && r.isPublic)
|
||||||
.map((r) => r.toRef())
|
.map((r) => r.toRef())
|
||||||
.toList();
|
.toList();
|
||||||
final ownedSys = owned.where((p) => p.isSystem).length;
|
|
||||||
final driftSys = filtered.where((r) => r.systemVariant != null).length;
|
|
||||||
debugPrint(
|
|
||||||
'playlistsListProvider($kind): drift rows=${rows.length} '
|
|
||||||
'filtered=${filtered.length} (system=$driftSys) '
|
|
||||||
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
|
|
||||||
'userId=${user.id}');
|
|
||||||
return PlaylistsList(owned: owned, public: pub);
|
return PlaylistsList(owned: owned, public: pub);
|
||||||
},
|
},
|
||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
@@ -153,10 +142,7 @@ final playlistDetailProvider =
|
|||||||
Future<bool> fetchAndPopulate() async {
|
Future<bool> fetchAndPopulate() async {
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
debugPrint('playlistDetailProvider($id): calling get');
|
|
||||||
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
|
|
||||||
|
|
||||||
// Collect the track + artist + album rows referenced by these
|
// Collect the track + artist + album rows referenced by these
|
||||||
// playlist entries. Without writing the cachedTracks rows
|
// playlist entries. Without writing the cachedTracks rows
|
||||||
@@ -262,8 +248,6 @@ final playlistDetailProvider =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
||||||
@@ -272,8 +256,6 @@ final playlistDetailProvider =
|
|||||||
// stale row + its track positions so the home tile disappears
|
// stale row + its track positions so the home tile disappears
|
||||||
// on next render and we don't keep trying.
|
// on next render and we don't keep trying.
|
||||||
if (e is DioException && e.response?.statusCode == 404) {
|
if (e is DioException && e.response?.statusCode == 404) {
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): server says 404, evicting stale drift rows');
|
|
||||||
await db.batch((b) {
|
await db.batch((b) {
|
||||||
b.deleteWhere(
|
b.deleteWhere(
|
||||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||||
@@ -292,7 +274,6 @@ final playlistDetailProvider =
|
|||||||
|
|
||||||
await for (final playlistRows in playlistQuery.watch()) {
|
await for (final playlistRows in playlistQuery.watch()) {
|
||||||
if (playlistRows.isEmpty) {
|
if (playlistRows.isEmpty) {
|
||||||
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
|
|
||||||
if (fetchAttempted) {
|
if (fetchAttempted) {
|
||||||
yield emptyDetail();
|
yield emptyDetail();
|
||||||
continue;
|
continue;
|
||||||
@@ -307,9 +288,6 @@ final playlistDetailProvider =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
|
|
||||||
|
|
||||||
final playlist = playlistRows.first.toRef();
|
final playlist = playlistRows.first.toRef();
|
||||||
final trackRows = await tracksQuery.get();
|
final trackRows = await tracksQuery.get();
|
||||||
|
|
||||||
@@ -318,8 +296,6 @@ final playlistDetailProvider =
|
|||||||
// tracks for system playlists). Trigger the same fetch, drift
|
// tracks for system playlists). Trigger the same fetch, drift
|
||||||
// watch re-emits with populated tracks.
|
// watch re-emits with populated tracks.
|
||||||
if (trackRows.isEmpty && !fetchAttempted) {
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
|
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
final ok = await fetchAndPopulate();
|
final ok = await fetchAndPopulate();
|
||||||
@@ -387,8 +363,41 @@ class _TrackRefRow {
|
|||||||
final int durationSec;
|
final int durationSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
|
||||||
|
/// status (single-row JSON blob populated by /api/me/system-playlists-
|
||||||
|
/// status). Home Playlists row reads this every render to choose
|
||||||
|
/// between real cards and "building / pending / failed" placeholders
|
||||||
|
/// — drift-first means the row paints with the prior status instantly
|
||||||
|
/// rather than flickering through SystemPlaylistsStatus.empty() while
|
||||||
|
/// the REST round-trip resolves. SWR refresh on every visit keeps it
|
||||||
|
/// current.
|
||||||
final systemPlaylistsStatusProvider =
|
final systemPlaylistsStatusProvider =
|
||||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
StreamProvider<SystemPlaylistsStatus>((ref) {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final db = ref.watch(appDbProvider);
|
||||||
return MeApi(dio).systemPlaylistsStatus();
|
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
|
||||||
|
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final fresh = await MeApi(dio).systemPlaylistsStatus();
|
||||||
|
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
|
||||||
|
CachedSystemPlaylistsStatusCompanion.insert(
|
||||||
|
json: jsonEncode({
|
||||||
|
'in_flight': fresh.inFlight,
|
||||||
|
'last_run_at': fresh.lastRunAt,
|
||||||
|
'last_error': fresh.lastError,
|
||||||
|
}),
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.isEmpty
|
||||||
|
? SystemPlaylistsStatus.empty()
|
||||||
|
: SystemPlaylistsStatus.fromJson(
|
||||||
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'systemPlaylistsStatus',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../cache/offline_provider.dart';
|
||||||
|
import '../../library/widgets/play_circle_button.dart';
|
||||||
import '../../models/playlist.dart';
|
import '../../models/playlist.dart';
|
||||||
|
import '../../models/track.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
import '../../shared/widgets/server_image.dart';
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import '../playlists_provider.dart';
|
||||||
|
|
||||||
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
|
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
|
||||||
/// name + optional system-variant badge below. Tap pushes
|
/// name + optional system-variant badge below. Tap pushes
|
||||||
/// `/playlists/{id}`.
|
/// `/playlists/{id}`. The bottom-right play button fetches the
|
||||||
class PlaylistCard extends StatelessWidget {
|
/// 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});
|
const PlaylistCard({super.key, required this.playlist});
|
||||||
|
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// #427 S4: refreshable system playlists need the live build/
|
||||||
|
// shuffle endpoints — unavailable offline. Disable their play
|
||||||
|
// (use Shuffle all instead). User playlists play from cache.
|
||||||
|
final offline = ref.watch(offlineProvider);
|
||||||
|
final hasTracks = playlist.trackCount > 0 &&
|
||||||
|
!(offline && playlist.refreshable);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 176,
|
width: 176,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.push('/playlists/${playlist.id}'),
|
onTap: () =>
|
||||||
|
context.push('/playlists/${playlist.id}', extra: playlist),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
ClipRRect(
|
// Stack: collage + overlaid play button at bottom-right.
|
||||||
borderRadius: BorderRadius.circular(6),
|
Stack(
|
||||||
child: Container(
|
children: [
|
||||||
width: 144,
|
ClipRRect(
|
||||||
height: 144,
|
borderRadius: BorderRadius.circular(6),
|
||||||
color: fs.slate,
|
child: Container(
|
||||||
// coverUrl is deterministic per playlist (see
|
width: 144,
|
||||||
// CachedPlaylistAdapter.toRef). When the server's
|
height: 144,
|
||||||
// collage isn't built yet, ServerImage's
|
color: fs.slate,
|
||||||
// errorBuilder shows the queue_music icon over the
|
// coverUrl is deterministic per playlist (see
|
||||||
// slate background.
|
// CachedPlaylistAdapter.toRef). When the server's
|
||||||
child: playlist.coverUrl.isEmpty
|
// collage isn't built yet, ServerImage's
|
||||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
// errorBuilder shows the queue_music icon over the
|
||||||
: ServerImage(
|
// slate background.
|
||||||
url: playlist.coverUrl,
|
child: playlist.coverUrl.isEmpty
|
||||||
fit: BoxFit.cover,
|
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||||
fallback:
|
: ServerImage(
|
||||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
url: playlist.coverUrl,
|
||||||
),
|
fit: BoxFit.cover,
|
||||||
),
|
fallback:
|
||||||
|
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
child: PlayCircleButton(
|
||||||
|
enabled: hasTracks,
|
||||||
|
onPressed: () => _playPlaylist(ref),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// System playlists get a refresh affordance so the
|
||||||
|
// user can force a fresh mix instead of waiting for
|
||||||
|
// the daily 03:00 rebuild. Mirrors the web kebab.
|
||||||
|
if (playlist.refreshable)
|
||||||
|
Positioned(
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
icon: Icon(Icons.more_vert, color: fs.parchment, size: 18),
|
||||||
|
tooltip: 'Playlist actions',
|
||||||
|
color: fs.iron,
|
||||||
|
onSelected: (_) => _refresh(context, ref),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: 'refresh',
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(Icons.refresh, color: fs.parchment, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_refreshLabel,
|
||||||
|
style: TextStyle(color: fs.parchment)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -69,10 +122,102 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '')
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
_refreshedLabel(playlist.createdAt),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get _refreshLabel => 'Refresh ${playlist.name}';
|
||||||
|
|
||||||
|
/// #417: system playlists atomic-replace on rebuild, so createdAt
|
||||||
|
/// is the last-rotated time. Friendly wording mirrors the web
|
||||||
|
/// PlaylistCard. Empty string when the timestamp is unparseable.
|
||||||
|
String _refreshedLabel(String iso) {
|
||||||
|
final t = DateTime.tryParse(iso);
|
||||||
|
if (t == null) return '';
|
||||||
|
final now = DateTime.now();
|
||||||
|
final mins = now.difference(t).inMinutes;
|
||||||
|
if (mins < 5) return 'Refreshed just now';
|
||||||
|
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||||
|
if (!t.isBefore(startOfToday)) return 'Refreshed today';
|
||||||
|
final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays;
|
||||||
|
if (days <= 1) return 'Refreshed yesterday';
|
||||||
|
if (days < 7) return 'Refreshed $days days ago';
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
return 'Refreshed ${months[t.month - 1]} ${t.day}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces a server-side rebuild of this system playlist, then
|
||||||
|
/// invalidates the aggregate list so the rotated UUID + new tracks
|
||||||
|
/// land on the next read. ScaffoldMessenger is captured before the
|
||||||
|
/// await so we don't touch a stale BuildContext afterward.
|
||||||
|
Future<void> _refresh(BuildContext context, WidgetRef ref) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
await api.refreshSystem(playlist.systemVariant!);
|
||||||
|
ref.invalidate(playlistsListProvider);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('${playlist.name} refreshed')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't refresh: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
// Refreshable (singleton) system playlists: fetch the server's
|
||||||
|
// rotation-aware order (#415) and play as-is, tagged with the
|
||||||
|
// source so the reporter advances rotation. User playlists AND
|
||||||
|
// non-singleton system kinds (songs_like_artist — no by-kind
|
||||||
|
// endpoint) play the stored order via get(), untagged.
|
||||||
|
final detail = playlist.refreshable
|
||||||
|
? await api.systemShuffle(playlist.systemVariant!)
|
||||||
|
: 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;
|
||||||
|
// Rotation-aware kinds arrive pre-ordered from the server — play
|
||||||
|
// as-is, tagged so the reporter advances rotation. No client
|
||||||
|
// shuffle. Everything else plays stored order, untagged.
|
||||||
|
await ref.read(playerActionsProvider).playTracks(
|
||||||
|
refs,
|
||||||
|
initialIndex: 0,
|
||||||
|
source: playlist.refreshable ? playlist.systemVariant : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
import '../api/endpoints/quarantine.dart';
|
import '../api/endpoints/quarantine.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../cache/db.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
@@ -10,58 +17,165 @@ final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
|
|||||||
return QuarantineApi(await ref.watch(dioProvider.future));
|
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>> {
|
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
||||||
@override
|
@override
|
||||||
Future<List<QuarantineMineRow>> build() async {
|
Future<List<QuarantineMineRow>> build() async {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final db = ref.watch(appDbProvider);
|
||||||
return MeApi(dio).quarantineMine();
|
|
||||||
|
// 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.
|
/// True when this track is in the caller's quarantine list.
|
||||||
bool isHidden(String trackId) =>
|
bool isHidden(String trackId) =>
|
||||||
(state.value ?? const []).any((r) => r.trackId == trackId);
|
(state.value ?? const []).any((r) => r.trackId == trackId);
|
||||||
|
|
||||||
/// Optimistic flag: prepends a synthetic row, calls server,
|
/// Optimistic flag: inserts the synthetic row into drift (watch fires
|
||||||
/// rolls back on error.
|
/// immediately so the UI updates), calls server, rolls back on error.
|
||||||
Future<void> flag(TrackRef track, String reason, String notes) async {
|
Future<void> flag(TrackRef track, String reason, String notes) async {
|
||||||
final api = await ref.read(quarantineApiProvider.future);
|
final db = ref.read(appDbProvider);
|
||||||
final current = state.value ?? const <QuarantineMineRow>[];
|
final existed = await (db.select(db.cachedQuarantineMine)
|
||||||
if (current.any((r) => r.trackId == track.id)) return; // already hidden
|
..where((t) => t.trackId.equals(track.id)))
|
||||||
final synthetic = QuarantineMineRow(
|
.getSingleOrNull();
|
||||||
|
if (existed != null) return; // already hidden
|
||||||
|
|
||||||
|
final companion = CachedQuarantineMineCompanion.insert(
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
notes: notes.isEmpty ? null : notes,
|
notes: notes.isEmpty
|
||||||
|
? const drift.Value.absent()
|
||||||
|
: drift.Value(notes),
|
||||||
createdAt: DateTime.now().toUtc().toIso8601String(),
|
createdAt: DateTime.now().toUtc().toIso8601String(),
|
||||||
trackTitle: track.title,
|
trackTitle: track.title,
|
||||||
trackDurationMs: track.durationSec * 1000,
|
trackDurationMs: drift.Value(track.durationSec * 1000),
|
||||||
albumId: track.albumId,
|
albumId: track.albumId,
|
||||||
albumTitle: track.albumTitle,
|
albumTitle: track.albumTitle,
|
||||||
artistId: track.artistId,
|
artistId: track.artistId,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
);
|
);
|
||||||
state = AsyncData([synthetic, ...current]);
|
await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
await api.flag(track.id, reason, notes: notes);
|
await api.flag(track.id, reason, notes: notes);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
state = AsyncData(current);
|
// REST failed — don't roll back; queue for replay so the user's
|
||||||
Error.throwWithStackTrace(e, st);
|
// "hide this track" intent persists offline.
|
||||||
|
await ref.read(mutationQueueProvider).enqueue(
|
||||||
|
MutationKinds.quarantineFlag,
|
||||||
|
{'trackId': track.id, 'reason': reason, 'notes': notes},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optimistic unflag: removes the row, calls server, rolls back on error.
|
/// Optimistic unflag: removes the drift row, calls server, queues
|
||||||
|
/// the call on failure so the unhide intent persists offline.
|
||||||
Future<void> unflag(String trackId) async {
|
Future<void> unflag(String trackId) async {
|
||||||
final api = await ref.read(quarantineApiProvider.future);
|
final db = ref.read(appDbProvider);
|
||||||
final current = state.value ?? const <QuarantineMineRow>[];
|
final existing = await (db.select(db.cachedQuarantineMine)
|
||||||
final removed = current.where((r) => r.trackId == trackId).toList();
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
if (removed.isEmpty) return;
|
.getSingleOrNull();
|
||||||
state = AsyncData(current.where((r) => r.trackId != trackId).toList());
|
if (existing == null) return;
|
||||||
|
|
||||||
|
await (db.delete(db.cachedQuarantineMine)
|
||||||
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
|
.go();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
await api.unflag(trackId);
|
await api.unflag(trackId);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
state = AsyncData(current);
|
// Don't restore the drift row; queue the unflag for replay.
|
||||||
Error.throwWithStackTrace(e, st);
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
final myQuarantineProvider =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/requests.dart';
|
import '../api/endpoints/requests.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/admin_request.dart';
|
import '../models/admin_request.dart';
|
||||||
|
|
||||||
@@ -41,17 +42,20 @@ class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optimistic remove + REST cancel. Restores the row on failure so
|
/// Optimistic remove + REST cancel. On failure the row stays
|
||||||
/// the user can retry — silent failure would lie about success.
|
/// removed in-memory and the cancel is queued for replay — this
|
||||||
|
/// keeps the user's intent visible across network loss instead of
|
||||||
|
/// restoring a row they explicitly asked to remove.
|
||||||
Future<void> cancel(String id) async {
|
Future<void> cancel(String id) async {
|
||||||
final api = await ref.read(requestsApiProvider.future);
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
final current = state.value ?? const <AdminRequest>[];
|
final current = state.value ?? const <AdminRequest>[];
|
||||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||||
try {
|
try {
|
||||||
await api.cancel(id);
|
await api.cancel(id);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
state = AsyncData(current);
|
await ref
|
||||||
Error.throwWithStackTrace(e, st);
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCancel, {'id': id});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../models/admin_request.dart';
|
import '../models/admin_request.dart';
|
||||||
|
import '../shared/live_events_provider.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'requests_provider.dart';
|
import 'requests_provider.dart';
|
||||||
@@ -13,6 +14,16 @@ class RequestsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// #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);
|
final requests = ref.watch(myRequestsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../theme/theme_extension.dart';
|
||||||
|
import '../update/client_update_provider.dart';
|
||||||
|
import '../update/update_info.dart';
|
||||||
|
|
||||||
|
final _packageInfoProvider = FutureProvider<PackageInfo>((_) {
|
||||||
|
return PackageInfo.fromPlatform();
|
||||||
|
});
|
||||||
|
|
||||||
|
class AboutSection extends ConsumerStatefulWidget {
|
||||||
|
const AboutSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AboutSection> createState() => _AboutSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _InstallStage { idle, downloading, error }
|
||||||
|
|
||||||
|
class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||||
|
DateTime? _lastChecked;
|
||||||
|
bool _checking = false;
|
||||||
|
|
||||||
|
_InstallStage _installStage = _InstallStage.idle;
|
||||||
|
double _installProgress = 0;
|
||||||
|
String? _installError;
|
||||||
|
|
||||||
|
Future<void> _checkNow() async {
|
||||||
|
setState(() => _checking = true);
|
||||||
|
ref.invalidate(clientUpdateProvider);
|
||||||
|
try {
|
||||||
|
await ref.read(clientUpdateProvider.future);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_checking = false;
|
||||||
|
_lastChecked = DateTime.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _install(UpdateInfo info) async {
|
||||||
|
setState(() {
|
||||||
|
_installStage = _InstallStage.downloading;
|
||||||
|
_installProgress = 0;
|
||||||
|
_installError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final installer = await ref.read(updateInstallerProvider.future);
|
||||||
|
final path = await installer.download(
|
||||||
|
info.apkUrl,
|
||||||
|
onProgress: (p) {
|
||||||
|
if (mounted) setState(() => _installProgress = p);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await installer.install(path);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_installStage = _InstallStage.error;
|
||||||
|
_installError = '$e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final pkg = ref.watch(_packageInfoProvider);
|
||||||
|
final update = ref.watch(clientUpdateProvider);
|
||||||
|
|
||||||
|
final installed = pkg.value == null
|
||||||
|
? '…'
|
||||||
|
: '${pkg.value!.version}+${pkg.value!.buildNumber}';
|
||||||
|
|
||||||
|
final UpdateInfo? available = update.value;
|
||||||
|
final hasUpdate = available != null;
|
||||||
|
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
|
child: _SectionHeader('About'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: fs.parchment, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Installed version',
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(installed,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.ash,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'JetBrainsMono')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cloud_outlined, color: fs.parchment, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Latest version',
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
hasUpdate ? available.version : _statusFor(update, installed),
|
||||||
|
style: TextStyle(
|
||||||
|
color: hasUpdate ? fs.accent : fs.ash,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'JetBrainsMono'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_lastChecked != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: Text(
|
||||||
|
'Last checked ${_formatTime(_lastChecked!)}',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('about_check_for_updates'),
|
||||||
|
onPressed: _checking ? null : _checkNow,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: fs.accent,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
icon: _checking
|
||||||
|
? SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: Text(_checking ? 'Checking…' : 'Check for updates'),
|
||||||
|
),
|
||||||
|
if (hasUpdate) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('about_install_update'),
|
||||||
|
onPressed: _installStage == _InstallStage.downloading
|
||||||
|
? null
|
||||||
|
: () => _install(available),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: fs.moss,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
icon: _installStage == _InstallStage.downloading
|
||||||
|
? SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
value: _installProgress > 0 ? _installProgress : null,
|
||||||
|
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.system_update, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_installStage == _InstallStage.downloading
|
||||||
|
? 'Downloading…'
|
||||||
|
: _installStage == _InstallStage.error
|
||||||
|
? 'Retry install'
|
||||||
|
: 'Install ${available.version}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_installStage == _InstallStage.error && _installError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Text(_installError!,
|
||||||
|
style: TextStyle(color: fs.error, fontSize: 12),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusFor(AsyncValue<UpdateInfo?> update, String installed) {
|
||||||
|
if (update.isLoading) return 'Checking…';
|
||||||
|
if (update.hasError) return 'Check failed';
|
||||||
|
return 'Up to date';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime t) {
|
||||||
|
final h = t.hour.toString().padLeft(2, '0');
|
||||||
|
final m = t.minute.toString().padLeft(2, '0');
|
||||||
|
return '$h:$m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
const _SectionHeader(this.label);
|
||||||
|
final String label;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../library/library_providers.dart' show dioProvider;
|
|||||||
import '../models/my_profile.dart';
|
import '../models/my_profile.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'about_section.dart';
|
||||||
import 'storage_section.dart';
|
import 'storage_section.dart';
|
||||||
import '../theme/theme_mode_provider.dart';
|
import '../theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
_PasswordSection(),
|
_PasswordSection(),
|
||||||
_Divider(),
|
_Divider(),
|
||||||
_ListenBrainzSection(),
|
_ListenBrainzSection(),
|
||||||
|
_Divider(),
|
||||||
|
AboutSection(),
|
||||||
_AdminSection(),
|
_AdminSection(),
|
||||||
SizedBox(height: 96),
|
SizedBox(height: 96),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../cache/cache_settings_provider.dart';
|
import '../cache/cache_settings_provider.dart';
|
||||||
import '../cache/sync_controller.dart';
|
import '../cache/sync_controller.dart';
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
|
||||||
/// Settings card: usage display, cap selector, prefetch window selector,
|
/// Settings card: per-bucket usage, two cap selectors (Liked +
|
||||||
/// cache-liked toggle, Clear cache + Sync now buttons.
|
/// Recently-played), prefetch window, cache-liked toggle, Clear
|
||||||
|
/// cache + Sync now. #427 S2/S3: two independent budgets.
|
||||||
class StorageSection extends ConsumerStatefulWidget {
|
class StorageSection extends ConsumerStatefulWidget {
|
||||||
const StorageSection({super.key});
|
const StorageSection({super.key});
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ class StorageSection extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StorageSectionState extends ConsumerState<StorageSection> {
|
class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||||
int? _usageBytes;
|
BucketUsage? _usage;
|
||||||
bool _syncing = false;
|
bool _syncing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -25,10 +27,13 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
_refreshUsage();
|
_refreshUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> _likedSet() =>
|
||||||
|
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
|
||||||
Future<void> _refreshUsage() async {
|
Future<void> _refreshUsage() async {
|
||||||
final mgr = ref.read(audioCacheManagerProvider);
|
final mgr = ref.read(audioCacheManagerProvider);
|
||||||
final used = await mgr.usageBytes();
|
final u = await mgr.bucketUsage(_likedSet());
|
||||||
if (mounted) setState(() => _usageBytes = used);
|
if (mounted) setState(() => _usage = u);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmtBytes(int? n) {
|
String _fmtBytes(int? n) {
|
||||||
@@ -36,10 +41,14 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
if (n == 0) return '0 B';
|
if (n == 0) return '0 B';
|
||||||
if (n < 1024) return '$n B';
|
if (n < 1024) return '$n B';
|
||||||
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
|
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
|
||||||
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
if (n < 1024 * 1024 * 1024) {
|
||||||
|
return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
|
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
@@ -65,17 +74,32 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w500)),
|
fontWeight: FontWeight.w500)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(children: [
|
_usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
|
||||||
Text('Cache usage', style: TextStyle(color: fs.ash)),
|
const SizedBox(height: 4),
|
||||||
const Spacer(),
|
_usageRow('Recently played', _usage?.rolling,
|
||||||
Text(
|
s.rollingCapBytes, fs),
|
||||||
'${_fmtBytes(_usageBytes)} / '
|
|
||||||
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
|
|
||||||
style: TextStyle(color: fs.parchment),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_capSelector(s, fs),
|
_capSelector(
|
||||||
|
'Liked cache limit',
|
||||||
|
const Key('liked_cap_selector'),
|
||||||
|
s.likedCapBytes,
|
||||||
|
(v) => ref
|
||||||
|
.read(cacheSettingsProvider.notifier)
|
||||||
|
.setLikedCapBytes(v),
|
||||||
|
s,
|
||||||
|
fs,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_capSelector(
|
||||||
|
'Recently-played cache limit',
|
||||||
|
const Key('rolling_cap_selector'),
|
||||||
|
s.rollingCapBytes,
|
||||||
|
(v) => ref
|
||||||
|
.read(cacheSettingsProvider.notifier)
|
||||||
|
.setRollingCapBytes(v),
|
||||||
|
s,
|
||||||
|
fs,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_prefetchSelector(s, fs),
|
_prefetchSelector(s, fs),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -128,29 +152,52 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
|
Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) {
|
||||||
const options = [
|
|
||||||
(1024 * 1024 * 1024, '1 GB'),
|
|
||||||
(5 * 1024 * 1024 * 1024, '5 GB'),
|
|
||||||
(10 * 1024 * 1024 * 1024, '10 GB'),
|
|
||||||
(25 * 1024 * 1024 * 1024, '25 GB'),
|
|
||||||
(0, 'Unlimited'),
|
|
||||||
];
|
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
|
Text(label, style: TextStyle(color: fs.ash)),
|
||||||
|
const Spacer(),
|
||||||
|
Text('${_fmtBytes(used)} / ${_cap(cap)}',
|
||||||
|
style: TextStyle(color: fs.parchment)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _capOptions = [
|
||||||
|
(1024 * 1024 * 1024, '1 GB'),
|
||||||
|
(5 * 1024 * 1024 * 1024, '5 GB'),
|
||||||
|
(10 * 1024 * 1024 * 1024, '10 GB'),
|
||||||
|
(25 * 1024 * 1024 * 1024, '25 GB'),
|
||||||
|
(0, 'Unlimited'),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _capSelector(
|
||||||
|
String label,
|
||||||
|
Key key,
|
||||||
|
int current,
|
||||||
|
Future<void> Function(int) setCap,
|
||||||
|
CacheSettings s,
|
||||||
|
FabledSwordTheme fs,
|
||||||
|
) {
|
||||||
|
return Row(children: [
|
||||||
|
Expanded(child: Text(label, style: TextStyle(color: fs.ash))),
|
||||||
DropdownButton<int>(
|
DropdownButton<int>(
|
||||||
key: const Key('cap_selector'),
|
key: key,
|
||||||
value: options.any((o) => o.$1 == s.capBytes)
|
value: _capOptions.any((o) => o.$1 == current)
|
||||||
? s.capBytes
|
? current
|
||||||
: 5 * 1024 * 1024 * 1024,
|
: 5 * 1024 * 1024 * 1024,
|
||||||
items: options
|
items: _capOptions
|
||||||
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
|
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) async {
|
onChanged: (v) async {
|
||||||
if (v == null) return;
|
if (v == null) return;
|
||||||
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
|
await setCap(v);
|
||||||
if (v > 0) {
|
// Enforce immediately against the freshest settings.
|
||||||
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
|
final fresh = ref.read(cacheSettingsProvider).value;
|
||||||
|
if (fresh != null) {
|
||||||
|
await ref.read(audioCacheManagerProvider).evictBuckets(
|
||||||
|
likedCap: fresh.likedCapBytes,
|
||||||
|
rollingCap: fresh.rollingCapBytes,
|
||||||
|
liked: _likedSet(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await _refreshUsage();
|
await _refreshUsage();
|
||||||
},
|
},
|
||||||
@@ -161,7 +208,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
|
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
|
||||||
const options = [1, 3, 5, 7, 10];
|
const options = [1, 3, 5, 7, 10];
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
Expanded(
|
||||||
|
child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
||||||
DropdownButton<int>(
|
DropdownButton<int>(
|
||||||
key: const Key('prefetch_selector'),
|
key: const Key('prefetch_selector'),
|
||||||
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
|
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
|
||||||
@@ -183,8 +231,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Clear cache?'),
|
title: const Text('Clear cache?'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This deletes all cached audio files (including manually downloaded ones). '
|
'This deletes all cached audio (liked and recently-played). '
|
||||||
'The next play of any track will re-download from the server.',
|
'The next play of any track re-downloads from the server.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../library/album_detail_screen.dart';
|
|||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -120,7 +121,10 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/playlists/:id',
|
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: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||||
@@ -147,6 +151,11 @@ class _ShellWithPlayerBar extends ConsumerWidget {
|
|||||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
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(),
|
const UpdateBanner(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: hasBanner
|
child: hasBanner
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
|
||||||
|
show HttpExceptionWithStatus;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../auth/auth_provider.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
|
/// `/api/albums/<id>/cover`) against the configured server base URL
|
||||||
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
||||||
/// unchanged. Empty/null base or empty URL render the [fallback]
|
/// unchanged. Empty/null base or empty URL render the [fallback]
|
||||||
@@ -12,6 +15,11 @@ import '../../auth/auth_provider.dart';
|
|||||||
///
|
///
|
||||||
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
||||||
/// stream URLs, so cover art and audio resolve URLs the same way.
|
/// 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 {
|
class ServerImage extends ConsumerWidget {
|
||||||
const ServerImage({
|
const ServerImage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -31,17 +39,18 @@ class ServerImage extends ConsumerWidget {
|
|||||||
final base = ref.watch(serverUrlProvider).value;
|
final base = ref.watch(serverUrlProvider).value;
|
||||||
final resolved = _resolve(base, url);
|
final resolved = _resolve(base, url);
|
||||||
if (resolved == null) return empty;
|
if (resolved == null) return empty;
|
||||||
// Cover endpoints are gated by RequireUser server-side. Image.network
|
// Cover endpoints are gated by RequireUser server-side. The image
|
||||||
// doesn't carry cookies/Bearer tokens automatically the way the
|
// loader doesn't carry cookies/Bearer tokens automatically the way
|
||||||
// browser's <img> tag does, so we forward the session token as a
|
// the browser's <img> tag does, so we forward the session token as
|
||||||
// header. Without this, the server returns 401 and the image fails.
|
// a header. Without this, the server returns 401 and the image
|
||||||
|
// fails.
|
||||||
//
|
//
|
||||||
// sessionTokenProvider is a FutureProvider — on first read after a
|
// sessionTokenProvider is a FutureProvider — on first read after a
|
||||||
// hot restart its .value is null until the secure-storage read
|
// hot restart its .value is null until the secure-storage read
|
||||||
// resolves. If we fired Image.network with headers:null at that
|
// resolves. If we mounted the image with httpHeaders:null at that
|
||||||
// moment, the network image cache would lock in the 401 response
|
// moment, the disk cache would lock in the 401 response and never
|
||||||
// and never retry. Instead, hold the fallback until the token is
|
// retry. Hold the fallback until the token is available, then
|
||||||
// available, then mount the Image with the auth header attached.
|
// mount the image with the auth header attached.
|
||||||
final tokenAsync = ref.watch(sessionTokenProvider);
|
final tokenAsync = ref.watch(sessionTokenProvider);
|
||||||
return tokenAsync.when(
|
return tokenAsync.when(
|
||||||
loading: () => empty,
|
loading: () => empty,
|
||||||
@@ -49,14 +58,34 @@ class ServerImage extends ConsumerWidget {
|
|||||||
data: (token) {
|
data: (token) {
|
||||||
final headers = (token != null && token.isNotEmpty)
|
final headers = (token != null && token.isNotEmpty)
|
||||||
? {'Authorization': 'Bearer $token'}
|
? {'Authorization': 'Bearer $token'}
|
||||||
: null;
|
: <String, String>{};
|
||||||
return Image.network(
|
return CachedNetworkImage(
|
||||||
resolved,
|
imageUrl: resolved,
|
||||||
|
httpHeaders: headers,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
headers: headers,
|
// 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
|
// Keep failures local — a single 401/timeout shouldn't dump
|
||||||
// a stack trace or replace the parent Container's background.
|
// a stack trace or replace the parent Container's background.
|
||||||
errorBuilder: (_, __, ___) => empty,
|
errorWidget: (_, __, ___) => empty,
|
||||||
|
// Filter expected 404s out of dev console noise: playlist
|
||||||
|
// collages aren't built until the playlist has tracks and
|
||||||
|
// the build job runs (system playlists like For-You /
|
||||||
|
// Discover hit this on first-render). The errorWidget
|
||||||
|
// already renders the fallback for the user; this just
|
||||||
|
// keeps the dev console clean. Non-404 errors still
|
||||||
|
// surface so auth/connectivity issues remain visible.
|
||||||
|
errorListener: (err) {
|
||||||
|
if (err is HttpExceptionWithStatus && err.statusCode == 404) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('ServerImage: $err');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
super.key,
|
||||||
required this.track,
|
required this.track,
|
||||||
this.hideQueueActions = false,
|
this.hideQueueActions = false,
|
||||||
|
this.onNavigate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
@@ -19,6 +20,11 @@ class TrackActionsButton extends StatelessWidget {
|
|||||||
/// screen where the menu's track IS the currently-playing one.
|
/// screen where the menu's track IS the currently-playing one.
|
||||||
final bool hideQueueActions;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
@@ -29,6 +35,7 @@ class TrackActionsButton extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
track,
|
track,
|
||||||
hideQueueActions: hideQueueActions,
|
hideQueueActions: hideQueueActions,
|
||||||
|
onNavigate: onNavigate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../api/endpoints/likes.dart';
|
import '../../../api/endpoints/likes.dart';
|
||||||
|
import '../../../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../../../cache/db.dart';
|
||||||
|
import '../../../cache/mutation_queue.dart';
|
||||||
import '../../../likes/likes_provider.dart';
|
import '../../../likes/likes_provider.dart';
|
||||||
import '../../../models/track.dart';
|
import '../../../models/track.dart';
|
||||||
import '../../../player/player_provider.dart';
|
import '../../../player/player_provider.dart';
|
||||||
@@ -21,15 +25,27 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.hideQueueActions,
|
required this.hideQueueActions,
|
||||||
|
this.onNavigate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
final bool hideQueueActions;
|
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(
|
static Future<void> show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TrackRef track, {
|
TrackRef track, {
|
||||||
bool hideQueueActions = false,
|
bool hideQueueActions = false,
|
||||||
|
Future<void> Function(String path)? onNavigate,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -37,6 +53,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
builder: (_) => TrackActionsSheet(
|
builder: (_) => TrackActionsSheet(
|
||||||
track: track,
|
track: track,
|
||||||
hideQueueActions: hideQueueActions,
|
hideQueueActions: hideQueueActions,
|
||||||
|
onNavigate: onNavigate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,7 +158,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
label: 'Go to album',
|
label: 'Go to album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.push('/albums/${track.albumId}');
|
final path = '/albums/${track.albumId}';
|
||||||
|
if (onNavigate != null) {
|
||||||
|
onNavigate!(path);
|
||||||
|
} else {
|
||||||
|
context.push(path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
@@ -150,7 +172,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
label: 'Go to artist',
|
label: 'Go to artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.push('/artists/${track.artistId}');
|
final path = '/artists/${track.artistId}';
|
||||||
|
if (onNavigate != null) {
|
||||||
|
onNavigate!(path);
|
||||||
|
} else {
|
||||||
|
context.push(path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const _Divider(),
|
const _Divider(),
|
||||||
@@ -232,7 +259,38 @@ typedef AddToPlaylistAction = Future<void> Function(String playlistId, String tr
|
|||||||
|
|
||||||
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
||||||
return (playlistId, trackId) async {
|
return (playlistId, trackId) async {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final db = ref.read(appDbProvider);
|
||||||
await api.appendTracks(playlistId, [trackId]);
|
// Optimistic drift write so the playlist detail screen shows the
|
||||||
|
// new track instantly. The position is best-effort — append after
|
||||||
|
// the current max for this playlist. The server's authoritative
|
||||||
|
// position lands on next playlistDetailProvider fetch (SWR), or
|
||||||
|
// when the queued mutation replays.
|
||||||
|
final maxPos = await (db.selectOnly(db.cachedPlaylistTracks)
|
||||||
|
..addColumns([db.cachedPlaylistTracks.position.max()])
|
||||||
|
..where(db.cachedPlaylistTracks.playlistId.equals(playlistId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final nextPos =
|
||||||
|
((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1);
|
||||||
|
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
|
||||||
|
CachedPlaylistTracksCompanion.insert(
|
||||||
|
playlistId: playlistId,
|
||||||
|
trackId: trackId,
|
||||||
|
position: drift.Value(nextPos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
await api.appendTracks(playlistId, [trackId]);
|
||||||
|
} catch (_) {
|
||||||
|
// REST failed — keep the optimistic drift row; queue the call.
|
||||||
|
await ref.read(mutationQueueProvider).enqueue(
|
||||||
|
MutationKinds.playlistAppend,
|
||||||
|
{
|
||||||
|
'playlistId': playlistId,
|
||||||
|
'trackIds': [trackId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.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/client.dart';
|
||||||
import '../../api/endpoints/health.dart';
|
import '../../api/endpoints/health.dart';
|
||||||
import '../../auth/auth_provider.dart';
|
import '../../auth/auth_provider.dart';
|
||||||
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async {
|
/// Result of the most recent /healthz version compatibility check.
|
||||||
final url = await ref.watch(serverUrlProvider.future);
|
///
|
||||||
if (url == null) return _VersionResult.skipped;
|
/// `skipped` means the server didn't emit a `min_client_version` field
|
||||||
final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null);
|
/// (older servers, partial deploys). Treat as compatible.
|
||||||
final body = await HealthApi(dio).check();
|
enum VersionResult { ok, tooOld, skipped }
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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});
|
const VersionGate({required this.child, super.key});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<VersionGate> createState() => _VersionGateState();
|
||||||
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())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TooOldScreen extends StatelessWidget {
|
class _VersionGateState extends ConsumerState<VersionGate>
|
||||||
const _TooOldScreen();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
void initState() {
|
||||||
return const Scaffold(
|
super.initState();
|
||||||
body: SafeArea(
|
// Read once so the provider's build() runs; the controller
|
||||||
child: Padding(
|
// hydrates from cache + fires a background check when stale.
|
||||||
padding: EdgeInsets.all(24),
|
ref.read(versionCheckProvider);
|
||||||
child: Center(
|
WidgetsBinding.instance.addObserver(this);
|
||||||
child: Column(
|
_startPolling();
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
}
|
||||||
children: [
|
|
||||||
Text(
|
@override
|
||||||
'Update required',
|
void dispose() {
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
|
_stopPolling();
|
||||||
),
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
SizedBox(height: 12),
|
super.dispose();
|
||||||
Text(
|
}
|
||||||
'This client is too old for that Minstrel server. Install the latest build to continue.',
|
|
||||||
textAlign: TextAlign.center,
|
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'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.6"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -327,7 +351,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
@@ -411,6 +435,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -664,6 +696,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -688,6 +728,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: minstrel
|
name: minstrel
|
||||||
description: Minstrel mobile client
|
description: Minstrel mobile client
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2026.5.11+1
|
version: 2026.5.16+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.5.0 <4.0.0'
|
sdk: '>=3.5.0 <4.0.0'
|
||||||
@@ -28,6 +28,21 @@ dependencies:
|
|||||||
drift_flutter: ^0.2.0
|
drift_flutter: ^0.2.0
|
||||||
sqlite3_flutter_libs: ^0.5.24
|
sqlite3_flutter_libs: ^0.5.24
|
||||||
connectivity_plus: ^6.0.5
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
+36
-17
@@ -4,6 +4,7 @@ library;
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:drift/native.dart' show NativeDatabase;
|
import 'package:drift/native.dart' show NativeDatabase;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -54,7 +55,8 @@ void main() {
|
|||||||
expect(await mgr.usageBytes(), 350);
|
expect(await mgr.usageBytes(), 350);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eviction order: incidental first, manual never', skip: _skipDrift, () async {
|
test('rolling cap evicts non-liked LRU; liked protected',
|
||||||
|
skip: _skipDrift, () async {
|
||||||
final db = _testDb();
|
final db = _testDb();
|
||||||
addTearDown(db.close);
|
addTearDown(db.close);
|
||||||
final tmp = Directory.systemTemp.createTempSync();
|
final tmp = Directory.systemTemp.createTempSync();
|
||||||
@@ -63,30 +65,47 @@ void main() {
|
|||||||
dioFactory: () async => Dio(),
|
dioFactory: () async => Dio(),
|
||||||
cacheDirFactory: () async => tmp,
|
cacheDirFactory: () async => tmp,
|
||||||
);
|
);
|
||||||
final f1 = File('${tmp.path}/audio_cache/inc.mp3');
|
Future<void> mk(String id) async {
|
||||||
await f1.create(recursive: true);
|
final f = File('${tmp.path}/audio_cache/$id.mp3');
|
||||||
await f1.writeAsBytes(List.filled(100, 0));
|
await f.create(recursive: true);
|
||||||
final f2 = File('${tmp.path}/audio_cache/man.mp3');
|
await f.writeAsBytes(List.filled(100, 0));
|
||||||
await f2.create(recursive: true);
|
}
|
||||||
await f2.writeAsBytes(List.filled(100, 0));
|
await mk('old');
|
||||||
|
await mk('new');
|
||||||
|
await mk('lik');
|
||||||
await db.batch((b) {
|
await db.batch((b) {
|
||||||
b.insertAll(db.audioCacheIndex, [
|
b.insertAll(db.audioCacheIndex, [
|
||||||
|
// 'old' has the older lastPlayedAt → evicted first.
|
||||||
AudioCacheIndexCompanion.insert(
|
AudioCacheIndexCompanion.insert(
|
||||||
trackId: 'inc',
|
trackId: 'old',
|
||||||
path: f1.path,
|
path: '${tmp.path}/audio_cache/old.mp3',
|
||||||
sizeBytes: 100,
|
sizeBytes: 100,
|
||||||
source: CacheSource.incidental),
|
source: CacheSource.incidental,
|
||||||
|
lastPlayedAt: Value(DateTime(2020))),
|
||||||
AudioCacheIndexCompanion.insert(
|
AudioCacheIndexCompanion.insert(
|
||||||
trackId: 'man',
|
trackId: 'new',
|
||||||
path: f2.path,
|
path: '${tmp.path}/audio_cache/new.mp3',
|
||||||
sizeBytes: 100,
|
sizeBytes: 100,
|
||||||
source: CacheSource.manual),
|
source: CacheSource.incidental,
|
||||||
|
lastPlayedAt: Value(DateTime(2024))),
|
||||||
|
AudioCacheIndexCompanion.insert(
|
||||||
|
trackId: 'lik',
|
||||||
|
path: '${tmp.path}/audio_cache/lik.mp3',
|
||||||
|
sizeBytes: 100,
|
||||||
|
source: CacheSource.incidental,
|
||||||
|
lastPlayedAt: Value(DateTime(2019))),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
expect(await mgr.usageBytes(), 200);
|
final liked = {'lik'};
|
||||||
await mgr.evict(targetBytes: 100);
|
final u = await mgr.bucketUsage(liked);
|
||||||
expect(await mgr.isCached('inc'), false);
|
expect(u.liked, 100);
|
||||||
expect(await mgr.isCached('man'), true);
|
expect(u.rolling, 200);
|
||||||
|
// Rolling cap fits one 100-byte file; Liked cap huge.
|
||||||
|
await mgr.evictBuckets(
|
||||||
|
likedCap: 1 << 30, rollingCap: 100, liked: liked);
|
||||||
|
expect(await mgr.isCached('old'), false); // oldest rolling, evicted
|
||||||
|
expect(await mgr.isCached('new'), true); // newer rolling, kept
|
||||||
|
expect(await mgr.isCached('lik'), true); // liked, protected
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearAll removes everything including manual', skip: _skipDrift, () async {
|
test('clearAll removes everything including manual', skip: _skipDrift, () async {
|
||||||
|
|||||||
+9
-2
@@ -32,9 +32,16 @@ void main() {
|
|||||||
isOnline: () async => true,
|
isOnline: () async => true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final firstFuture = results.first;
|
// Post-coldFetchAttempted guard, cacheFirst yields the current
|
||||||
|
// (still-empty) rows after the fetch returns, so the stream
|
||||||
|
// never hangs when populate is a no-op for this filter. The
|
||||||
|
// simulated drift re-emit then yields the populated rows.
|
||||||
|
// Capture the Future *before* feeding the controller so the
|
||||||
|
// listener is subscribed when the first add() lands.
|
||||||
|
final emissionsFuture = results.take(2).toList();
|
||||||
controller.add([]);
|
controller.add([]);
|
||||||
expect(await firstFuture, 42);
|
final emissions = await emissionsFuture;
|
||||||
|
expect(emissions, [0, 42]);
|
||||||
expect(fetchCalled, 1);
|
expect(fetchCalled, 1);
|
||||||
await controller.close();
|
await controller.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ void main() {
|
|||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final s = await container.read(cacheSettingsProvider.future);
|
final s = await container.read(cacheSettingsProvider.future);
|
||||||
expect(s.capBytes, CacheSettings.defaults.capBytes);
|
expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes);
|
||||||
|
expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes);
|
||||||
|
expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024);
|
||||||
expect(s.prefetchWindow, 5);
|
expect(s.prefetchWindow, 5);
|
||||||
expect(s.cacheLikedTracks, true);
|
expect(s.cacheLikedTracks, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,53 +5,51 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:minstrel/api/endpoints/playlists.dart';
|
import 'package:minstrel/api/endpoints/playlists.dart';
|
||||||
import 'package:minstrel/library/home_screen.dart';
|
import 'package:minstrel/library/home_screen.dart';
|
||||||
import 'package:minstrel/library/library_providers.dart';
|
import 'package:minstrel/library/library_providers.dart';
|
||||||
import 'package:minstrel/models/album.dart';
|
import 'package:minstrel/models/home_index.dart';
|
||||||
import 'package:minstrel/models/artist.dart';
|
|
||||||
import 'package:minstrel/models/home_data.dart';
|
|
||||||
import 'package:minstrel/models/playlist.dart';
|
import 'package:minstrel/models/playlist.dart';
|
||||||
import 'package:minstrel/models/system_playlists_status.dart';
|
import 'package:minstrel/models/system_playlists_status.dart';
|
||||||
import 'package:minstrel/playlists/playlists_provider.dart';
|
import 'package:minstrel/playlists/playlists_provider.dart';
|
||||||
import 'package:minstrel/theme/theme_data.dart';
|
import 'package:minstrel/theme/theme_data.dart';
|
||||||
|
|
||||||
const _emptyHome = HomeData(
|
// All tests pump an empty HomeIndex unless they care about populated
|
||||||
recentlyAddedAlbums: [],
|
// section IDs — per-tile hydration is intentionally not exercised here
|
||||||
rediscoverAlbums: [],
|
// (that requires drift, and the @Tags(['drift']) tier covers it).
|
||||||
rediscoverArtists: [],
|
// What this suite verifies is the screen's section-level shape:
|
||||||
mostPlayedTracks: [],
|
// placeholders / empty-state copy / section presence given the index.
|
||||||
lastPlayedArtists: [],
|
const _emptyIndex = HomeIndex.empty;
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
|
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(ProviderScope(
|
await tester.pumpWidget(ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||||
playlistsListProvider('all').overrideWith(
|
playlistsListProvider('all').overrideWith(
|
||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
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('For You'), findsOneWidget);
|
||||||
|
expect(find.text('Discover'), findsOneWidget);
|
||||||
expect(find.text('Songs like…'), findsNWidgets(3));
|
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 {
|
(tester) async {
|
||||||
await tester.pumpWidget(ProviderScope(
|
await tester.pumpWidget(ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||||
playlistsListProvider('all').overrideWith(
|
playlistsListProvider('all').overrideWith(
|
||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -87,13 +85,13 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpWidget(ProviderScope(
|
await tester.pumpWidget(ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||||
playlistsListProvider('all').overrideWith(
|
playlistsListProvider('all').overrideWith(
|
||||||
(ref) => Stream.value(
|
(ref) => Stream.value(
|
||||||
const PlaylistsList(owned: [forYou], public: [])),
|
const PlaylistsList(owned: [forYou], public: [])),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -104,34 +102,8 @@ void main() {
|
|||||||
expect(find.text('for you'), findsOneWidget);
|
expect(find.text('for you'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders home rollups when payload is non-empty', (tester) async {
|
// Section-with-populated-IDs coverage lives in the drift-tagged
|
||||||
const home = HomeData(
|
// integration suite (Slice F follow-up) because per-tile providers
|
||||||
recentlyAddedAlbums: [
|
// require a real (in-memory) drift DB. The flutter-ci runner skips
|
||||||
AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'),
|
// drift tests pending libsqlite3.
|
||||||
],
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:minstrel/models/playlist.dart';
|
import 'package:minstrel/models/playlist.dart';
|
||||||
@@ -35,24 +36,48 @@ const _forYou = Playlist(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders name and no badge for user playlist', (tester) async {
|
testWidgets('renders name and no badge for user playlist', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _userPlaylist),
|
body: PlaylistCard(playlist: _userPlaylist),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.text('Road trip'), findsOneWidget);
|
expect(find.text('Road trip'), findsOneWidget);
|
||||||
expect(find.text('for you'), findsNothing);
|
expect(find.text('for you'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders system badge for system playlist', (tester) async {
|
testWidgets('renders system badge for system playlist', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _forYou),
|
body: PlaylistCard(playlist: _forYou),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.text('For You'), findsOneWidget);
|
expect(find.text('For You'), findsOneWidget);
|
||||||
expect(find.text('for you'), findsOneWidget);
|
expect(find.text('for you'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows refresh kebab on system playlists', (tester) async {
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildThemeData(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: PlaylistCard(playlist: _forYou),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no refresh kebab on user playlists', (tester) async {
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildThemeData(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: PlaylistCard(playlist: _userPlaylist),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
|
@Tags(['drift'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:drift/native.dart' show NativeDatabase;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:minstrel/api/endpoints/quarantine.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/models/track.dart';
|
||||||
import 'package:minstrel/quarantine/quarantine_provider.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 {
|
class _StubQuarantineApi implements QuarantineApi {
|
||||||
_StubQuarantineApi({this.shouldThrow = false});
|
_StubQuarantineApi({this.shouldThrow = false});
|
||||||
bool shouldThrow;
|
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(
|
const _track = TrackRef(
|
||||||
id: 't1',
|
id: 't1',
|
||||||
title: 'Roygbiv',
|
title: 'Roygbiv',
|
||||||
@@ -44,75 +49,95 @@ const _track = TrackRef(
|
|||||||
streamUrl: '',
|
streamUrl: '',
|
||||||
);
|
);
|
||||||
|
|
||||||
const _existing = QuarantineMineRow(
|
ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
|
||||||
trackId: 't1',
|
return ProviderContainer(overrides: [
|
||||||
reason: 'bad_rip',
|
appDbProvider.overrideWithValue(db),
|
||||||
notes: null,
|
quarantineApiProvider.overrideWith((ref) async => api),
|
||||||
createdAt: '2026-05-01T00:00:00Z',
|
]);
|
||||||
trackTitle: 'Roygbiv',
|
}
|
||||||
trackDurationMs: 137000,
|
|
||||||
albumId: 'a1',
|
Future<void> _seed(AppDb db) async {
|
||||||
albumTitle: 'Geogaddi',
|
await db.into(db.cachedQuarantineMine).insert(
|
||||||
artistId: 'ar1',
|
CachedQuarantineMineCompanion.insert(
|
||||||
artistName: 'Boards of Canada',
|
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() {
|
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 api = _StubQuarantineApi();
|
||||||
final container = ProviderContainer(overrides: [
|
final container = _container(db: db, api: api);
|
||||||
quarantineApiProvider.overrideWith((ref) async => api),
|
|
||||||
myQuarantineProvider.overrideWith(() => _StubController(const [])),
|
|
||||||
]);
|
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container.read(myQuarantineProvider.future);
|
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);
|
expect(api.flagCalls, 1);
|
||||||
final list = container.read(myQuarantineProvider).value!;
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||||
expect(list, hasLength(1));
|
expect(rows, hasLength(1));
|
||||||
expect(list.first.trackId, 't1');
|
expect(rows.first.trackId, 't1');
|
||||||
expect(list.first.reason, 'bad_rip');
|
expect(rows.first.reason, 'bad_rip');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flag rolls back on server failure', () async {
|
test('flag keeps drift optimistic + queues mutation on server failure',
|
||||||
|
skip: _skipDrift, () async {
|
||||||
|
final db = AppDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
final api = _StubQuarantineApi(shouldThrow: true);
|
final api = _StubQuarantineApi(shouldThrow: true);
|
||||||
final container = ProviderContainer(overrides: [
|
final container = _container(db: db, api: api);
|
||||||
quarantineApiProvider.overrideWith((ref) async => api),
|
|
||||||
myQuarantineProvider.overrideWith(() => _StubController(const [])),
|
|
||||||
]);
|
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container.read(myQuarantineProvider.future);
|
await container.read(myQuarantineProvider.future);
|
||||||
await expectLater(
|
// No longer rethrows — flag swallows the REST failure and queues
|
||||||
() => container
|
// for replay so the user's intent persists offline.
|
||||||
.read(myQuarantineProvider.notifier)
|
await container
|
||||||
.flag(_track, 'bad_rip', ''),
|
.read(myQuarantineProvider.notifier)
|
||||||
throwsException,
|
.flag(_track, 'bad_rip', '');
|
||||||
);
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||||
expect(container.read(myQuarantineProvider).value, isEmpty);
|
expect(rows, hasLength(1),
|
||||||
|
reason: 'optimistic drift row should persist across REST failure');
|
||||||
|
final mutations = await db.select(db.cachedMutations).get();
|
||||||
|
expect(mutations, hasLength(1),
|
||||||
|
reason: 'failed flag should have been enqueued for replay');
|
||||||
|
expect(mutations.first.kind, 'quarantine.flag');
|
||||||
});
|
});
|
||||||
|
|
||||||
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 api = _StubQuarantineApi();
|
||||||
final container = ProviderContainer(overrides: [
|
final container = _container(db: db, api: api);
|
||||||
quarantineApiProvider.overrideWith((ref) async => api),
|
|
||||||
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
|
|
||||||
]);
|
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container.read(myQuarantineProvider.future);
|
await container.read(myQuarantineProvider.future);
|
||||||
await container.read(myQuarantineProvider.notifier).unflag('t1');
|
await container.read(myQuarantineProvider.notifier).unflag('t1');
|
||||||
|
|
||||||
expect(api.unflagCalls, 1);
|
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 {
|
test('isHidden reflects drift state', skip: _skipDrift, () async {
|
||||||
final container = ProviderContainer(overrides: [
|
final db = AppDb(NativeDatabase.memory());
|
||||||
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
|
addTearDown(db.close);
|
||||||
]);
|
await _seed(db);
|
||||||
|
final api = _StubQuarantineApi();
|
||||||
|
final container = _container(db: db, api: api);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container.read(myQuarantineProvider.future);
|
await container.read(myQuarantineProvider.future);
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ void main() {
|
|||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Storage'), findsOneWidget);
|
expect(find.text('Storage'), findsOneWidget);
|
||||||
expect(find.text('Cache size limit'), findsOneWidget);
|
expect(find.text('Liked cache limit'), findsOneWidget);
|
||||||
|
expect(find.text('Recently-played cache limit'), findsOneWidget);
|
||||||
expect(find.text('Pre-fetch ahead'), findsOneWidget);
|
expect(find.text('Pre-fetch ahead'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
|
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('sync_now_button')), findsOneWidget);
|
expect(find.byKey(const Key('sync_now_button')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget);
|
expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('cap_selector')), findsOneWidget);
|
expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
|
expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ go 1.23.0
|
|||||||
require (
|
require (
|
||||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
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/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/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||||
github.com/jackc/pgx/v5 v5.7.4
|
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
|
golang.org/x/crypto v0.35.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -20,7 +22,9 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/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
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
golang.org/x/sync v0.11.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/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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
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 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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/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 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
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.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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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/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.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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
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=
|
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.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 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
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 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
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=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
|
|||||||
@@ -48,19 +48,20 @@ func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
type adminBulkRefetchResp struct {
|
type adminBulkRefetchResp struct {
|
||||||
Queued int `json:"queued"`
|
Started bool `json:"started"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
|
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
|
||||||
// Returns immediately; actual drain runs in a background goroutine bounded by
|
// Returns immediately; the drain runs UNBOUNDED in a background goroutine
|
||||||
// the configured backfill cap.
|
// (#388 — no global cap; remote providers self-throttle per provider, local
|
||||||
|
// sources run at disk speed). The count is unknowable synchronously, so the
|
||||||
|
// response just acknowledges the drain started.
|
||||||
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
|
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
|
||||||
cap := h.coverArtBackfillCap
|
|
||||||
go func() {
|
go func() {
|
||||||
bgCtx := context.Background()
|
bgCtx := context.Background()
|
||||||
if _, err := h.coverart.EnrichRetryMissing(bgCtx, cap); err != nil {
|
if _, err := h.coverart.EnrichRetryMissing(bgCtx, -1); err != nil {
|
||||||
h.logger.Warn("admin: bulk cover refetch failed", "err", err)
|
h.logger.Warn("admin: bulk cover refetch failed", "err", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Queued: cap})
|
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Started: true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ func testHandlersWithCovers(t *testing.T) (*handlers, *coverart.Enricher) {
|
|||||||
}
|
}
|
||||||
enricher := coverart.NewEnricher(pool, slog.Default(), settings)
|
enricher := coverart.NewEnricher(pool, slog.Default(), settings)
|
||||||
h.coverart = enricher
|
h.coverart = enricher
|
||||||
h.coverArtBackfillCap = 100
|
|
||||||
return h, enricher
|
return h, enricher
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ func TestAdminAlbumRefetchCover_AdminOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
|
func TestAdminBulkRefetchCovers_AdminStartsRefetch(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
@@ -115,8 +114,8 @@ func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
|
|||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Queued != h.coverArtBackfillCap {
|
if !resp.Started {
|
||||||
t.Errorf("queued = %d, want %d (cap)", resp.Queued, h.coverArtBackfillCap)
|
t.Errorf("started = false, want true (bulk refetch should acknowledge)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
|
|||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Broadcast: affects every user who'd flagged this track.
|
||||||
|
h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true)
|
||||||
writeJSON(w, http.StatusOK, actionResultView{
|
writeJSON(w, http.StatusOK, actionResultView{
|
||||||
ActionID: uuidToString(action.ID),
|
ActionID: uuidToString(action.ID),
|
||||||
AffectedUsers: action.AffectedUsers,
|
AffectedUsers: action.AffectedUsers,
|
||||||
@@ -140,6 +142,9 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
return
|
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{
|
writeJSON(w, http.StatusOK, actionResultView{
|
||||||
ActionID: uuidToString(action.ID),
|
ActionID: uuidToString(action.ID),
|
||||||
AffectedUsers: action.AffectedUsers,
|
AffectedUsers: action.AffectedUsers,
|
||||||
@@ -179,6 +184,8 @@ func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
return
|
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{
|
writeJSON(w, http.StatusOK, actionResultView{
|
||||||
ActionID: uuidToString(action.ID),
|
ActionID: uuidToString(action.ID),
|
||||||
AffectedUsers: action.AffectedUsers,
|
AffectedUsers: action.AffectedUsers,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,5 +165,6 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-35
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
@@ -27,24 +28,25 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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, 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
lidarrQuarantine: lidarrQuar,
|
lidarrQuarantine: lidarrQuar,
|
||||||
tracks: tracksSvc,
|
tracks: tracksSvc,
|
||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverArtBackfillCap: coverBackfillCap,
|
coverSettings: coverSettings,
|
||||||
coverSettings: coverSettings,
|
scanner: scanner,
|
||||||
scanner: scanner,
|
scanCfg: scanCfg,
|
||||||
scanCfg: scanCfg,
|
scheduler: scheduler,
|
||||||
scheduler: scheduler,
|
dataDir: dataDir,
|
||||||
dataDir: dataDir,
|
mailer: sender,
|
||||||
mailer: sender,
|
eventbus: bus,
|
||||||
|
playlistScheduler: playlistScheduler,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
@@ -62,6 +64,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/me/history", h.handleGetMyHistory)
|
authed.Get("/me/history", h.handleGetMyHistory)
|
||||||
authed.Put("/me/password", h.handleChangePassword)
|
authed.Put("/me/password", h.handleChangePassword)
|
||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
|
|
||||||
@@ -70,6 +73,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||||
authed.Get("/library/sync", h.handleLibrarySync)
|
authed.Get("/library/sync", h.handleLibrarySync)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
@@ -78,7 +82,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/radio", h.handleRadio)
|
authed.Get("/radio", h.handleRadio)
|
||||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||||
authed.Get("/home", h.handleGetHome)
|
authed.Get("/home", h.handleGetHome)
|
||||||
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||||
authed.Post("/events", h.handleEvents)
|
authed.Post("/events", h.handleEvents)
|
||||||
|
authed.Get("/events/stream", h.handleEventsStream)
|
||||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||||||
@@ -166,29 +172,30 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||||
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||||
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||||
authed.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
|
authed.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
||||||
authed.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
|
authed.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type handlers struct {
|
type handlers struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
lidarrQuarantine *lidarrquarantine.Service
|
lidarrQuarantine *lidarrquarantine.Service
|
||||||
tracks *tracks.Service
|
tracks *tracks.Service
|
||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverArtBackfillCap int
|
coverSettings *coverart.SettingsService
|
||||||
coverSettings *coverart.SettingsService
|
scanner *library.Scanner
|
||||||
scanner *library.Scanner
|
scanCfg library.RunScanConfig
|
||||||
scanCfg library.RunScanConfig
|
scheduler *library.Scheduler
|
||||||
scheduler *library.Scheduler
|
dataDir string
|
||||||
dataDir string
|
mailer mailer.Sender
|
||||||
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})
|
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{
|
writeJSON(w, http.StatusOK, LoginResponse{
|
||||||
Token: sessionToken,
|
Token: sessionToken,
|
||||||
User: UserView{
|
User: UserView{
|
||||||
|
|||||||
+53
-1
@@ -20,6 +20,10 @@ type eventRequest struct {
|
|||||||
PositionMs *int32 `json:"position_ms"`
|
PositionMs *int32 `json:"position_ms"`
|
||||||
At *string `json:"at"`
|
At *string `json:"at"`
|
||||||
ClientID *string `json:"client_id"`
|
ClientID *string `json:"client_id"`
|
||||||
|
// Source tags which surface a play_started came from. "for_you"
|
||||||
|
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||||
|
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||||
|
Source *string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type playStartedResponse struct {
|
type playStartedResponse struct {
|
||||||
@@ -61,6 +65,8 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleEventPlayEnded(w, r, user, req, at)
|
h.handleEventPlayEnded(w, r, user, req, at)
|
||||||
case "play_skipped":
|
case "play_skipped":
|
||||||
h.handleEventPlaySkipped(w, r, user, req, at)
|
h.handleEventPlaySkipped(w, r, user, req, at)
|
||||||
|
case "play_offline":
|
||||||
|
h.handleEventPlayOffline(w, r, user, req, at, clientID)
|
||||||
default:
|
default:
|
||||||
writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
|
writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
|
||||||
}
|
}
|
||||||
@@ -84,7 +90,11 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := h.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at)
|
source := ""
|
||||||
|
if req.Source != nil {
|
||||||
|
source = *req.Source
|
||||||
|
}
|
||||||
|
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: events: play_started", "err", err)
|
h.logger.Error("api: events: play_started", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
@@ -96,6 +106,48 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleEventPlayOffline records a complete play that happened with
|
||||||
|
// no/flaky connectivity, replayed from the Flutter offline mutation
|
||||||
|
// queue (#426 part B). `at` is the original (client) play-start time
|
||||||
|
// — parsed by handleEvents from req.At — so history lands on the
|
||||||
|
// real timeline, not replay time. duration_played_ms drives the skip
|
||||||
|
// rule; source advances #415 rotation for system-playlist plays.
|
||||||
|
func (h *handlers) handleEventPlayOffline(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
user dbq.User, req eventRequest, at time.Time, clientID string,
|
||||||
|
) {
|
||||||
|
trackID, ok := parseUUID(req.TrackID)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: events: offline lookup track", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source := ""
|
||||||
|
if req.Source != nil {
|
||||||
|
source = *req.Source
|
||||||
|
}
|
||||||
|
if err := h.events.RecordOfflinePlay(
|
||||||
|
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Error("api: events: play_offline", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handlers) handleEventPlayEnded(
|
func (h *handlers) handleEventPlayEnded(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
user dbq.User, req eventRequest, at time.Time,
|
user dbq.User, req eventRequest, at time.Time,
|
||||||
|
|||||||
@@ -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)
|
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
|
// 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.
|
// regenerated with a different name, this fails fast at build time.
|
||||||
var _ = dbq.ListMostPlayedTracksForUserRow{}.Track
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -158,6 +159,43 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||||
|
// the online source for the client's always-present "Shuffle all"
|
||||||
|
// (#427 S4). N random tracks across the whole library, per-user
|
||||||
|
// quarantine filtered. limit defaults to 100, clamped to 1..500.
|
||||||
|
// Offline, the client shuffles its local cache instead and never
|
||||||
|
// calls this.
|
||||||
|
func (h *handlers) handleLibraryShuffle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 100
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 1
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListRandomTracksForUser(r.Context(),
|
||||||
|
dbq.ListRandomTracksForUserParams{UserID: user.ID, Limit: int32(limit)})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: library shuffle", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("shuffle failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]TrackRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
// handleListArtists implements GET /api/artists with two sort modes
|
// handleListArtists implements GET /api/artists with two sort modes
|
||||||
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
||||||
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -443,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
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.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/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)
|
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
|
||||||
|
h.publishLikeEvent(user.ID, id, "track", true)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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.
|
// Don't fail the response — soft-delete is best-effort.
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
|
||||||
|
h.publishLikeEvent(user.ID, id, "track", false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert)
|
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert)
|
||||||
|
h.publishLikeEvent(user.ID, id, "album", true)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete)
|
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete)
|
||||||
|
h.publishLikeEvent(user.ID, id, "album", false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,7 @@ func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert)
|
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert)
|
||||||
|
h.publishLikeEvent(user.ID, id, "artist", true)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete)
|
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete)
|
||||||
|
h.publishLikeEvent(user.ID, id, "artist", false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", imageContentType(path))
|
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)
|
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("Content-Type", audioContentType(track.FileFormat))
|
||||||
w.Header().Set("Accept-Ranges", "bytes")
|
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)
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,17 @@ type playlistRowView struct {
|
|||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
SystemVariant *string `json:"system_variant,omitempty"`
|
SystemVariant *string `json:"system_variant,omitempty"`
|
||||||
SeedArtistID *string `json:"seed_artist_id,omitempty"`
|
// Refreshable: a singleton system kind addressable by the generic
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
// /system/{kind}/{refresh,shuffle} endpoints. Lets clients show
|
||||||
TrackCount int32 `json:"track_count"`
|
// the per-tile refresh affordance generically without hardcoding
|
||||||
DurationSec int32 `json:"duration_sec"`
|
// which kinds support it (false for user + songs_like_artist).
|
||||||
CreatedAt string `json:"created_at"`
|
Refreshable bool `json:"refreshable"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
SeedArtistID *string `json:"seed_artist_id,omitempty"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
TrackCount int32 `json:"track_count"`
|
||||||
|
DurationSec int32 `json:"duration_sec"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// playlistTrackView is one row of a playlist's track list on the wire.
|
// playlistTrackView is one row of a playlist's track list on the wire.
|
||||||
@@ -185,6 +190,7 @@ func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request)
|
|||||||
h.writePlaylistErr(w, err, "create")
|
h.writePlaylistErr(w, err, "create")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.created", caller.ID, row.ID)
|
||||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +246,7 @@ func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request)
|
|||||||
h.writePlaylistErr(w, err, "update")
|
h.writePlaylistErr(w, err, "update")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID)
|
||||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +265,7 @@ func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request)
|
|||||||
h.writePlaylistErr(w, err, "delete")
|
h.writePlaylistErr(w, err, "delete")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +302,7 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writePlaylistErr(w, err, "append tracks")
|
h.writePlaylistErr(w, err, "append tracks")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writePlaylistErr(w, err, "get-after-append")
|
h.writePlaylistErr(w, err, "get-after-append")
|
||||||
@@ -323,6 +332,7 @@ func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Requ
|
|||||||
h.writePlaylistErr(w, err, "remove track")
|
h.writePlaylistErr(w, err, "remove track")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writePlaylistErr(w, err, "get-after-remove")
|
h.writePlaylistErr(w, err, "get-after-remove")
|
||||||
@@ -355,6 +365,7 @@ func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request)
|
|||||||
h.writePlaylistErr(w, err, "reorder")
|
h.writePlaylistErr(w, err, "reorder")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writePlaylistErr(w, err, "get-after-reorder")
|
h.writePlaylistErr(w, err, "get-after-reorder")
|
||||||
@@ -386,6 +397,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
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)
|
http.ServeFile(w, r, full)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +440,7 @@ func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
|
|||||||
IsPublic: r.IsPublic,
|
IsPublic: r.IsPublic,
|
||||||
Kind: r.Kind,
|
Kind: r.Kind,
|
||||||
SystemVariant: r.SystemVariant,
|
SystemVariant: r.SystemVariant,
|
||||||
|
Refreshable: r.SystemVariant != nil && playlists.RefreshableSystemKind(*r.SystemVariant),
|
||||||
TrackCount: r.TrackCount,
|
TrackCount: r.TrackCount,
|
||||||
DurationSec: r.DurationSec,
|
DurationSec: r.DurationSec,
|
||||||
CreatedAt: formatTimestamp(r.CreatedAt),
|
CreatedAt: formatTimestamp(r.CreatedAt),
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
||||||
)
|
|
||||||
|
|
||||||
// discoverRefreshResp is the wire shape for POST
|
|
||||||
// /api/playlists/system/discover/refresh. playlist_id is null when
|
|
||||||
// the build succeeded but the user's library yielded no eligible
|
|
||||||
// tracks (degenerate empty-library); track_count is 0 in that case.
|
|
||||||
type discoverRefreshResp struct {
|
|
||||||
PlaylistID *string `json:"playlist_id"`
|
|
||||||
TrackCount int32 `json:"track_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDiscoverRefresh re-runs BuildSystemPlaylists synchronously
|
|
||||||
// for the calling user, then returns the resulting Discover row's
|
|
||||||
// id + track_count. Used by the frontend's refresh affordances on
|
|
||||||
// the detail page header and the home tile kebab.
|
|
||||||
//
|
|
||||||
// Authenticated user only (gated by the authed sub-router's
|
|
||||||
// RequireUser middleware). Each user only ever refreshes their own
|
|
||||||
// Discover — no admin route, no cross-user impersonation.
|
|
||||||
func (h *handlers) handleDiscoverRefresh(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, ok := requireUser(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
|
||||||
h.logger.Error("discover refresh: build failed",
|
|
||||||
"user_id", uuidToString(user.ID), "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sysVariant := "discover"
|
|
||||||
pl, err := dbq.New(h.pool).GetSystemPlaylistByVariantForUser(r.Context(),
|
|
||||||
dbq.GetSystemPlaylistByVariantForUserParams{
|
|
||||||
UserID: user.ID,
|
|
||||||
SystemVariant: &sysVariant,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
// Build succeeded but no Discover row produced — empty library.
|
|
||||||
writeJSON(w, http.StatusOK, discoverRefreshResp{
|
|
||||||
PlaylistID: nil, TrackCount: 0,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("discover refresh: lookup failed",
|
|
||||||
"user_id", uuidToString(user.ID), "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := uuidToString(pl.ID)
|
|
||||||
writeJSON(w, http.StatusOK, discoverRefreshResp{
|
|
||||||
PlaylistID: &idStr,
|
|
||||||
TrackCount: pl.TrackCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newDiscoverRefreshRouter(h *handlers) chi.Router {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
|
||||||
api.Use(auth.RequireUser(h.pool))
|
|
||||||
api.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiscoverRefresh_AuthenticatedUser_Returns200(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, "discrefresh", "pw", false)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
|
|
||||||
req = withUser(req, user)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var resp discoverRefreshResp
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
// On a fresh user with no library, the build runs and emits a
|
|
||||||
// Discover playlist (or no row if library is truly empty).
|
|
||||||
// Either case is OK; we only assert the response shape decoded.
|
|
||||||
if resp.TrackCount < 0 {
|
|
||||||
t.Errorf("track_count = %d, want >= 0", resp.TrackCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiscoverRefresh_NoAuth_Returns401(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
||||||
)
|
|
||||||
|
|
||||||
// foryouRefreshResp is the wire shape for POST
|
|
||||||
// /api/playlists/system/for-you/refresh. track_ids is the playlist's
|
|
||||||
// tracks in playlist position order — the frontend enqueues this
|
|
||||||
// list directly to start playback without a second roundtrip.
|
|
||||||
//
|
|
||||||
// playlist_id is null + track_ids empty when the build succeeded but
|
|
||||||
// the user's library yielded no eligible candidates (degenerate
|
|
||||||
// empty-library case).
|
|
||||||
type foryouRefreshResp struct {
|
|
||||||
PlaylistID *string `json:"playlist_id"`
|
|
||||||
TrackCount int32 `json:"track_count"`
|
|
||||||
TrackIDs []string `json:"track_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleForYouRefresh re-runs BuildSystemPlaylists synchronously for
|
|
||||||
// the calling user and returns their freshly-built For-You playlist.
|
|
||||||
// Used by the home Playlists row's For-You tile play button: click
|
|
||||||
// triggers refresh, the response carries the track IDs, the
|
|
||||||
// frontend enqueues + auto-plays.
|
|
||||||
//
|
|
||||||
// Authenticated user only — each user refreshes only their own
|
|
||||||
// For-You.
|
|
||||||
func (h *handlers) handleForYouRefresh(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, ok := requireUser(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
|
||||||
h.logger.Error("for-you refresh: build failed",
|
|
||||||
"user_id", uuidToString(user.ID), "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := dbq.New(h.pool)
|
|
||||||
sysVariant := "for_you"
|
|
||||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
|
||||||
dbq.GetSystemPlaylistByVariantForUserParams{
|
|
||||||
UserID: user.ID,
|
|
||||||
SystemVariant: &sysVariant,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
// Build succeeded but no For-You row — empty library.
|
|
||||||
writeJSON(w, http.StatusOK, foryouRefreshResp{
|
|
||||||
PlaylistID: nil,
|
|
||||||
TrackCount: 0,
|
|
||||||
TrackIDs: []string{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("for-you refresh: lookup failed",
|
|
||||||
"user_id", uuidToString(user.ID), "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch tracks in playlist position order so the frontend can
|
|
||||||
// enqueue them directly.
|
|
||||||
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("for-you refresh: list tracks failed",
|
|
||||||
"playlist_id", uuidToString(pl.ID), "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("list tracks failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trackIDs := make([]string, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
// Skip rows where the track was removed from the library
|
|
||||||
// (LiveTrackID won't be Valid). The snapshot row still
|
|
||||||
// exists in playlist_tracks, but there's nothing to enqueue.
|
|
||||||
if !row.LiveTrackID.Valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := uuidToString(pl.ID)
|
|
||||||
writeJSON(w, http.StatusOK, foryouRefreshResp{
|
|
||||||
PlaylistID: &idStr,
|
|
||||||
TrackCount: pl.TrackCount,
|
|
||||||
TrackIDs: trackIDs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newForYouRefreshRouter(h *handlers) chi.Router {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
|
||||||
api.Use(auth.RequireUser(h.pool))
|
|
||||||
api.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestForYouRefresh_AuthenticatedUser_Returns200(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, "foryourefresh", "pw", false)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for-you/refresh", nil)
|
|
||||||
req = withUser(req, user)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newForYouRefreshRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var resp foryouRefreshResp
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if resp.TrackIDs == nil {
|
|
||||||
t.Errorf("track_ids = nil, want []string (empty array, not null)")
|
|
||||||
}
|
|
||||||
if int(resp.TrackCount) != len(resp.TrackIDs) {
|
|
||||||
// Acceptable mismatch only when some tracks were removed from
|
|
||||||
// the library between build and response. For a fresh test
|
|
||||||
// user the counts should match.
|
|
||||||
t.Errorf("track_count=%d, len(track_ids)=%d — mismatch", resp.TrackCount, len(resp.TrackIDs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestForYouRefresh_NoAuth_Returns401(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for-you/refresh", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newForYouRefreshRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generic registry-driven system-playlist endpoints (#411 R2),
|
||||||
|
// replacing the former per-kind handlers. {kind} is the raw
|
||||||
|
// system_variant ('for_you' | 'discover' | future singleton kinds);
|
||||||
|
// only singleton kinds (playlists.RefreshableSystemKind) are
|
||||||
|
// addressable here — songs_like_artist is multi-per-user and played
|
||||||
|
// by playlist id, so it 404s.
|
||||||
|
//
|
||||||
|
// GET /api/playlists/system/{kind}/shuffle — the caller's playlist
|
||||||
|
// for that kind with tracks in rotation-aware order (#415): unheard
|
||||||
|
// this rotation first (shuffled), then heard; resets when exhausted.
|
||||||
|
// Same JSON shape as GET /api/playlists/{id} so clients reuse track
|
||||||
|
// parsing. Intentionally uncached — it varies per play, unlike the
|
||||||
|
// cached detail GET.
|
||||||
|
//
|
||||||
|
// POST /api/playlists/system/{kind}/refresh — rebuilds the caller's
|
||||||
|
// system playlists and returns that kind's fresh row.
|
||||||
|
|
||||||
|
func systemKindFromURL(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
kind := chi.URLParam(r, "kind")
|
||||||
|
if !playlists.RefreshableSystemKind(kind) {
|
||||||
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "unknown system playlist"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return kind, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind, ok := systemKindFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.serveSystemPlaylistShuffle(w, r, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemRefreshResp unifies the prior for-you/discover shapes
|
||||||
|
// (for-you carried track_ids, discover didn't); clients ignore
|
||||||
|
// extra fields. playlist_id null + empty when the build produced no
|
||||||
|
// row for that kind (empty library).
|
||||||
|
type systemRefreshResp struct {
|
||||||
|
PlaylistID *string `json:"playlist_id"`
|
||||||
|
TrackCount int32 `json:"track_count"`
|
||||||
|
TrackIDs []string `json:"track_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind, ok := systemKindFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
||||||
|
h.logger.Error("system refresh: build failed",
|
||||||
|
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
v := kind
|
||||||
|
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||||
|
dbq.GetSystemPlaylistByVariantForUserParams{UserID: user.ID, SystemVariant: &v})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeJSON(w, http.StatusOK, systemRefreshResp{TrackIDs: []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("system refresh: lookup failed",
|
||||||
|
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("system refresh: list tracks failed",
|
||||||
|
"kind", kind, "playlist_id", uuidToString(pl.ID), "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("list tracks failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackIDs := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if !row.LiveTrackID.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
|
||||||
|
}
|
||||||
|
idStr := uuidToString(pl.ID)
|
||||||
|
writeJSON(w, http.StatusOK, systemRefreshResp{
|
||||||
|
PlaylistID: &idStr,
|
||||||
|
TrackCount: pl.TrackCount,
|
||||||
|
TrackIDs: trackIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) serveSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request, variant string) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
|
v := variant
|
||||||
|
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||||
|
dbq.GetSystemPlaylistByVariantForUserParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
SystemVariant: &v,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// No snapshot yet (empty library / not built). Empty list,
|
||||||
|
// not an error — the client just has nothing to play.
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailView{
|
||||||
|
Tracks: []playlistTrackView{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("system shuffle: lookup failed", "variant", variant, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := h.playlists.Get(r.Context(), user.ID, pl.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "system shuffle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Played set for the current rotation. ErrNoRows = nothing played
|
||||||
|
// from this playlist yet → empty set, everything counts unplayed.
|
||||||
|
played := map[[16]byte]bool{}
|
||||||
|
st, rerr := q.GetRotationState(r.Context(), dbq.GetRotationStateParams{
|
||||||
|
UserID: user.ID, PlaylistKind: variant,
|
||||||
|
})
|
||||||
|
if rerr != nil && !errors.Is(rerr, pgx.ErrNoRows) {
|
||||||
|
h.logger.Error("system shuffle: rotation lookup failed", "variant", variant, "err", rerr)
|
||||||
|
writeErr(w, apierror.InternalMsg("rotation lookup failed", rerr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rerr == nil {
|
||||||
|
for _, id := range st.PlayedTrackIds {
|
||||||
|
if id.Valid {
|
||||||
|
played[id.Bytes] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition playable tracks. Rows whose upstream track was deleted
|
||||||
|
// (TrackID nil) are dropped — nothing to enqueue.
|
||||||
|
unplayed := make([]playlists.PlaylistTrack, 0, len(detail.Tracks))
|
||||||
|
heard := make([]playlists.PlaylistTrack, 0)
|
||||||
|
for _, t := range detail.Tracks {
|
||||||
|
if t.TrackID == nil || !t.TrackID.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if played[t.TrackID.Bytes] {
|
||||||
|
heard = append(heard, t)
|
||||||
|
} else {
|
||||||
|
unplayed = append(unplayed, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole snapshot heard → reset the rotation and treat everything
|
||||||
|
// as fresh. Reset failure is non-fatal: we still return a
|
||||||
|
// playable reshuffled list; the next play just re-resets.
|
||||||
|
if len(unplayed) == 0 && len(heard) > 0 {
|
||||||
|
if err := q.ResetRotationState(r.Context(), dbq.ResetRotationStateParams{
|
||||||
|
UserID: user.ID, PlaylistKind: variant,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Warn("system shuffle: rotation reset failed",
|
||||||
|
"variant", variant, "err", err)
|
||||||
|
}
|
||||||
|
unplayed = heard
|
||||||
|
heard = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rand.Shuffle(len(unplayed), func(i, j int) {
|
||||||
|
unplayed[i], unplayed[j] = unplayed[j], unplayed[i]
|
||||||
|
})
|
||||||
|
rand.Shuffle(len(heard), func(i, j int) {
|
||||||
|
heard[i], heard[j] = heard[j], heard[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
detail.Tracks = append(unplayed, heard...)
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generic registry-driven system-playlist endpoints (#411 R2).
|
||||||
|
// Replaces the former per-kind refresh handler tests.
|
||||||
|
func newSystemPlaylistRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
api.Use(auth.RequireUser(h.pool))
|
||||||
|
api.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
||||||
|
api.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemRefresh_ForYou_Returns200(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, "sysforyou", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for_you/refresh", nil)
|
||||||
|
req = withUser(req, user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newSystemPlaylistRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp systemRefreshResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.TrackIDs == nil {
|
||||||
|
t.Errorf("track_ids = nil, want [] (empty array, not null)")
|
||||||
|
}
|
||||||
|
if int(resp.TrackCount) != len(resp.TrackIDs) {
|
||||||
|
t.Errorf("track_count=%d, len(track_ids)=%d — mismatch", resp.TrackCount, len(resp.TrackIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemRefresh_Discover_Returns200(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, "sysdiscover", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
|
||||||
|
req = withUser(req, user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newSystemPlaylistRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp systemRefreshResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.TrackCount < 0 {
|
||||||
|
t.Errorf("track_count = %d, want >= 0", resp.TrackCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-singleton (songs_like_artist) or unknown kind isn't
|
||||||
|
// addressable by the generic endpoints → 404, not a build.
|
||||||
|
func TestSystemRefresh_NonSingletonKind_Returns404(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, "sysbadkind", "pw", false)
|
||||||
|
|
||||||
|
for _, kind := range []string{"songs_like_artist", "bogus"} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/"+kind+"/refresh", nil)
|
||||||
|
req = withUser(req, user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newSystemPlaylistRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("kind=%s: status = %d, want 404", kind, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemRefresh_NoAuth_Returns401(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for_you/refresh", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newSystemPlaylistRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
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))
|
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))
|
writeErr(w, apierror.InternalMsg("unflag failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Scoped to user: only their other clients need to invalidate.
|
||||||
|
h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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))
|
writeJSON(w, http.StatusCreated, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,5 +255,6 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,24 @@ type HomePayload struct {
|
|||||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
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
|
// HistoryEvent is one play in a user's listening history. Used by
|
||||||
// /api/me/history; one event per row from play_events.
|
// /api/me/history; one event per row from play_events.
|
||||||
type HistoryEvent struct {
|
type HistoryEvent struct {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user