From 3f2822dfc69f402f3c3347bdc27d4e1d1b62b52b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 14:53:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20discover=20screen=20=E2=80=94?= =?UTF-8?q?=20search=20Lidarr=20+=20create=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models/lidarr.dart (LidarrSearchResult + LidarrRequestKind) - api/endpoints/discover.dart (search + createRequest) - discover/discover_screen.dart with TextField, kind toggle (Artists/Albums), results list with in_library / requested pills - /discover route + explore icon on home AppBar (5 actions now: Library, Playlists, Search, Discover, Settings) --- .../lib/api/endpoints/discover.dart | 59 ++++ .../lib/discover/discover_screen.dart | 261 ++++++++++++++++++ flutter_client/lib/library/home_screen.dart | 5 + flutter_client/lib/models/lidarr.dart | 47 ++++ flutter_client/lib/shared/routing.dart | 2 + 5 files changed, 374 insertions(+) create mode 100644 flutter_client/lib/api/endpoints/discover.dart create mode 100644 flutter_client/lib/discover/discover_screen.dart create mode 100644 flutter_client/lib/models/lidarr.dart diff --git a/flutter_client/lib/api/endpoints/discover.dart b/flutter_client/lib/api/endpoints/discover.dart new file mode 100644 index 00000000..3f5cad00 --- /dev/null +++ b/flutter_client/lib/api/endpoints/discover.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; + +import '../../models/lidarr.dart'; + +class DiscoverApi { + DiscoverApi(this._dio); + final Dio _dio; + + /// 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. + Future> search( + String query, + LidarrRequestKind kind, + ) async { + final r = await _dio.get>( + '/api/lidarr/search', + queryParameters: {'q': query, 'kind': kind.wire}, + ); + final raw = r.data ?? const []; + return raw + .map((e) => + LidarrSearchResult.fromJson((e as Map).cast())) + .toList(growable: false); + } + + /// POST /api/requests. Returns the newly-created LidarrRequest body + /// — we don't expose a typed wrapper for that yet on mobile (admin + /// reviews requests; user just kicks them off), so we discard the + /// response and surface success/failure to the caller. + Future createRequest({ + required LidarrRequestKind kind, + required String artistMbid, + required String artistName, + String? albumMbid, + String? albumTitle, + String? trackMbid, + String? trackTitle, + }) async { + final body = { + 'kind': kind.wire, + 'lidarr_artist_mbid': artistMbid, + 'artist_name': artistName, + }; + if (albumMbid != null && albumMbid.isNotEmpty) { + body['lidarr_album_mbid'] = albumMbid; + } + if (trackMbid != null && trackMbid.isNotEmpty) { + body['lidarr_track_mbid'] = trackMbid; + } + if (albumTitle != null && albumTitle.isNotEmpty) { + body['album_title'] = albumTitle; + } + if (trackTitle != null && trackTitle.isNotEmpty) { + body['track_title'] = trackTitle; + } + await _dio.post>('/api/requests', data: body); + } +} diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart new file mode 100644 index 00000000..78345b4e --- /dev/null +++ b/flutter_client/lib/discover/discover_screen.dart @@ -0,0 +1,261 @@ +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/endpoints/discover.dart'; +import '../api/errors.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/lidarr.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; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + 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()!; + try { + final api = await ref.read(_discoverApiProvider.future); + await api.createRequest( + kind: _kind, + 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, + ); + 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 (e) { + if (mounted) { + final code = ApiError.fromDio(e).code; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Request failed: $code'), + backgroundColor: fs.error, + )); + } + } + } + + @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(Icons.arrow_back, color: fs.parchment), + onPressed: () => context.pop(), + ), + title: Text('Discover', style: TextStyle(color: fs.parchment)), + ), + 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(Icons.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 + ? Center( + child: Text( + 'Type to search, then tap Request to send to Lidarr.', + textAlign: TextAlign.center, + style: TextStyle(color: fs.ash), + ), + ) + : 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('No matches.', + style: TextStyle(color: fs.ash)), + ); + } + return ListView.separated( + itemCount: rows.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) => _ResultTile( + row: rows[i], + onRequest: () => _request(rows[i]), + ), + ); + }, + ), + ), + ]), + ); + } +} + +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(Icons.album, color: fs.ash) + : Image.network(row.imageUrl, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Icon(Icons.album, 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)), + ); + } +} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index cc058f0f..ab623d79 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -44,6 +44,11 @@ class HomeScreen extends ConsumerWidget { tooltip: 'Search', onPressed: () => context.push('/search'), ), + IconButton( + icon: Icon(Icons.explore, color: fs.parchment), + tooltip: 'Discover', + onPressed: () => context.push('/discover'), + ), IconButton( icon: Icon(Icons.settings, color: fs.parchment), tooltip: 'Settings', diff --git a/flutter_client/lib/models/lidarr.dart b/flutter_client/lib/models/lidarr.dart new file mode 100644 index 00000000..8a8e7f6f --- /dev/null +++ b/flutter_client/lib/models/lidarr.dart @@ -0,0 +1,47 @@ +/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in +/// `/api/lidarr/search` results. `in_library` and `requested` let the +/// UI gray out rows the user can't act on (already imported / already +/// awaiting review). +class LidarrSearchResult { + const LidarrSearchResult({ + required this.mbid, + required this.name, + required this.secondaryText, + required this.imageUrl, + required this.artistMbid, + required this.albumMbid, + required this.inLibrary, + required this.requested, + }); + + final String mbid; + final String name; + final String secondaryText; + final String imageUrl; + final String artistMbid; + final String albumMbid; + final bool inLibrary; + final bool requested; + + factory LidarrSearchResult.fromJson(Map j) => + LidarrSearchResult( + mbid: j['mbid'] as String? ?? '', + name: j['name'] as String? ?? '', + secondaryText: j['secondary_text'] as String? ?? '', + imageUrl: j['image_url'] as String? ?? '', + artistMbid: j['artist_mbid'] as String? ?? '', + albumMbid: j['album_mbid'] as String? ?? '', + inLibrary: j['in_library'] as bool? ?? false, + requested: j['requested'] as bool? ?? false, + ); +} + +enum LidarrRequestKind { artist, album, track } + +extension LidarrRequestKindStr on LidarrRequestKind { + String get wire => switch (this) { + LidarrRequestKind.artist => 'artist', + LidarrRequestKind.album => 'album', + LidarrRequestKind.track => 'track', + }; +} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 8becfdb7..1c30c297 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -7,6 +7,7 @@ import '../auth/login_screen.dart'; import '../auth/server_url_screen.dart'; import '../library/album_detail_screen.dart'; import '../library/artist_detail_screen.dart'; +import '../discover/discover_screen.dart'; import '../library/home_screen.dart'; import '../library/library_screen.dart'; import '../player/now_playing_screen.dart'; @@ -60,6 +61,7 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), + GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()), GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), GoRoute(