Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b4652c77 | |||
| 261b44522d | |||
| 6f20a75f9b | |||
| 2d5f0691c2 | |||
| e8a515dac4 | |||
| acc7149537 | |||
| 4ede37d9ad | |||
| 4bd069430b | |||
| 22152b1ba3 | |||
| 572325e23f | |||
| 8d466ebdd5 | |||
| af5744f8ab | |||
| c7549bbe48 | |||
| 9cac664679 | |||
| c08f4ace80 | |||
| c1df2af992 | |||
| a7f35a5d6d | |||
| e610948307 | |||
| 8c00ee21c7 | |||
| 2500914498 | |||
| fb811804d2 | |||
| 0134281b8c | |||
| 4dbb3190ff | |||
| 2299824ad9 | |||
| a77d4ceac0 | |||
| 11c40c6aca |
@@ -72,6 +72,25 @@ jobs:
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Decode signing keystore
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Reconstructs the release keystore from a base64-encoded
|
||||
# CI secret so every tagged build is signed with the same
|
||||
# key. Without this, each CI runner would generate its own
|
||||
# debug keystore and Android would refuse to upgrade an
|
||||
# existing install (signature mismatch).
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
run: |
|
||||
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
|
||||
exit 1
|
||||
fi
|
||||
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||
@@ -79,7 +98,15 @@ jobs:
|
||||
# reports — otherwise the in-app update banner would always
|
||||
# think the user is behind because pubspec.yaml's static
|
||||
# version drifts from the actual release tag.
|
||||
#
|
||||
# ANDROID_KEYSTORE_PATH is set by the previous step;
|
||||
# build.gradle.kts picks it up and signs the release APK with
|
||||
# the operator's persistent keystore.
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
@@ -30,11 +30,38 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// Real release signing config — populated only when CI exports
|
||||
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
|
||||
// matching password/alias env vars. Without these (local
|
||||
// `flutter build apk --release` runs), the release build falls
|
||||
// through to the debug keystore so local builds still work.
|
||||
//
|
||||
// Why this matters: Flutter's default `flutter run --release` and
|
||||
// CI's earlier setup both signed with the per-machine debug
|
||||
// keystore (~/.android/debug.keystore). Every CI runner generated
|
||||
// its own debug key, so consecutive release APKs had different
|
||||
// signatures and Android refused to upgrade an existing install.
|
||||
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath)
|
||||
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Local-only fallback so `flutter run --release` works
|
||||
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ class LibraryListsApi {
|
||||
final Dio _dio;
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||
// not /api/library/artists. Albums use /api/library/albums for
|
||||
// historical reasons; the paths aren't symmetric.
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
'/api/artists',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'sort': 'alpha',
|
||||
},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
|
||||
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
|
||||
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
|
||||
/// scored by the server's recommendation engine. The shape matches
|
||||
/// what `playerActions.playTracks` expects, so a radio start is just
|
||||
/// one fetch + one playTracks call.
|
||||
class RadioApi {
|
||||
RadioApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/radio',
|
||||
queryParameters: {
|
||||
'seed_track': trackId,
|
||||
if (limit != null) 'limit': limit,
|
||||
},
|
||||
);
|
||||
final raw = (r.data?['tracks'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'cache/metadata_prefetcher.dart';
|
||||
import 'cache/prefetcher.dart';
|
||||
import 'cache/sync_controller.dart';
|
||||
import 'shared/routing.dart';
|
||||
@@ -27,6 +28,11 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
// ignore: unawaited_futures
|
||||
ref.read(syncControllerProvider.notifier).sync();
|
||||
ref.read(prefetcherProvider);
|
||||
// Metadata prefetcher: when /api/home returns, fire background
|
||||
// albumProvider/artistProvider reads for the top-N items in
|
||||
// each section so subsequent taps are drift hits, not network
|
||||
// round trips.
|
||||
ref.read(metadataPrefetcherProvider);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -88,6 +88,12 @@ extension TrackRefDriftWrite on TrackRef {
|
||||
extension CachedPlaylistAdapter on CachedPlaylist {
|
||||
/// `ownerUsername` is server-derived; cache stores the userId only.
|
||||
/// Pass empty unless a join supplies it.
|
||||
/// `coverUrl` is reconstructed deterministically from the playlist
|
||||
/// id — the server serves the cached collage at this path
|
||||
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
|
||||
/// the server hasn't built a collage yet (system playlists with no
|
||||
/// tracks at build time), the endpoint 404s and PlaylistCard's
|
||||
/// ServerImage falls back to its slate placeholder.
|
||||
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
||||
id: id,
|
||||
userId: userId,
|
||||
@@ -96,7 +102,7 @@ extension CachedPlaylistAdapter on CachedPlaylist {
|
||||
isPublic: isPublic,
|
||||
systemVariant: systemVariant,
|
||||
trackCount: trackCount,
|
||||
coverUrl: '', // server-derived; cache doesn't persist
|
||||
coverUrl: '/api/playlists/$id/cover',
|
||||
ownerUsername: ownerUsername,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
|
||||
+39
-6
@@ -83,6 +83,27 @@ class AudioCacheManager {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Registers a file already on disk in the cache index. Intended for
|
||||
/// the streaming path (LockCachingAudioSource) which writes the file
|
||||
/// itself; we need an index row so eviction can find and delete it
|
||||
/// when usage exceeds the cap. `source` defaults to incidental so
|
||||
/// stream-cached tracks are first to be evicted.
|
||||
Future<void> registerStreamCache(
|
||||
String trackId,
|
||||
String path,
|
||||
int sizeBytes, {
|
||||
CacheSource source = CacheSource.incidental,
|
||||
}) async {
|
||||
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: trackId,
|
||||
path: path,
|
||||
sizeBytes: sizeBytes,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a track from the index AND deletes the file.
|
||||
Future<void> unpin(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
@@ -96,13 +117,25 @@ class AudioCacheManager {
|
||||
.go();
|
||||
}
|
||||
|
||||
/// Total bytes used by the cache.
|
||||
/// Total bytes used by the cache. Walks the cache directory directly
|
||||
/// instead of summing the index, because the streaming-as-you-play
|
||||
/// path (audio_handler's LockCachingAudioSource) writes files
|
||||
/// without registering an index row. The index sum would always
|
||||
/// understate (often to zero) for users who only stream.
|
||||
Future<int> usageBytes() async {
|
||||
final result = await _db.customSelect(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
|
||||
readsFrom: {_db.audioCacheIndex},
|
||||
).getSingle();
|
||||
return result.read<int>('total');
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (!await dir.exists()) return 0;
|
||||
var total = 0;
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {
|
||||
// Race against concurrent writes / deletes — just skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart';
|
||||
import '../models/home_data.dart';
|
||||
|
||||
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||
/// purpose: only warms artistProvider rows (single row, single round
|
||||
/// trip per id) and only ever fires once per id per session. Album
|
||||
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
|
||||
/// list when missing, and pre-warming N albums fans out N parallel
|
||||
/// "fetch tracks" round trips that compete with the user's actual
|
||||
/// playback request for bandwidth.
|
||||
class MetadataPrefetcher {
|
||||
MetadataPrefetcher(this._ref) {
|
||||
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
|
||||
next.whenData(_warmHome);
|
||||
});
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// Per-session dedupe so re-rendering a screen (every UI rebuild
|
||||
/// fires the data: callback) doesn't trigger N more fetches for
|
||||
/// ids we've already warmed.
|
||||
final Set<String> _warmedArtists = {};
|
||||
|
||||
/// Cap per section. Covers what fits on screen without scrolling.
|
||||
static const _topN = 8;
|
||||
|
||||
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||
/// see class comment.
|
||||
void warmArtists(Iterable<String> ids) {
|
||||
var n = 0;
|
||||
for (final id in ids) {
|
||||
if (id.isEmpty) continue;
|
||||
if (!_warmedArtists.add(id)) continue; // already warmed
|
||||
if (n++ >= _topN) break;
|
||||
_swallow(_ref.read(artistProvider(id).future));
|
||||
}
|
||||
if (n > 0) debugPrint('metadataPrefetcher: warming $n artists');
|
||||
}
|
||||
|
||||
void _warmHome(HomeData h) {
|
||||
final artistIds = <String>{};
|
||||
for (final a in h.recentlyAddedAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final a in h.rediscoverAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final ar in h.rediscoverArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final ar in h.lastPlayedArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final t in h.mostPlayedTracks.take(_topN)) {
|
||||
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
|
||||
}
|
||||
warmArtists(artistIds);
|
||||
}
|
||||
|
||||
/// Discards the return value and any error from a fire-and-forget
|
||||
/// provider read. We don't care about the value here — we only want
|
||||
/// the side effect of writing drift.
|
||||
void _swallow(Future<Object?> f) {
|
||||
f.then<void>((_) {}).onError((_, __) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start to activate the prefetcher (e.g. wire it
|
||||
/// from a top-level Consumer or main.dart container override).
|
||||
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
|
||||
return MetadataPrefetcher(ref);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
@@ -12,18 +13,78 @@ import 'library_providers.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class AlbumDetailScreen extends ConsumerWidget {
|
||||
const AlbumDetailScreen({required this.id, super.key});
|
||||
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional album reference passed by the caller (typically the
|
||||
/// tile they tapped) so the header can render immediately while
|
||||
/// the full provider loads tracks. Hydration only — provider data
|
||||
/// still wins once it arrives.
|
||||
final AlbumRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final live = ref.watch(albumProvider(id));
|
||||
|
||||
// Pick the best header info available: live data > seed > nothing.
|
||||
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||
? live.value!.album.title
|
||||
: seed?.title ?? '';
|
||||
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||
? live.value!.album.artistName
|
||||
: seed?.artistName ?? '';
|
||||
|
||||
Widget header() => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
if (headerArtist.isNotEmpty)
|
||||
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: ref.watch(albumProvider(id)).when(
|
||||
body: live.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => seed != null
|
||||
? ListView(children: [
|
||||
header(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
])
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
data: (r) => ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
@@ -12,22 +13,49 @@ import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
|
||||
class ArtistDetailScreen extends ConsumerWidget {
|
||||
const ArtistDetailScreen({required this.id, super.key});
|
||||
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional artist reference from the caller. Lets the screen render
|
||||
/// the name + avatar immediately while albums load.
|
||||
final ArtistRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(artistAlbumsProvider(id));
|
||||
|
||||
// Resolve which artist info to render in the header. Live wins
|
||||
// when present and populated; seed fills the gap during the
|
||||
// first frame after navigation.
|
||||
final liveArtist = artist.value;
|
||||
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: artist.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (a) => ListView(children: [
|
||||
// While loading: render header from seed if available so the
|
||||
// page isn't blank.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _artistBody(context, ref, seed!, albums, fs),
|
||||
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ArtistRef a,
|
||||
AsyncValue<List<AlbumRef>> albums,
|
||||
FabledSwordTheme fs,
|
||||
) =>
|
||||
ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
@@ -64,10 +92,33 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
||||
if (tracks.isEmpty) return;
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
||||
try {
|
||||
final tracks =
|
||||
await ref.read(artistTracksProvider(id).future);
|
||||
debugPrint(
|
||||
'artist_detail: play tapped — ${tracks.length} tracks for $id');
|
||||
if (tracks.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No tracks found for this artist yet.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(shuffled);
|
||||
} catch (e) {
|
||||
debugPrint('artist_detail: play failed: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Couldn't start playback: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -81,7 +132,8 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
albums.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||
data: (list) => LayoutBuilder(builder: (ctx, constraints) {
|
||||
data: (list) {
|
||||
return LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
@@ -89,10 +141,11 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||
cols;
|
||||
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||
// ≈ 36) + small fudge. Artist line is suppressed in this
|
||||
// ≈ 36) + slack. Artist line is suppressed in this
|
||||
// grid (showArtist: false) since the page header already
|
||||
// names the artist.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 4;
|
||||
// names the artist. Slack is generous on purpose — line-
|
||||
// height variations would otherwise overflow by 1px.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 8;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -111,16 +164,15 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
showArtist: false,
|
||||
onTap: () => context.push('/albums/${album.id}'),
|
||||
onTap: () =>
|
||||
context.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||
|
||||
@@ -12,7 +12,6 @@ import '../models/track.dart';
|
||||
import '../playlists/playlists_provider.dart';
|
||||
import '../playlists/widgets/playlist_card.dart';
|
||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||
import '../shared/delayed_loading.dart';
|
||||
import '../shared/widgets/connection_error_banner.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => const DelayedLoading(
|
||||
isLoading: true,
|
||||
whenReady: SizedBox.shrink(),
|
||||
whileDelayed:
|
||||
Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
loading: () => _HomeSkeleton(fs: fs),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(
|
||||
@@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget {
|
||||
.map((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
_push(context, '/albums/${a.id}'),
|
||||
context.push('/albums/${a.id}', extra: a),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
@@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget {
|
||||
.map((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
_push(context, '/albums/${a.id}'),
|
||||
context.push('/albums/${a.id}', extra: a),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
@@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget {
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () =>
|
||||
_push(context, '/artists/${ar.id}'),
|
||||
context.push('/artists/${ar.id}', extra: ar),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
@@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget {
|
||||
children: artists
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () => _push(context, '/artists/${ar.id}'),
|
||||
onTap: () =>
|
||||
context.push('/artists/${ar.id}', extra: ar),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
@@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _push(BuildContext context, String path) {
|
||||
context.push(path);
|
||||
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
||||
/// section titles + card-sized placeholders) so the page feels alive
|
||||
/// while /api/home is in flight, instead of a 30-second blank spinner.
|
||||
/// Each section drops in independently as data arrives via the
|
||||
/// individual providers.
|
||||
class _HomeSkeleton extends StatelessWidget {
|
||||
const _HomeSkeleton({required this.fs});
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
|
||||
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonSection extends StatelessWidget {
|
||||
const _SkeletonSection({
|
||||
required this.fs,
|
||||
required this.title,
|
||||
required this.cardWidth,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final String title;
|
||||
final double cardWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coverSize = cardWidth - 16;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: coverSize + 40,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 6,
|
||||
itemBuilder: (_, __) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.slate,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
|
||||
const SizedBox(height: 4),
|
||||
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
List<List<T>> _chunk<T>(List<T> items, int size) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
@@ -64,6 +66,10 @@ final artistProvider =
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// No alwaysRefresh: artist rows don't change frequently, and the
|
||||
// metadata prefetcher creates many subscriptions in parallel —
|
||||
// each silently re-fetching once would saturate the request
|
||||
// pipeline behind the user's actual playback request.
|
||||
tag: 'artist($id)',
|
||||
);
|
||||
});
|
||||
@@ -128,8 +134,10 @@ final artistTracksProvider =
|
||||
albumTitle: album?.title ?? '',
|
||||
);
|
||||
}).toList(),
|
||||
isOnline: () async =>
|
||||
(await ref.read(connectivityProvider.future)),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
tag: 'artistTracks($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +168,7 @@ final albumProvider = StreamProvider.family<
|
||||
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
||||
// server genuinely returns zero tracks (or if the fetch fails).
|
||||
var fetchAttempted = false;
|
||||
// (revalidated flag removed; see SWR note below the yield.)
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
@@ -290,6 +299,14 @@ final albumProvider = StreamProvider.family<
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Note: NO SWR here on purpose. Prior code kicked a background
|
||||
// refresh on every first cache hit, which combined with the
|
||||
// metadata prefetcher meant every prewarmed album id triggered
|
||||
// an extra fetch even when drift was already populated. Tracks
|
||||
// and album metadata don't change on the same timescale as
|
||||
// playlists; a stale read is fine until the user invalidates
|
||||
// (pull-to-refresh) or the album is genuinely re-fetched.
|
||||
|
||||
yield (album: album, tracks: tracks);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/endpoints/library_lists.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../cache/metadata_prefetcher.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
@@ -37,13 +38,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
||||
});
|
||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||
/// pages append to the existing items rather than replacing them.
|
||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
||||
});
|
||||
@override
|
||||
Future<wire.Paged<ArtistRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listArtists(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listArtists(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — the next near-bottom event will retry. The current
|
||||
// partial list stays visible.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||
_LibraryArtistsNotifier,
|
||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||
|
||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<AlbumRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listAlbums(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — next scroll event will retry.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||
_LibraryAlbumsNotifier,
|
||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||
|
||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).history();
|
||||
@@ -131,25 +208,53 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryArtistsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
data: (page) {
|
||||
// Warm details for the first screenful so taps are instant.
|
||||
ref
|
||||
.read(metadataPrefetcherProvider)
|
||||
.warmArtists(page.items.map((a) => a.id));
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: page.items[i],
|
||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryArtistsProvider);
|
||||
await ref.read(_libraryArtistsProvider.future);
|
||||
},
|
||||
// Listen for scroll-near-bottom and ask the notifier
|
||||
// to fetch the next page. 800px lookahead so the next
|
||||
// page lands before the user reaches the visible end.
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryArtistsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = page.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => ctx.push('/artists/${artist.id}',
|
||||
extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,25 +268,66 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryAlbumsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
data: (page) {
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: page.items[i],
|
||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryAlbumsProvider);
|
||||
await ref.read(_libraryAlbumsProvider.future);
|
||||
},
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW = (constraints.maxWidth -
|
||||
sidePad * 2 -
|
||||
gap * (cols - 1)) /
|
||||
cols;
|
||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||
// + artist line (~16) + slack. Slack is generous on
|
||||
// purpose — line-height + font scaling variations
|
||||
// would otherwise overflow the cell by a pixel
|
||||
// (logged as a noisy RenderFlex warning).
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryAlbumsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(sidePad),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = page.items[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
onTap: () => ctx.push('/albums/${album.id}',
|
||||
extra: album),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -260,10 +406,14 @@ class _LikedTab extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: ar.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: ar.items[i],
|
||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
||||
),
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = ar.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () =>
|
||||
ctx.push('/artists/${artist.id}', extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -275,10 +425,14 @@ class _LikedTab extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: al.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: al.items[i],
|
||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
||||
),
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = al.items[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
onTap: () =>
|
||||
ctx.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,12 +12,36 @@ import 'album_cover_cache.dart';
|
||||
|
||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
MinstrelAudioHandler() {
|
||||
_player.playbackEventStream.listen(_broadcastState);
|
||||
_player.playbackEventStream.listen(
|
||||
_broadcastState,
|
||||
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||
// failure, network drop) here. Without an error sink, the
|
||||
// player just goes quiet — exactly the "starts then stops"
|
||||
// symptom we hit.
|
||||
onError: (Object e, StackTrace st) {
|
||||
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||
},
|
||||
);
|
||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
||||
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||
_player.loopModeStream.listen((_) => _broadcastState(null));
|
||||
// Watch buffered-position so we can register stream-cached files
|
||||
// in the audio cache index once they're fully downloaded. Without
|
||||
// this, files written by LockCachingAudioSource never appear in
|
||||
// the index and the eviction loop can't reclaim them.
|
||||
_player.bufferedPositionStream
|
||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||
// 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();
|
||||
@@ -26,6 +50,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
AlbumCoverCache? _coverCache;
|
||||
AudioCacheManager? _audioCacheManager;
|
||||
|
||||
/// Trackers to dedupe registration — once we've inserted an index row
|
||||
/// for a trackId, don't repeat the work on every buffered-position
|
||||
/// emit. Cleared on dispose only; surviving across queue rebuilds is
|
||||
/// fine because the index is itself the source of truth.
|
||||
final Set<String> _streamCacheRegistered = {};
|
||||
|
||||
/// Cached on first use so we don't hit the platform channel every
|
||||
/// time the buffered-position stream emits (~200ms cadence).
|
||||
String? _cacheDirPath;
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
@@ -64,12 +98,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
|
||||
debugPrint('audio_handler.setQueueFromTracks: '
|
||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||
final sw = Stopwatch()..start();
|
||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
||||
debugPrint('audio_handler: built ${sources.length} sources in '
|
||||
'${sw.elapsedMilliseconds}ms');
|
||||
sw.reset();
|
||||
sw.start();
|
||||
|
||||
await _player.setAudioSources(
|
||||
sources,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms');
|
||||
|
||||
// Kick the cover fetch for the initial item — async, doesn't block
|
||||
// playback. Subsequent track changes are handled by the
|
||||
@@ -127,8 +167,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
// pin / Download buttons cover the index path; LockCaching handles
|
||||
// the network optimization.
|
||||
if (mgr != null) {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
||||
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
||||
'using LockCachingAudioSource → ${cacheFile.path}');
|
||||
// ignore: experimental_member_use
|
||||
@@ -165,19 +205,69 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
queue.add([...queue.value, item]);
|
||||
}
|
||||
|
||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
// Stash album_id in extras so _loadArtForCurrentItem can pull it
|
||||
// back without re-walking the track list.
|
||||
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
|
||||
);
|
||||
MediaItem _toMediaItem(TrackRef t) {
|
||||
// Stash album_id + artist_id in extras so widgets reconstructing
|
||||
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
||||
// "Go to album") have the IDs they need to navigate. Earlier code
|
||||
// only carried album_id which left "Go to artist" pushing
|
||||
// /artists/ (empty id, route 404).
|
||||
final extras = <String, dynamic>{};
|
||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||
return MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
extras: extras.isEmpty ? null : extras,
|
||||
);
|
||||
}
|
||||
|
||||
/// Once a track is fully buffered (LockCaching has written the whole
|
||||
/// file to disk), insert an audio_cache_index row so the file shows
|
||||
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
|
||||
/// cache manager isn't configured, no current track, the file isn't
|
||||
/// fully buffered yet, the on-disk file is missing, or we already
|
||||
/// registered this trackId during this subscription.
|
||||
Future<void> _maybeRegisterStreamCache() async {
|
||||
final mgr = _audioCacheManager;
|
||||
if (mgr == null) return;
|
||||
final current = mediaItem.value;
|
||||
if (current == null) return;
|
||||
final trackId = current.id;
|
||||
if (_streamCacheRegistered.contains(trackId)) return;
|
||||
|
||||
final dur = _player.duration;
|
||||
if (dur == null) return;
|
||||
final buf = _player.bufferedPosition;
|
||||
// 200ms slack for header bytes / encoding rounding.
|
||||
if (buf < dur - const Duration(milliseconds: 200)) return;
|
||||
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return;
|
||||
final size = await file.length();
|
||||
if (size <= 0) return;
|
||||
|
||||
_streamCacheRegistered.add(trackId);
|
||||
await mgr.registerStreamCache(trackId, path, size);
|
||||
debugPrint(
|
||||
'audio_handler: registered stream cache for $trackId ($size bytes)');
|
||||
}
|
||||
|
||||
void _onCurrentIndexChanged(int? idx) {
|
||||
if (idx == null) return;
|
||||
// Push the new track's MediaItem onto the mediaItem stream so
|
||||
// the player UI rebuilds with the new title/artist/album/cover.
|
||||
// Without this, the bar and full player stayed pinned to whichever
|
||||
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
||||
// of skip/auto-advance.
|
||||
final items = queue.value;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
mediaItem.add(items[idx]);
|
||||
}
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
}
|
||||
|
||||
|
||||
@@ -113,20 +113,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 24),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SecondaryControls(
|
||||
fs: fs,
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 24),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@@ -219,30 +220,15 @@ class _TitleRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
]);
|
||||
// Title-only — like + kebab moved into _SecondaryControls above
|
||||
// the seek bar so they share the same row as shuffle/repeat/queue.
|
||||
return Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,17 +332,23 @@ class _PrimaryControls extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Action row sitting just above the seek bar. Holds shuffle / repeat
|
||||
/// / queue plus the like + kebab that used to live in the title row,
|
||||
/// so the title can sit truly centered above and this row carries
|
||||
/// every per-track action in one place.
|
||||
class _SecondaryControls extends StatelessWidget {
|
||||
const _SecondaryControls({
|
||||
required this.fs,
|
||||
required this.actions,
|
||||
required this.shuffleOn,
|
||||
required this.repeatMode,
|
||||
required this.media,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final PlayerActions actions;
|
||||
final bool shuffleOn;
|
||||
final AudioServiceRepeatMode repeatMode;
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -392,6 +384,20 @@ class _SecondaryControls extends StatelessWidget {
|
||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: '',
|
||||
// artist_id is stashed in extras by audio_handler — without it
|
||||
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/radio.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
@@ -52,8 +54,22 @@ class PlayerActions {
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
// Stage-by-stage timing so we can see exactly where the
|
||||
// "tap → audio" delay lives. Look for "playTracks: ..." lines
|
||||
// in the next log capture; each stage label is followed by its
|
||||
// elapsed millis since the previous stage.
|
||||
final sw = Stopwatch()..start();
|
||||
int lap() {
|
||||
final ms = sw.elapsedMilliseconds;
|
||||
sw.reset();
|
||||
sw.start();
|
||||
return ms;
|
||||
}
|
||||
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
debugPrint('playTracks: serverUrl ${lap()}ms');
|
||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
debugPrint('playTracks: token ${lap()}ms');
|
||||
final cache = _ref.read(albumCoverCacheProvider);
|
||||
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||
final h = _ref.read(audioHandlerProvider)
|
||||
@@ -63,8 +79,11 @@ class PlayerActions {
|
||||
coverCache: cache,
|
||||
audioCacheManager: audioCache,
|
||||
);
|
||||
debugPrint('playTracks: configure ${lap()}ms');
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||
debugPrint('playTracks: setQueue (${tracks.length} tracks) ${lap()}ms');
|
||||
await h.play();
|
||||
debugPrint('playTracks: play() returned ${lap()}ms');
|
||||
}
|
||||
|
||||
Future<void> playNext(TrackRef track) async {
|
||||
@@ -102,6 +121,15 @@ class PlayerActions {
|
||||
Future<void> setVolume(double v) async {
|
||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||
}
|
||||
|
||||
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
||||
/// returned track list (seed at index 0 + recommended picks).
|
||||
Future<void> startRadio(String trackId) async {
|
||||
final dio = await _ref.read(dioProvider.future);
|
||||
final tracks = await RadioApi(dio).seedTrack(trackId);
|
||||
if (tracks.isEmpty) return;
|
||||
await playTracks(tracks);
|
||||
}
|
||||
}
|
||||
|
||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||
|
||||
@@ -83,7 +83,11 @@ class _PlaylistTile extends StatelessWidget {
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -42,7 +45,21 @@ final playlistsListProvider =
|
||||
debugPrint(
|
||||
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
|
||||
'(system=$ownedSysCount) public=${fresh.public.length}');
|
||||
|
||||
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
||||
// every rebuild, so insertOrReplace alone leaves stale rows in
|
||||
// drift. Tapping one of those stale tiles 404s. Delete every
|
||||
// owned drift row whose id isn't in the fresh response, then
|
||||
// upsert the fresh set.
|
||||
final freshOwnedIds =
|
||||
fresh.owned.map((p) => p.id).toSet();
|
||||
await db.batch((b) {
|
||||
if (user != null) {
|
||||
b.deleteWhere(db.cachedPlaylists, (t) {
|
||||
return t.userId.equals(user.id) &
|
||||
t.id.isNotIn(freshOwnedIds);
|
||||
});
|
||||
}
|
||||
for (final p in fresh.all) {
|
||||
b.insert(db.cachedPlaylists, p.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
@@ -123,41 +140,193 @@ final playlistDetailProvider =
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> fetchAndPopulate() async {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
debugPrint('playlistDetailProvider($id): calling get');
|
||||
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||
debugPrint(
|
||||
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
|
||||
|
||||
// Collect the track + artist + album rows referenced by these
|
||||
// playlist entries. Without writing the cachedTracks rows
|
||||
// themselves, the detail-screen JOIN against cachedTracks
|
||||
// returns null on every row (only the playlist_tracks join
|
||||
// succeeds), so the UI shows a list with empty titles and
|
||||
// unplayable tracks.
|
||||
final tracks = <String, _TrackRefRow>{};
|
||||
final artists = <String, ArtistRefRow>{};
|
||||
final albums = <String, AlbumRefRow>{};
|
||||
for (final t in fresh.tracks) {
|
||||
final tId = t.trackId;
|
||||
if (tId != null && tId.isNotEmpty) {
|
||||
tracks.putIfAbsent(
|
||||
tId,
|
||||
() => _TrackRefRow(
|
||||
id: tId,
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? '',
|
||||
durationSec: t.durationSec,
|
||||
),
|
||||
);
|
||||
}
|
||||
final aId = t.artistId;
|
||||
final aName = t.artistName;
|
||||
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
||||
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
|
||||
}
|
||||
final lbId = t.albumId;
|
||||
final lbTitle = t.albumTitle;
|
||||
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
|
||||
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
|
||||
// Wipe + re-insert this playlist's track positions so deletions
|
||||
// propagate. Without the wipe, removed tracks would linger in
|
||||
// drift after the playlist mutated server-side.
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedArtists,
|
||||
artists.values
|
||||
.map((a) => CachedArtistsCompanion.insert(
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
sortName: a.name,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (tracks.isNotEmpty) {
|
||||
// Insert/update cachedTracks so the detail screen's JOIN
|
||||
// produces real titles + durations. We don't have
|
||||
// track_number / disc_number on the wire (PlaylistTrack
|
||||
// omits them), so they default to 0 — UI doesn't surface
|
||||
// them on this screen so it's fine.
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedTracks,
|
||||
tracks.values
|
||||
.map((t) => CachedTracksCompanion.insert(
|
||||
id: t.id,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
title: t.title,
|
||||
durationMs: drift.Value(t.durationSec * 1000),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (albums.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums,
|
||||
albums.values
|
||||
.map((a) => CachedAlbumsCompanion.insert(
|
||||
id: a.id,
|
||||
artistId: a.artistId,
|
||||
title: a.title,
|
||||
sortTitle: a.title,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
debugPrint(
|
||||
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
||||
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
|
||||
// tiles can outlive the actual server-side playlist). Wipe the
|
||||
// stale row + its track positions so the home tile disappears
|
||||
// on next render and we don't keep trying.
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
debugPrint(
|
||||
'playlistDetailProvider($id): server says 404, evicting stale drift rows');
|
||||
await db.batch((b) {
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Once-per-subscription guard so an empty server response doesn't
|
||||
// cause repeated re-fetches.
|
||||
var fetchAttempted = false;
|
||||
|
||||
await for (final playlistRows in playlistQuery.watch()) {
|
||||
if (playlistRows.isEmpty) {
|
||||
if (await ref.read(connectivityProvider.future)) {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id);
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
// watch() re-emits next iteration with populated row.
|
||||
} catch (_) {
|
||||
yield emptyDetail();
|
||||
}
|
||||
} else {
|
||||
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
|
||||
if (fetchAttempted) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
fetchAttempted = true;
|
||||
if (!await isOnline()) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
final ok = await fetchAndPopulate();
|
||||
if (!ok) yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
|
||||
|
||||
final playlist = playlistRows.first.toRef();
|
||||
final trackRows = await tracksQuery.get();
|
||||
|
||||
// Same pattern as albumProvider: playlist row exists but no tracks
|
||||
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
|
||||
// tracks for system playlists). Trigger the same fetch, drift
|
||||
// watch re-emits with populated tracks.
|
||||
if (trackRows.isEmpty && !fetchAttempted) {
|
||||
debugPrint(
|
||||
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
|
||||
fetchAttempted = true;
|
||||
if (await isOnline()) {
|
||||
final ok = await fetchAndPopulate();
|
||||
if (ok) continue; // wait for watch re-emit
|
||||
}
|
||||
}
|
||||
|
||||
final tracks = trackRows.asMap().entries.map((e) {
|
||||
final r = e.value;
|
||||
final track = r.readTableOrNull(db.cachedTracks);
|
||||
@@ -177,9 +346,47 @@ final playlistDetailProvider =
|
||||
}).toList();
|
||||
|
||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||
|
||||
// No SWR refresh here. The aggregate playlistsListProvider does
|
||||
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
|
||||
// refresh on every visit was multiplying with the prefetcher's
|
||||
// parallel fetches and starving user-initiated playback. Pull-
|
||||
// to-refresh on the detail page invalidates the provider, which
|
||||
// is the right path for an explicit refresh.
|
||||
}
|
||||
});
|
||||
|
||||
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
|
||||
/// and album rows referenced by playlist tracks before we batch-write
|
||||
/// them to drift.
|
||||
class ArtistRefRow {
|
||||
ArtistRefRow(this.id, this.name);
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class AlbumRefRow {
|
||||
AlbumRefRow(this.id, this.title, this.artistId);
|
||||
final String id;
|
||||
final String title;
|
||||
final String artistId;
|
||||
}
|
||||
|
||||
class _TrackRefRow {
|
||||
_TrackRefRow({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.artistId,
|
||||
required this.durationSec,
|
||||
});
|
||||
final String id;
|
||||
final String title;
|
||||
final String albumId;
|
||||
final String artistId;
|
||||
final int durationSec;
|
||||
}
|
||||
|
||||
final systemPlaylistsStatusProvider =
|
||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
|
||||
@@ -31,9 +31,19 @@ class PlaylistCard extends StatelessWidget {
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
// coverUrl is deterministic per playlist (see
|
||||
// CachedPlaylistAdapter.toRef). When the server's
|
||||
// collage isn't built yet, ServerImage's
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback:
|
||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../auth/login_screen.dart';
|
||||
import '../auth/server_url_screen.dart';
|
||||
import '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
@@ -83,19 +85,34 @@ GoRouter buildRouter(Ref ref) {
|
||||
},
|
||||
),
|
||||
),
|
||||
// /queue lives outside the ShellRoute too. Why: pushing /queue
|
||||
// from /now-playing (which is also outside the shell) used to
|
||||
// cause go_router to mount a second ShellRoute instance under
|
||||
// the existing one, producing a duplicate page-key assertion
|
||||
// (NavigatorState._debugCheckDuplicatedPageKeys). Top-level
|
||||
// routes can stack on each other freely; shell-children can't
|
||||
// when something on top of the shell is already routing.
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||
routes: [
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/artists/:id',
|
||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
||||
// `extra` carries an optional ArtistRef so the detail
|
||||
// header renders immediately while tracks/albums load.
|
||||
builder: (_, s) => ArtistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => AlbumDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||
|
||||
@@ -117,6 +117,23 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_start_radio'),
|
||||
icon: Icons.radio,
|
||||
label: 'Start radio',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await ref.read(playerActionsProvider).startRadio(track.id);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Couldn't start radio: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const _Divider(),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_go_to_album'),
|
||||
|
||||
+27
-20
@@ -40,19 +40,28 @@ func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
||||
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
||||
// tracks (ordered by disc/track number via the underlying query) with
|
||||
// duration summed from the track list — keeps one source of truth.
|
||||
//
|
||||
// Two DB round trips: the album+artist join and the per-user tracks
|
||||
// list. Down from three (separate album, artist, tracks) before
|
||||
// GetAlbumWithArtist landed.
|
||||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
|
||||
if apiErr != nil {
|
||||
writeErr(w, apiErr)
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
|
||||
row, err := q.GetAlbumWithArtist(r.Context(), id)
|
||||
if err != nil {
|
||||
h.logger.Error("api: get album artist failed", "err", err)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("album"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get album+artist failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
album := row.Album
|
||||
artistName := row.ArtistName
|
||||
var userID pgtype.UUID
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
userID = user.ID
|
||||
@@ -68,20 +77,24 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
refs := make([]TrackRef, 0, len(tracks))
|
||||
durSec := 0
|
||||
for _, t := range tracks {
|
||||
ref := trackRefFrom(t, album.Title, artist.Name)
|
||||
ref := trackRefFrom(t, album.Title, artistName)
|
||||
refs = append(refs, ref)
|
||||
durSec += ref.DurationSec
|
||||
}
|
||||
detail := AlbumDetail{
|
||||
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
|
||||
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||
Tracks: refs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
||||
// each album carries its own track_count (one count query per album, same
|
||||
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
|
||||
// each album carries its own track_count.
|
||||
//
|
||||
// Down from 1 + 1 + N queries (artist, albums, per-album CountTracksByAlbum)
|
||||
// to 1 + 1 (artist, albums-with-track-count via correlated subquery).
|
||||
// On a 30-album artist that's ~32 round trips collapsed to 2 — the
|
||||
// difference between "feels slow" and "feels instant" on detail nav.
|
||||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
|
||||
@@ -89,25 +102,19 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, apiErr)
|
||||
return
|
||||
}
|
||||
albums, err := q.ListAlbumsByArtist(r.Context(), artist.ID)
|
||||
rows, err := q.ListAlbumsByArtistWithTrackCount(r.Context(), artist.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list albums by artist failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
refs := make([]AlbumRef, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
|
||||
if cerr != nil {
|
||||
h.logger.Error("api: count tracks failed", "err", cerr)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", cerr))
|
||||
return
|
||||
}
|
||||
refs := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
|
||||
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
||||
}
|
||||
detail := ArtistDetail{
|
||||
ArtistRef: artistRefFrom(artist, len(albums)),
|
||||
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||
Albums: refs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
|
||||
@@ -168,6 +168,41 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.id = $1
|
||||
`
|
||||
|
||||
type GetAlbumWithArtistRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Combined fetch for /api/albums/{id}: returns the album row + the
|
||||
// joined artist name in a single round trip. Replaces a sequential
|
||||
// GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
|
||||
var i GetAlbumWithArtistRow
|
||||
err := row.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
|
||||
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
@@ -389,6 +424,56 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||
AS track_count
|
||||
FROM albums
|
||||
WHERE albums.artist_id = $1
|
||||
ORDER BY release_date NULLS LAST, sort_title
|
||||
`
|
||||
|
||||
type ListAlbumsByArtistWithTrackCountRow struct {
|
||||
Album Album
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
// Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||
// per album). Returns each album joined with its track count via a
|
||||
// correlated subquery — single round trip regardless of album count.
|
||||
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumsByArtistWithTrackCountRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumsByArtistWithTrackCountRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.TrackCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||
FROM albums
|
||||
|
||||
@@ -12,17 +12,19 @@ import (
|
||||
)
|
||||
|
||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
max_started.started_at::timestamptz AS last_played_at
|
||||
FROM artists a
|
||||
JOIN LATERAL (
|
||||
SELECT max(pe.started_at) AS started_at
|
||||
WITH user_plays AS (
|
||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
||||
) max_started ON max_started.started_at IS NOT NULL
|
||||
WHERE pe.user_id = $1
|
||||
GROUP BY t.artist_id
|
||||
)
|
||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
up.last_started::timestamptz AS last_played_at
|
||||
FROM user_plays up
|
||||
JOIN artists a ON a.id = up.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||
@@ -32,7 +34,7 @@ LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = a.id
|
||||
) cnt ON true
|
||||
ORDER BY max_started.started_at DESC, a.id
|
||||
ORDER BY up.last_started DESC, a.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
@@ -52,6 +54,15 @@ type ListLastPlayedArtistsForUserRow struct {
|
||||
// a derived cover_album_id via a representative-album lateral join (most
|
||||
// recent album that has cover_art_path set). album_count joined for the
|
||||
// ArtistRef wire shape.
|
||||
//
|
||||
// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||
// subquery per artist — O(total_artists) plan even for users with a
|
||||
// handful of plays. New shape aggregates the user's plays by artist
|
||||
// via the play_events → tracks join up front (uses
|
||||
// play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||
// the artist row and lateral cover/count subqueries only for the
|
||||
// artists that actually appear. Distinct-artists set is small for a
|
||||
// typical user, so cost is bounded by play history not library size.
|
||||
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
@@ -87,24 +98,24 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
JOIN play_events pe ON pe.track_id = t.id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title, artists.name
|
||||
ORDER BY count(*) DESC, t.id
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
@@ -122,6 +133,11 @@ type ListMostPlayedTracksForUserRow struct {
|
||||
// M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||
// excludes skips so a user spamming next doesn't fabricate a top track.
|
||||
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||
//
|
||||
// Aggregate play_events first (uses play_events_user_track_idx) and
|
||||
// join through tracks/albums/artists only for the survivors. Earlier
|
||||
// shape did the join across every play_event row before grouping —
|
||||
// O(plays) instead of O(distinct tracks).
|
||||
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,26 @@ RETURNING *;
|
||||
-- name: GetAlbumByID :one
|
||||
SELECT * FROM albums WHERE id = $1;
|
||||
|
||||
-- name: GetAlbumWithArtist :one
|
||||
-- Combined fetch for /api/albums/{id}: returns the album row + the
|
||||
-- joined artist name in a single round trip. Replaces a sequential
|
||||
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.id = $1;
|
||||
|
||||
-- name: ListAlbumsByArtistWithTrackCount :many
|
||||
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||
-- per album). Returns each album joined with its track count via a
|
||||
-- correlated subquery — single round trip regardless of album count.
|
||||
SELECT sqlc.embed(albums),
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||
AS track_count
|
||||
FROM albums
|
||||
WHERE albums.artist_id = $1
|
||||
ORDER BY release_date NULLS LAST, sort_title;
|
||||
|
||||
-- name: GetAlbumByArtistAndTitle :one
|
||||
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
|
||||
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
|
||||
|
||||
@@ -206,24 +206,29 @@ LIMIT $3;
|
||||
-- M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||
-- excludes skips so a user spamming next doesn't fabricate a top track.
|
||||
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||
--
|
||||
-- Aggregate play_events first (uses play_events_user_track_idx) and
|
||||
-- join through tracks/albums/artists only for the survivors. Earlier
|
||||
-- shape did the join across every play_event row before grouping —
|
||||
-- O(plays) instead of O(distinct tracks).
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT sqlc.embed(t),
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
JOIN play_events pe ON pe.track_id = t.id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title, artists.name
|
||||
ORDER BY count(*) DESC, t.id
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListLastPlayedArtistsForUser :many
|
||||
@@ -231,17 +236,28 @@ LIMIT $2;
|
||||
-- a derived cover_album_id via a representative-album lateral join (most
|
||||
-- recent album that has cover_art_path set). album_count joined for the
|
||||
-- ArtistRef wire shape.
|
||||
SELECT sqlc.embed(a),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
max_started.started_at::timestamptz AS last_played_at
|
||||
FROM artists a
|
||||
JOIN LATERAL (
|
||||
SELECT max(pe.started_at) AS started_at
|
||||
--
|
||||
-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||
-- subquery per artist — O(total_artists) plan even for users with a
|
||||
-- handful of plays. New shape aggregates the user's plays by artist
|
||||
-- via the play_events → tracks join up front (uses
|
||||
-- play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||
-- the artist row and lateral cover/count subqueries only for the
|
||||
-- artists that actually appear. Distinct-artists set is small for a
|
||||
-- typical user, so cost is bounded by play history not library size.
|
||||
WITH user_plays AS (
|
||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
||||
) max_started ON max_started.started_at IS NOT NULL
|
||||
WHERE pe.user_id = $1
|
||||
GROUP BY t.artist_id
|
||||
)
|
||||
SELECT sqlc.embed(a),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
up.last_started::timestamptz AS last_played_at
|
||||
FROM user_plays up
|
||||
JOIN artists a ON a.id = up.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||
@@ -251,7 +267,7 @@ LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = a.id
|
||||
) cnt ON true
|
||||
ORDER BY max_started.started_at DESC, a.id
|
||||
ORDER BY up.last_started DESC, a.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListRediscoverAlbumsForUser :many
|
||||
|
||||
Reference in New Issue
Block a user