96aa2407d9
Server has been generating system_variant='discover' playlists since M6a (internal/playlists/system.go and POST /api/playlists/system/discover/refresh) but neither client surfaced it. The Flutter home filtered system playlists by exact match on 'for_you' and 'songs_like_artist', dropping Discover. The web home did the same. Tiles were generated server-side and silently discarded by both clients. Add a Discover slot to the playlists row in both: - Flutter: 5-slot row now (For You, Discover, 3× Songs-like) with matching placeholder state machine. - Web: same shape, same placeholderVariant() call. When the engine has built it, the real playlist tile renders; otherwise a placeholder with the same building/pending/failed status semantics as the other system tiles.
421 lines
13 KiB
Dart
421 lines
13 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api/errors.dart';
|
|
import '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/playlist.dart';
|
|
import '../models/system_playlists_status.dart';
|
|
import '../models/track.dart';
|
|
import '../playlists/playlists_provider.dart';
|
|
import '../playlists/widgets/playlist_card.dart';
|
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
|
import '../shared/widgets/connection_error_banner.dart';
|
|
import '../shared/widgets/main_app_bar_actions.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'library_providers.dart';
|
|
import 'widgets/album_card.dart';
|
|
import 'widgets/artist_card.dart';
|
|
import 'widgets/compact_track_card.dart';
|
|
import 'widgets/horizontal_scroll_row.dart';
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final home = ref.watch(homeProvider);
|
|
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
|
final status = ref.watch(systemPlaylistsStatusProvider);
|
|
return Scaffold(
|
|
backgroundColor: fs.obsidian,
|
|
appBar: AppBar(
|
|
backgroundColor: fs.obsidian,
|
|
elevation: 0,
|
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
|
actions: const [MainAppBarActions(currentRoute: '/home')],
|
|
),
|
|
body: SafeArea(
|
|
child: home.when(
|
|
error: (e, _) {
|
|
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
|
if (code == 'connection_refused') {
|
|
return ConnectionErrorBanner(
|
|
onRetry: () => ref.refresh(homeProvider),
|
|
);
|
|
}
|
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
|
},
|
|
loading: () => _HomeSkeleton(fs: fs),
|
|
data: (h) => RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(homeProvider.future),
|
|
child: ListView(
|
|
// ClampingScrollPhysics: no bounce overscroll past the
|
|
// content. Combined with the bottom padding below, this
|
|
// makes "scroll to end" land the last item just above the
|
|
// player bar — no empty void.
|
|
physics: const ClampingScrollPhysics(),
|
|
children: [
|
|
_PlaylistsSection(
|
|
playlists: allPlaylists.value?.owned ?? const [],
|
|
status: status.value ?? SystemPlaylistsStatus.empty(),
|
|
),
|
|
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
|
_RediscoverSection(
|
|
albums: h.rediscoverAlbums,
|
|
artists: h.rediscoverArtists,
|
|
),
|
|
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
|
_LastPlayedSection(artists: h.lastPlayedArtists),
|
|
// Bottom padding ≈ player bar height (top row 80 + seek
|
|
// 30 + padding 16 ≈ ~140) so the last section's bottom
|
|
// edge lands right above the player when scrolled.
|
|
const SizedBox(height: 140),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaylistsSection extends StatelessWidget {
|
|
const _PlaylistsSection({required this.playlists, required this.status});
|
|
final List<Playlist> playlists;
|
|
final SystemPlaylistsStatus status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final items = _buildPlaylistsRow(playlists, status);
|
|
return HorizontalScrollRow(
|
|
title: 'Playlists',
|
|
height: 220,
|
|
children: items.map((item) {
|
|
if (item is _RealPlaylist) {
|
|
return PlaylistCard(playlist: item.playlist);
|
|
}
|
|
final ph = item as _PlaceholderPlaylist;
|
|
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
|
}).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
abstract class _PlaylistRowItem {}
|
|
class _RealPlaylist extends _PlaylistRowItem {
|
|
_RealPlaylist(this.playlist);
|
|
final Playlist playlist;
|
|
}
|
|
class _PlaceholderPlaylist extends _PlaylistRowItem {
|
|
_PlaceholderPlaylist(this.label, this.variant);
|
|
final String label;
|
|
final String variant;
|
|
}
|
|
|
|
List<_PlaylistRowItem> _buildPlaylistsRow(
|
|
List<Playlist> ownedAll,
|
|
SystemPlaylistsStatus status,
|
|
) {
|
|
final out = <_PlaylistRowItem>[];
|
|
|
|
Playlist? findFirst(bool Function(Playlist) test) {
|
|
for (final p in ownedAll) {
|
|
if (test(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Slot 1: For-You.
|
|
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
|
out.add(forYou != null
|
|
? _RealPlaylist(forYou)
|
|
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
|
|
|
// Slot 2: Discover. Server emits this with system_variant='discover'
|
|
// when the recommendation engine has eligible tracks; before then,
|
|
// show a placeholder in the same "building/pending" state machine
|
|
// as For-You.
|
|
final discover = findFirst((p) => p.systemVariant == 'discover');
|
|
out.add(discover != null
|
|
? _RealPlaylist(discover)
|
|
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
|
|
|
|
// Slots 3-5: Songs-like (real first, padded to 3).
|
|
final songsLike = ownedAll
|
|
.where((p) => p.systemVariant == 'songs_like_artist')
|
|
.take(3)
|
|
.toList();
|
|
for (var i = 0; i < 3; i++) {
|
|
out.add(i < songsLike.length
|
|
? _RealPlaylist(songsLike[i])
|
|
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
|
|
}
|
|
|
|
// User-created trail (server returns most-recently-updated first).
|
|
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
|
|
out.add(_RealPlaylist(p));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
String _variantFor(String slot, SystemPlaylistsStatus s) {
|
|
if (s.inFlight) return 'building';
|
|
if (s.lastError != null) return 'failed';
|
|
if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed';
|
|
return 'pending';
|
|
}
|
|
|
|
class _RecentlyAddedSection extends StatelessWidget {
|
|
const _RecentlyAddedSection({required this.albums});
|
|
final List<AlbumRef> albums;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (albums.isEmpty) {
|
|
return const _EmptySection(
|
|
title: 'Recently added',
|
|
message: "Nothing added yet. Scan a folder via the server's config.",
|
|
);
|
|
}
|
|
final controller = ScrollController();
|
|
final rows = _chunk(albums, 25);
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
for (var i = 0; i < rows.length; i++)
|
|
HorizontalScrollRow(
|
|
title: i == 0 ? 'Recently added' : '',
|
|
controller: controller,
|
|
children: rows[i]
|
|
.map((a) => AlbumCard(
|
|
album: a,
|
|
onTap: () =>
|
|
context.push('/albums/${a.id}', extra: a),
|
|
))
|
|
.toList(),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class _RediscoverSection extends StatelessWidget {
|
|
const _RediscoverSection({required this.albums, required this.artists});
|
|
final List<AlbumRef> albums;
|
|
final List<ArtistRef> artists;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (albums.isEmpty && artists.isEmpty) {
|
|
return const _EmptySection(
|
|
title: 'Rediscover',
|
|
message:
|
|
'No forgotten favourites yet. Like some albums or artists to fill this in.',
|
|
);
|
|
}
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
if (albums.isNotEmpty)
|
|
HorizontalScrollRow(
|
|
title: 'Rediscover',
|
|
children: albums
|
|
.map((a) => AlbumCard(
|
|
album: a,
|
|
onTap: () =>
|
|
context.push('/albums/${a.id}', extra: a),
|
|
))
|
|
.toList(),
|
|
),
|
|
if (artists.isNotEmpty)
|
|
HorizontalScrollRow(
|
|
title: albums.isEmpty ? 'Rediscover' : '',
|
|
height: 168,
|
|
children: artists
|
|
.map((ar) => ArtistCard(
|
|
artist: ar,
|
|
onTap: () =>
|
|
context.push('/artists/${ar.id}', extra: ar),
|
|
))
|
|
.toList(),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class _MostPlayedSection extends StatelessWidget {
|
|
const _MostPlayedSection({required this.tracks});
|
|
final List<TrackRef> tracks;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (tracks.isEmpty) {
|
|
return const _EmptySection(
|
|
title: 'Most played',
|
|
message: 'No plays to draw from. Listen to something.',
|
|
);
|
|
}
|
|
final controller = ScrollController();
|
|
final rows = _chunk(tracks, 25);
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
for (var i = 0; i < rows.length; i++)
|
|
HorizontalScrollRow(
|
|
title: i == 0 ? 'Most played' : '',
|
|
height: 64,
|
|
controller: controller,
|
|
children: [
|
|
for (var j = 0; j < rows[i].length; j++)
|
|
CompactTrackCard(
|
|
track: rows[i][j],
|
|
sectionTracks: tracks,
|
|
index: i * 25 + j,
|
|
),
|
|
],
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class _LastPlayedSection extends StatelessWidget {
|
|
const _LastPlayedSection({required this.artists});
|
|
final List<ArtistRef> artists;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (artists.isEmpty) {
|
|
return const _EmptySection(
|
|
title: 'Last played',
|
|
message: 'No recent plays.',
|
|
);
|
|
}
|
|
return HorizontalScrollRow(
|
|
title: 'Last played',
|
|
height: 168,
|
|
children: artists
|
|
.map((ar) => ArtistCard(
|
|
artist: ar,
|
|
onTap: () =>
|
|
context.push('/artists/${ar.id}', extra: ar),
|
|
))
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptySection extends StatelessWidget {
|
|
const _EmptySection({required this.title, required this.message});
|
|
final String title;
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: fs.display.fontFamily,
|
|
fontSize: 18,
|
|
color: fs.parchment,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(message, style: TextStyle(color: fs.ash)),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
|
/// section titles + card-sized placeholders) so the page feels alive
|
|
/// while /api/home is in flight, instead of a 30-second blank spinner.
|
|
/// Each section drops in independently as data arrives via the
|
|
/// individual providers.
|
|
class _HomeSkeleton extends StatelessWidget {
|
|
const _HomeSkeleton({required this.fs});
|
|
final FabledSwordTheme fs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
physics: const ClampingScrollPhysics(),
|
|
children: [
|
|
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
|
|
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
|
|
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
|
|
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
|
|
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
|
|
const SizedBox(height: 140),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SkeletonSection extends StatelessWidget {
|
|
const _SkeletonSection({
|
|
required this.fs,
|
|
required this.title,
|
|
required this.cardWidth,
|
|
});
|
|
final FabledSwordTheme fs;
|
|
final String title;
|
|
final double cardWidth;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final coverSize = cardWidth - 16;
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: fs.display.fontFamily,
|
|
fontSize: 18,
|
|
color: fs.parchment,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
height: coverSize + 40,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: 6,
|
|
itemBuilder: (_, __) => Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: coverSize,
|
|
height: coverSize,
|
|
decoration: BoxDecoration(
|
|
color: fs.slate,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
|
|
const SizedBox(height: 4),
|
|
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
List<List<T>> _chunk<T>(List<T> items, int size) {
|
|
final out = <List<T>>[];
|
|
for (var i = 0; i < items.length; i += size) {
|
|
out.add(items.sublist(i, i + size > items.length ? items.length : i + size));
|
|
}
|
|
return out;
|
|
}
|