import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; 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'; final _discoverApiProvider = FutureProvider((ref) async { return DiscoverApi(await ref.watch(dioProvider.future)); }); class DiscoverScreen extends ConsumerStatefulWidget { const DiscoverScreen({super.key}); @override ConsumerState createState() => _DiscoverScreenState(); } 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() { _ctrl.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 _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) { setState(() => _resultsFuture = null); return; } setState(() { _resultsFuture = ref .read(_discoverApiProvider.future) .then((api) => api.search(q, _kind)); }); } Future _request(LidarrSearchResult row) async { final fs = Theme.of(context).extension()!; final args = { 'kind': _kind.wire, 'artistMbid': _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, 'artistName': _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, 'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null, 'albumTitle': _kind == LidarrRequestKind.album ? row.name : null, }; try { final api = await ref.read(_discoverApiProvider.future); await api.createRequest( kind: _kind, artistMbid: args['artistMbid'] as String, artistName: args['artistName'] as String, albumMbid: args['albumMbid'], albumTitle: args['albumTitle'], ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Requested: ${row.name}'), backgroundColor: fs.iron, )); // Re-run search to refresh the `requested` flag on the row. _runSearch(); } } on DioException catch (_) { // Queue for replay so the user's request persists across // network loss. We don't have a drift table for in-flight // requests yet, so the row won't show on the Requests screen // until replay succeeds — accept that for v1. await ref .read(mutationQueueProvider) .enqueue(MutationKinds.requestCreate, args); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Request queued: ${row.name}'), backgroundColor: fs.iron, )); } } } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Scaffold( backgroundColor: fs.obsidian, appBar: AppBar( backgroundColor: fs.obsidian, elevation: 0, leading: IconButton( icon: Icon(LucideIcons.arrow_left, color: fs.parchment), onPressed: () => context.pop(), ), title: Text('Discover', style: TextStyle(color: fs.parchment)), actions: const [MainAppBarActions(currentRoute: '/discover')], ), body: Column(children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row(children: [ Expanded( child: TextField( controller: _ctrl, style: TextStyle(color: fs.parchment), cursorColor: fs.accent, onSubmitted: (_) => _runSearch(), decoration: InputDecoration( hintText: 'Search Lidarr for new music', hintStyle: TextStyle(color: fs.ash), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.iron), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.accent), ), suffixIcon: IconButton( icon: Icon(LucideIcons.search, color: fs.ash), onPressed: _runSearch, ), ), ), ), ]), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: SegmentedButton( segments: const [ ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')), ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')), ], selected: {_kind}, onSelectionChanged: (s) { setState(() { _kind = s.first; if (_ctrl.text.trim().isNotEmpty) _runSearch(); }); }, ), ), Expanded( child: _resultsFuture == null ? _buildSuggestions(fs) : FutureBuilder>( future: _resultsFuture, builder: (ctx, snap) { if (snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { final err = snap.error; final code = err is DioException ? ApiError.fromDio(err).code : 'unknown'; return Center( child: Text('Search failed: $code', style: TextStyle(color: fs.error)), ); } final rows = snap.data ?? const []; if (rows.isEmpty) { return Center( child: Text('Nothing to add for that search yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center), ); } return ListView.separated( itemCount: rows.length, separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), itemBuilder: (ctx, i) => _ResultTile( row: rows[i], onRequest: () => _request(rows[i]), ), ); }, ), ), ]), ); } 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 { const _ResultTile({required this.row, required this.onRequest}); final LidarrSearchResult row; final VoidCallback onRequest; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final disabled = row.inLibrary || row.requested; 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: row.imageUrl.isEmpty ? Icon(LucideIcons.disc_3, color: fs.ash) : CachedNetworkImage( imageUrl: row.imageUrl, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 120), fadeOutDuration: Duration.zero, errorWidget: (_, __, ___) => Icon(LucideIcons.disc_3, color: fs.ash), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(row.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.parchment, fontSize: 14)), if (row.secondaryText.isNotEmpty) Text(row.secondaryText, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 12)), ], ), ), const SizedBox(width: 8), if (row.inLibrary) _Pill(label: 'In library', color: fs.ash) else if (row.requested) _Pill(label: 'Requested', color: fs.ash) else FilledButton( onPressed: disabled ? null : onRequest, style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), child: const Text('Request'), ), ]), ); } } class _Pill extends StatelessWidget { const _Pill({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: fs.iron, borderRadius: BorderRadius.circular(4), ), child: Text(label, style: TextStyle(color: color, fontSize: 11)), ); } } 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(LucideIcons.user, color: fs.ash) : CachedNetworkImage( imageUrl: s.imageUrl, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 120), fadeOutDuration: Duration.zero, errorWidget: (_, __, ___) => Icon(LucideIcons.user, 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'), ), ]), ); } }