feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.
- ArtistSuggestion/SeedContribution model mirroring web types, with
attributionText() matching web's "Because you liked/played X[, Y, and
Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
(image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
name + attribution + Request, reusing the existing createRequest +
mutation-queue-replay flow with optimistic hide); typing → Lidarr
search replaces; clearing → suggestions return. Mirrors web exactly.
Flutter-only; server endpoint unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,25 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/artist_suggestion.dart';
|
||||||
import '../../models/lidarr.dart';
|
import '../../models/lidarr.dart';
|
||||||
|
|
||||||
class DiscoverApi {
|
class DiscoverApi {
|
||||||
DiscoverApi(this._dio);
|
DiscoverApi(this._dio);
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/discover/suggestions — out-of-library artist suggestions
|
||||||
|
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
|
||||||
|
/// may be empty). The server already filters in-library and
|
||||||
|
/// non-terminal-request candidates.
|
||||||
|
Future<List<ArtistSuggestion>> listSuggestions() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) =>
|
||||||
|
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
||||||
/// 60s LRU cache for repeat queries so re-typing the same string in
|
/// 60s LRU cache for repeat queries so re-typing the same string in
|
||||||
/// quick succession is cheap.
|
/// quick succession is cheap.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../api/endpoints/discover.dart';
|
|||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
import '../cache/mutation_queue.dart';
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../models/artist_suggestion.dart';
|
||||||
import '../models/lidarr.dart';
|
import '../models/lidarr.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
@@ -27,6 +28,22 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
final _ctrl = TextEditingController();
|
final _ctrl = TextEditingController();
|
||||||
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
||||||
Future<List<LidarrSearchResult>>? _resultsFuture;
|
Future<List<LidarrSearchResult>>? _resultsFuture;
|
||||||
|
// Default (empty-search) surface: LB-derived out-of-library artist
|
||||||
|
// suggestions, mirroring web's SuggestionFeed.
|
||||||
|
Future<List<ArtistSuggestion>>? _suggestionsFuture;
|
||||||
|
final _requested = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSuggestions();
|
||||||
|
// Clearing the box returns to suggestions (web swaps live too).
|
||||||
|
_ctrl.addListener(() {
|
||||||
|
if (_ctrl.text.trim().isEmpty && _resultsFuture != null) {
|
||||||
|
setState(() => _resultsFuture = null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -34,6 +51,55 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assigns the future only; callers trigger the rebuild (initState
|
||||||
|
// runs before first build, so setState here would be a no-op/warn).
|
||||||
|
void _loadSuggestions() {
|
||||||
|
_suggestionsFuture = ref
|
||||||
|
.read(_discoverApiProvider.future)
|
||||||
|
.then((api) => api.listSuggestions());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestSuggestion(ArtistSuggestion s) async {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final args = {
|
||||||
|
'kind': LidarrRequestKind.artist.wire,
|
||||||
|
'artistMbid': s.mbid,
|
||||||
|
'artistName': s.name,
|
||||||
|
'albumMbid': null,
|
||||||
|
'albumTitle': null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_discoverApiProvider.future);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: LidarrRequestKind.artist,
|
||||||
|
artistMbid: s.mbid,
|
||||||
|
artistName: s.name,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
// Reassign the future first; the setState below rebuilds and
|
||||||
|
// the FutureBuilder picks up the fresh fetch (server now
|
||||||
|
// filters this candidate out). _requested hides it meanwhile.
|
||||||
|
_loadSuggestions();
|
||||||
|
setState(() => _requested.add(s.mbid));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Requested: ${s.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} on DioException catch (_) {
|
||||||
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCreate, args);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _requested.add(s.mbid));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Request queued: ${s.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _runSearch() {
|
void _runSearch() {
|
||||||
final q = _ctrl.text.trim();
|
final q = _ctrl.text.trim();
|
||||||
if (q.isEmpty) {
|
if (q.isEmpty) {
|
||||||
@@ -153,13 +219,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _resultsFuture == null
|
child: _resultsFuture == null
|
||||||
? Center(
|
? _buildSuggestions(fs)
|
||||||
child: Text(
|
|
||||||
'Type to search, then tap Request to send to Lidarr.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: fs.ash),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: FutureBuilder<List<LidarrSearchResult>>(
|
: FutureBuilder<List<LidarrSearchResult>>(
|
||||||
future: _resultsFuture,
|
future: _resultsFuture,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
@@ -199,6 +259,62 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSuggestions(FabledSwordTheme fs) {
|
||||||
|
return FutureBuilder<List<ArtistSuggestion>>(
|
||||||
|
future: _suggestionsFuture,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final items = (snap.data ?? const <ArtistSuggestion>[])
|
||||||
|
.where((s) => !_requested.contains(s.mbid))
|
||||||
|
.toList(growable: false);
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Suggested for you',
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
"Out-of-library artists drawn from what you've liked and played.",
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (snap.hasError)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text("Couldn't load suggestions.",
|
||||||
|
style: TextStyle(color: fs.ash)),
|
||||||
|
)
|
||||||
|
else if (items.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Listen to something or like an artist to start getting suggestions.',
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...items.map((s) => _SuggestionTile(
|
||||||
|
s: s,
|
||||||
|
onRequest: () => _requestSuggestion(s),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ResultTile extends StatelessWidget {
|
class _ResultTile extends StatelessWidget {
|
||||||
@@ -284,3 +400,63 @@ class _Pill extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SuggestionTile extends StatelessWidget {
|
||||||
|
const _SuggestionTile({required this.s, required this.onRequest});
|
||||||
|
final ArtistSuggestion s;
|
||||||
|
final VoidCallback onRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: fs.slate,
|
||||||
|
child: s.imageUrl.isEmpty
|
||||||
|
? Icon(Icons.person, color: fs.ash)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: s.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(Icons.person, color: fs.ash),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(s.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||||
|
if (s.attributionText.isNotEmpty)
|
||||||
|
Text(s.attributionText,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onRequest,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: fs.accent,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
child: const Text('Request'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution —
|
||||||
|
/// one out-of-library artist from GET /api/discover/suggestions. image_url
|
||||||
|
/// is resolved on-demand from Lidarr server-side (may be empty).
|
||||||
|
class SeedContribution {
|
||||||
|
const SeedContribution({required this.name, required this.isLiked});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final bool isLiked;
|
||||||
|
|
||||||
|
factory SeedContribution.fromJson(Map<String, dynamic> j) => SeedContribution(
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
isLiked: j['is_liked'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArtistSuggestion {
|
||||||
|
const ArtistSuggestion({
|
||||||
|
required this.mbid,
|
||||||
|
required this.name,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.attribution,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String mbid;
|
||||||
|
final String name;
|
||||||
|
final String imageUrl;
|
||||||
|
final List<SeedContribution> attribution;
|
||||||
|
|
||||||
|
factory ArtistSuggestion.fromJson(Map<String, dynamic> j) => ArtistSuggestion(
|
||||||
|
mbid: j['mbid'] as String? ?? '',
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
imageUrl: j['image_url'] as String? ?? '',
|
||||||
|
attribution: ((j['attribution'] as List?) ?? const [])
|
||||||
|
.map((e) =>
|
||||||
|
SeedContribution.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3).
|
||||||
|
String get attributionText {
|
||||||
|
if (attribution.isEmpty) return '';
|
||||||
|
final phrases = attribution
|
||||||
|
.map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}')
|
||||||
|
.toList(growable: false);
|
||||||
|
if (phrases.length == 1) return 'Because you ${phrases[0]}.';
|
||||||
|
if (phrases.length == 2) {
|
||||||
|
return 'Because you ${phrases[0]} and ${phrases[1]}.';
|
||||||
|
}
|
||||||
|
return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user