diff --git a/flutter_client/lib/api/endpoints/discover.dart b/flutter_client/lib/api/endpoints/discover.dart index 3f5cad00..1a6d2f4f 100644 --- a/flutter_client/lib/api/endpoints/discover.dart +++ b/flutter_client/lib/api/endpoints/discover.dart @@ -1,11 +1,25 @@ import 'package:dio/dio.dart'; +import '../../models/artist_suggestion.dart'; import '../../models/lidarr.dart'; class DiscoverApi { DiscoverApi(this._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> listSuggestions() async { + final r = await _dio.get>('/api/discover/suggestions'); + final raw = r.data ?? const []; + return raw + .map((e) => + ArtistSuggestion.fromJson((e as Map).cast())) + .toList(growable: false); + } + /// 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 /// quick succession is cheap. diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index 6699ab8f..9519d109 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -8,6 +8,7 @@ import '../api/endpoints/discover.dart'; import '../api/errors.dart'; import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; +import '../models/artist_suggestion.dart'; import '../models/lidarr.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; @@ -27,6 +28,22 @@ class _DiscoverScreenState extends ConsumerState { final _ctrl = TextEditingController(); LidarrRequestKind _kind = LidarrRequestKind.artist; Future>? _resultsFuture; + // Default (empty-search) surface: LB-derived out-of-library artist + // suggestions, mirroring web's SuggestionFeed. + Future>? _suggestionsFuture; + final _requested = {}; + + @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 void dispose() { @@ -34,6 +51,55 @@ class _DiscoverScreenState extends ConsumerState { 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 _requestSuggestion(ArtistSuggestion s) async { + final fs = Theme.of(context).extension()!; + 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() { final q = _ctrl.text.trim(); if (q.isEmpty) { @@ -153,13 +219,7 @@ class _DiscoverScreenState extends ConsumerState { ), Expanded( child: _resultsFuture == null - ? Center( - child: Text( - 'Type to search, then tap Request to send to Lidarr.', - textAlign: TextAlign.center, - style: TextStyle(color: fs.ash), - ), - ) + ? _buildSuggestions(fs) : FutureBuilder>( future: _resultsFuture, builder: (ctx, snap) { @@ -199,6 +259,62 @@ class _DiscoverScreenState extends ConsumerState { ]), ); } + + Widget _buildSuggestions(FabledSwordTheme fs) { + return FutureBuilder>( + future: _suggestionsFuture, + builder: (ctx, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final items = (snap.data ?? const []) + .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 { @@ -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()!; + 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'), + ), + ]), + ); + } +} diff --git a/flutter_client/lib/models/artist_suggestion.dart b/flutter_client/lib/models/artist_suggestion.dart new file mode 100644 index 00000000..a636a4b7 --- /dev/null +++ b/flutter_client/lib/models/artist_suggestion.dart @@ -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 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 attribution; + + factory ArtistSuggestion.fromJson(Map 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())) + .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]}.'; + } +}