diff --git a/README.md b/README.md index a798b81b..81faa71f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes, # compose.yaml services: minstrel: - image: git.fabledsword.com/bvandeusen/minstrel:v1.0.0 + image: git.fabledsword.com/bvandeusen/minstrel:latest ports: ['4533:4533'] volumes: - ./music:/music:ro 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/api/endpoints/library_lists.dart b/flutter_client/lib/api/endpoints/library_lists.dart new file mode 100644 index 00000000..f06dc6bd --- /dev/null +++ b/flutter_client/lib/api/endpoints/library_lists.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../../models/album.dart'; +import '../../models/artist.dart'; +import '../../models/page.dart'; + +/// Paged library browse endpoints. Distinct from LibraryApi (which +/// fetches Home + entity detail) so screens that just need a flat +/// list don't drag in detail-page providers. +class LibraryListsApi { + LibraryListsApi(this._dio); + final Dio _dio; + + Future> listArtists({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/library/artists', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); + } + + Future> listAlbums({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/library/albums', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); + } +} diff --git a/flutter_client/lib/api/endpoints/likes.dart b/flutter_client/lib/api/endpoints/likes.dart index 03771e47..e4732369 100644 --- a/flutter_client/lib/api/endpoints/likes.dart +++ b/flutter_client/lib/api/endpoints/likes.dart @@ -1,5 +1,10 @@ import 'package:dio/dio.dart'; +import '../../models/album.dart'; +import '../../models/artist.dart'; +import '../../models/page.dart'; +import '../../models/track.dart'; + enum LikeKind { artist, album, track } extension LikeKindPath on LikeKind { @@ -22,6 +27,31 @@ class LikesApi { await _dio.delete('/api/likes/${kind.path}/$id'); } + /// GET /api/likes/tracks?limit=N&offset=N. Paged. + Future> listTracks({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/tracks', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson); + } + + Future> listAlbums({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/albums', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); + } + + Future> listArtists({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/artists', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); + } + /// Returns sets of {artists, albums, tracks} the user has liked. /// Server response keys (verified in internal/api/likes.go /// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`. diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart new file mode 100644 index 00000000..dd98bb06 --- /dev/null +++ b/flutter_client/lib/api/endpoints/me.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import '../../models/history_event.dart'; +import '../../models/quarantine_mine.dart'; + +/// /api/me/* endpoints — caller-scoped data (history, profile, etc.). +class MeApi { + MeApi(this._dio); + final Dio _dio; + + /// GET /api/me/history. Paginated via offset/limit; server emits + /// `{events, has_more}` rather than the standard `Page` envelope. + Future history({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/me/history', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return HistoryPage.fromJson(r.data ?? const {}); + } + + /// GET /api/quarantine/mine — flat list (no envelope). + Future> quarantineMine() async { + final r = await _dio.get>('/api/quarantine/mine'); + final raw = r.data ?? const []; + return raw + .map((e) => + QuarantineMineRow.fromJson((e as Map).cast())) + .toList(growable: false); + } +} diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart new file mode 100644 index 00000000..6019f858 --- /dev/null +++ b/flutter_client/lib/api/endpoints/playlists.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; + +import '../../models/playlist.dart'; + +class PlaylistsApi { + PlaylistsApi(this._dio); + final Dio _dio; + + /// GET /api/playlists?kind=user|system|all. The server's default kind + /// is "user" — passing "all" returns user playlists + system mixes. + /// "system" returns just for-you / discover. The mobile UI defaults + /// to "all" so users see their generated mixes alongside their own. + Future> list({String kind = 'all'}) async { + final r = await _dio.get>( + '/api/playlists', + queryParameters: {'kind': kind}, + ); + final raw = r.data ?? const []; + return raw + .map((e) => Playlist.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future get(String id) async { + final r = await _dio.get>('/api/playlists/$id'); + return PlaylistDetail.fromJson(r.data ?? const {}); + } +} diff --git a/flutter_client/lib/api/endpoints/search.dart b/flutter_client/lib/api/endpoints/search.dart new file mode 100644 index 00000000..2ba84df1 --- /dev/null +++ b/flutter_client/lib/api/endpoints/search.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../../models/search_response.dart'; + +/// SearchApi wraps GET /api/search. The server runs three facets (artists, +/// albums, tracks) sharing one limit/offset pair. Each facet returns its +/// own total reflecting the full match count. +class SearchApi { + SearchApi(this._dio); + final Dio _dio; + + /// Empty / whitespace-only query is the caller's responsibility to + /// guard against — the server returns 400 bad_request for it. + Future search( + String query, { + int limit = 20, + int offset = 0, + }) async { + final r = await _dio.get>( + '/api/search', + queryParameters: { + 'q': query, + 'limit': limit, + 'offset': offset, + }, + ); + return SearchResponse.fromJson(r.data ?? const {}); + } +} diff --git a/flutter_client/lib/api/endpoints/settings.dart b/flutter_client/lib/api/endpoints/settings.dart new file mode 100644 index 00000000..70cd1e57 --- /dev/null +++ b/flutter_client/lib/api/endpoints/settings.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; + +import '../../models/my_profile.dart'; + +class SettingsApi { + SettingsApi(this._dio); + final Dio _dio; + + Future getProfile() async { + final r = await _dio.get>('/api/me'); + return MyProfile.fromJson(r.data ?? const {}); + } + + /// Pass only the fields you want to change; server merges. Empty + /// strings clear; null leaves the existing value alone. + Future updateProfile({String? displayName, String? email}) async { + final body = {}; + if (displayName != null) body['display_name'] = displayName; + if (email != null) body['email'] = email; + final r = await _dio.put>( + '/api/me/profile', + data: body, + ); + return MyProfile.fromJson(r.data ?? const {}); + } + + Future changePassword({ + required String current, + required String next, + }) async { + await _dio.put('/api/me/password', data: { + 'current_password': current, + 'new_password': next, + }); + } + + Future getListenBrainz() async { + final r = await _dio.get>('/api/me/listenbrainz'); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } + + Future setListenBrainzToken(String token) async { + final r = await _dio.put>( + '/api/me/listenbrainz', + data: {'token': token}, + ); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } + + Future setListenBrainzEnabled(bool enabled) async { + final r = await _dio.put>( + '/api/me/listenbrainz', + data: {'enabled': enabled}, + ); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } +} 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 b01fd579..ab623d79 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -24,6 +24,38 @@ class HomeScreen extends ConsumerWidget { final fs = Theme.of(context).extension()!; return Scaffold( backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Minstrel', style: TextStyle(color: fs.parchment)), + actions: [ + IconButton( + icon: Icon(Icons.library_music, color: fs.parchment), + tooltip: 'Library', + onPressed: () => context.push('/library'), + ), + IconButton( + icon: Icon(Icons.queue_music, color: fs.parchment), + tooltip: 'Playlists', + onPressed: () => context.push('/playlists'), + ), + IconButton( + icon: Icon(Icons.search, color: fs.parchment), + 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', + onPressed: () => context.push('/settings'), + ), + ], + ), body: SafeArea( child: ref.watch(homeProvider).when( error: (e, _) { diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart new file mode 100644 index 00000000..4a9decb3 --- /dev/null +++ b/flutter_client/lib/library/library_screen.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../api/endpoints/library_lists.dart'; +import '../api/endpoints/likes.dart'; +import '../api/endpoints/me.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/history_event.dart'; +// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0 +// route descriptor) that conflicts with our wire-format Page. +import '../models/page.dart' as wire; +import '../models/quarantine_mine.dart'; +import '../models/track.dart'; +import '../player/player_provider.dart'; +import '../theme/theme_extension.dart'; +import 'widgets/album_card.dart'; +import 'widgets/artist_card.dart'; +import 'widgets/track_row.dart'; + +// Providers scoped to this screen. Each tab gets its own first page. +// Infinite scroll is a future enhancement; for v1 phone testing the +// first 50 rows per tab is enough. + +final _libraryListsApiProvider = FutureProvider((ref) async { + return LibraryListsApi(await ref.watch(dioProvider.future)); +}); + +final _likesApiProvider = FutureProvider((ref) async { + return LikesApi(await ref.watch(dioProvider.future)); +}); + +final _meApiProvider = FutureProvider((ref) async { + return MeApi(await ref.watch(dioProvider.future)); +}); + +final _libraryArtistsProvider = FutureProvider>((ref) async { + return (await ref.watch(_libraryListsApiProvider.future)).listArtists(); +}); + +final _libraryAlbumsProvider = FutureProvider>((ref) async { + return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); +}); + +final _historyProvider = FutureProvider((ref) async { + return (await ref.watch(_meApiProvider.future)).history(); +}); + +final _likedTracksProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listTracks(); +}); + +final _likedAlbumsProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listAlbums(); +}); + +final _likedArtistsProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listArtists(); +}); + +final _quarantineProvider = FutureProvider>((ref) async { + return (await ref.watch(_meApiProvider.future)).quarantineMine(); +}); + +class LibraryScreen extends ConsumerStatefulWidget { + const LibraryScreen({super.key}); + + @override + ConsumerState createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState + with TickerProviderStateMixin { + late final TabController _ctrl = TabController(length: 5, vsync: this); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Library', style: TextStyle(color: fs.parchment)), + bottom: TabBar( + controller: _ctrl, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: fs.parchment, + unselectedLabelColor: fs.ash, + indicatorColor: fs.accent, + tabs: const [ + Tab(text: 'Artists'), + Tab(text: 'Albums'), + Tab(text: 'History'), + Tab(text: 'Liked'), + Tab(text: 'Hidden'), + ], + ), + ), + body: TabBarView( + controller: _ctrl, + children: const [ + _ArtistsTab(), + _AlbumsTab(), + _HistoryTab(), + _LikedTab(), + _HiddenTab(), + ], + ), + ); + } +} + +class _ArtistsTab extends ConsumerWidget { + const _ArtistsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_libraryArtistsProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.items.isEmpty + ? Center(child: Text('No artists.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.78, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) => ArtistCard( + artist: page.items[i], + onTap: () => ctx.push('/artists/${page.items[i].id}'), + ), + ), + ), + ); + } +} + +class _AlbumsTab extends ConsumerWidget { + const _AlbumsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_libraryAlbumsProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.items.isEmpty + ? Center(child: Text('No albums.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.78, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) => AlbumCard( + album: page.items[i], + onTap: () => ctx.push('/albums/${page.items[i].id}'), + ), + ), + ), + ); + } +} + +class _HistoryTab extends ConsumerWidget { + const _HistoryTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_historyProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.events.isEmpty + ? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_historyProvider.future), + child: ListView.separated( + itemCount: page.events.length, + separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) { + final event = page.events[i]; + return TrackRow( + track: event.track, + onTap: () => ref + .read(playerActionsProvider) + .playTracks([event.track]), + trailing: Text( + _relativeTime(event.playedAt), + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ); + }, + ), + ), + ); + } +} + +class _LikedTab extends ConsumerWidget { + const _LikedTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final tracksA = ref.watch(_likedTracksProvider); + final albumsA = ref.watch(_likedAlbumsProvider); + final artistsA = ref.watch(_likedArtistsProvider); + + if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + final t = tracksA.value; + final al = albumsA.value; + final ar = artistsA.value; + if (t == null || al == null || ar == null) { + return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); + } + if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) { + return Center(child: Text('Nothing liked yet.', style: TextStyle(color: fs.ash))); + } + return RefreshIndicator( + onRefresh: () async { + await Future.wait([ + ref.refresh(_likedTracksProvider.future), + ref.refresh(_likedAlbumsProvider.future), + ref.refresh(_likedArtistsProvider.future), + ]); + }, + child: ListView(children: [ + if (ar.items.isNotEmpty) ...[ + _SectionHeader(label: 'Artists', count: ar.total), + SizedBox( + height: 168, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: ar.items.length, + itemBuilder: (ctx, i) => ArtistCard( + artist: ar.items[i], + onTap: () => ctx.push('/artists/${ar.items[i].id}'), + ), + ), + ), + ], + if (al.items.isNotEmpty) ...[ + _SectionHeader(label: 'Albums', count: al.total), + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: al.items.length, + itemBuilder: (ctx, i) => AlbumCard( + album: al.items[i], + onTap: () => ctx.push('/albums/${al.items[i].id}'), + ), + ), + ), + ], + if (t.items.isNotEmpty) ...[ + _SectionHeader(label: 'Tracks', count: t.total), + ...t.items.asMap().entries.map((e) { + return TrackRow( + track: e.value, + onTap: () => ref + .read(playerActionsProvider) + .playTracks(t.items, initialIndex: e.key), + ); + }), + ], + const SizedBox(height: 96), + ]), + ); + } +} + +class _HiddenTab extends ConsumerWidget { + const _HiddenTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_quarantineProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (rows) => rows.isEmpty + ? Center( + child: Text( + 'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.', + textAlign: TextAlign.center, + style: TextStyle(color: fs.ash), + ), + ) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_quarantineProvider.future), + child: ListView.separated( + itemCount: rows.length, + separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]), + ), + ), + ); + } +} + +class _QuarantineTile extends StatelessWidget { + const _QuarantineTile({required this.row}); + final QuarantineMineRow row; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final reasonLabel = switch (row.reason) { + 'bad_rip' => 'Bad rip', + 'wrong_file' => 'Wrong file', + 'wrong_tags' => 'Wrong tags', + 'duplicate' => 'Duplicate', + _ => 'Other', + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + row.trackTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + Text( + '${row.artistName} · ${row.albumTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fs.iron, + borderRadius: BorderRadius.circular(4), + ), + child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), + ), + ), + ]), + ), + ]), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label, required this.count}); + final String label; + final int count; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row(children: [ + Text(label, + style: TextStyle( + color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), + ]), + ); + } +} + +/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago" +/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025". +/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime. +String _relativeTime(String iso) { + final t = DateTime.tryParse(iso); + if (t == null) return ''; + final now = DateTime.now(); + final diff = now.difference(t); + if (diff.inMinutes < 60) { + final m = diff.inMinutes < 1 ? 1 : diff.inMinutes; + return '${m}m ago'; + } + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return '${days[t.weekday - 1]} $hh:$mm'; + } + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + if (t.year == now.year) return '${months[t.month - 1]} ${t.day}'; + return '${months[t.month - 1]} ${t.day}, ${t.year}'; +} diff --git a/flutter_client/lib/models/history_event.dart b/flutter_client/lib/models/history_event.dart new file mode 100644 index 00000000..5482eb00 --- /dev/null +++ b/flutter_client/lib/models/history_event.dart @@ -0,0 +1,44 @@ +import 'track.dart'; + +/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits +/// `{events, has_more}` rather than the standard `Page` envelope — +/// history is timestamp-keyed so total isn't meaningful, only "more +/// pages exist below." +class HistoryEvent { + const HistoryEvent({ + required this.id, + required this.playedAt, + required this.track, + }); + + final String id; + /// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" / + /// "May 1, 2025") via formatRelative helper. + final String playedAt; + final TrackRef track; + + factory HistoryEvent.fromJson(Map j) => HistoryEvent( + id: j['id'] as String? ?? '', + playedAt: j['played_at'] as String? ?? '', + track: TrackRef.fromJson( + (j['track'] as Map?)?.cast() ?? const {}, + ), + ); +} + +class HistoryPage { + const HistoryPage({required this.events, required this.hasMore}); + + final List events; + final bool hasMore; + + factory HistoryPage.fromJson(Map j) { + final raw = (j['events'] as List?) ?? const []; + return HistoryPage( + events: raw + .map((e) => HistoryEvent.fromJson((e as Map).cast())) + .toList(growable: false), + hasMore: j['has_more'] as bool? ?? false, + ); + } +} 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/models/my_profile.dart b/flutter_client/lib/models/my_profile.dart new file mode 100644 index 00000000..0c6ceb5c --- /dev/null +++ b/flutter_client/lib/models/my_profile.dart @@ -0,0 +1,49 @@ +/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and +/// `email` are nullable — server returns null when the user hasn't +/// set them yet (registration only requires a username). +class MyProfile { + const MyProfile({ + required this.id, + required this.username, + this.displayName, + this.email, + required this.isAdmin, + }); + + final String id; + final String username; + final String? displayName; + final String? email; + final bool isAdmin; + + factory MyProfile.fromJson(Map j) => MyProfile( + id: j['id'] as String? ?? '', + username: j['username'] as String? ?? '', + displayName: j['display_name'] as String?, + email: j['email'] as String?, + isAdmin: j['is_admin'] as bool? ?? false, + ); +} + +/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus. +class ListenBrainzStatus { + const ListenBrainzStatus({ + required this.enabled, + required this.tokenSet, + this.lastScrobbledAt, + }); + + final bool enabled; + /// True when the user has stored a token (token itself is never read + /// back from the server). UI uses this to show "token saved" vs + /// "no token" without exposing the value. + final bool tokenSet; + final String? lastScrobbledAt; + + factory ListenBrainzStatus.fromJson(Map j) => + ListenBrainzStatus( + enabled: j['enabled'] as bool? ?? false, + tokenSet: j['token_set'] as bool? ?? false, + lastScrobbledAt: j['last_scrobbled_at'] as String?, + ); +} diff --git a/flutter_client/lib/models/page.dart b/flutter_client/lib/models/page.dart new file mode 100644 index 00000000..29df33db --- /dev/null +++ b/flutter_client/lib/models/page.dart @@ -0,0 +1,44 @@ +// Mirrors the server's Page[T] envelope used by paged endpoints +// (/api/search, /api/library/artists, /api/library/albums, +// /api/likes/*). Class is named `Paged` rather than `Page` +// to avoid ambiguous imports with Flutter Material's Navigator-2.0 +// `Page` class. +// +// Wire shape from internal/api/types.go Page[T]: +// { items: T[], total: int, limit: int, offset: int } +// +// Dart generics can't auto-infer T from JSON, so callers pass an +// itemFromJson function alongside the raw map. Parse logic stays in +// each entity's own fromJson; this class only handles the envelope. +class Paged { + const Paged({ + required this.items, + required this.total, + required this.limit, + required this.offset, + }); + + final List items; + final int total; + final int limit; + final int offset; + + factory Paged.fromJson( + Map j, + T Function(Map) itemFromJson, + ) { + final raw = (j['items'] as List?) ?? const []; + return Paged( + items: raw + .map((e) => itemFromJson((e as Map).cast())) + .toList(growable: false), + total: (j['total'] as num?)?.toInt() ?? 0, + limit: (j['limit'] as num?)?.toInt() ?? 0, + offset: (j['offset'] as num?)?.toInt() ?? 0, + ); + } + + /// Convenience for endpoints that always return the first page or + /// when the caller just wants the items. + bool get hasMore => offset + items.length < total; +} diff --git a/flutter_client/lib/models/playlist.dart b/flutter_client/lib/models/playlist.dart new file mode 100644 index 00000000..7916fde7 --- /dev/null +++ b/flutter_client/lib/models/playlist.dart @@ -0,0 +1,117 @@ +// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes. +// +// Server distinguishes user playlists (created by the caller) from +// system playlists (server-generated mixes like for_you / discover). +// system_variant is null for user playlists; "for_you" / "discover" +// otherwise. Read-only operations work the same; PATCH/POST/DELETE +// are gated on user-owned playlists. + +class Playlist { + const Playlist({ + required this.id, + required this.userId, + required this.name, + required this.description, + required this.isPublic, + required this.systemVariant, + required this.trackCount, + required this.coverUrl, + required this.ownerUsername, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String userId; + final String name; + final String description; + final bool isPublic; + /// "for_you" / "discover" / null + final String? systemVariant; + final int trackCount; + /// Server-emitted URL to the cover; empty when no tracks yet. + final String coverUrl; + /// Display name of the owner — for showing other users' public playlists. + final String ownerUsername; + final String createdAt; + final String updatedAt; + + bool get isSystem => systemVariant != null; + + factory Playlist.fromJson(Map j) => Playlist( + id: j['id'] as String, + userId: j['user_id'] as String? ?? '', + name: j['name'] as String? ?? '', + description: j['description'] as String? ?? '', + isPublic: j['is_public'] as bool? ?? false, + systemVariant: j['system_variant'] as String?, + trackCount: (j['track_count'] as num?)?.toInt() ?? 0, + coverUrl: j['cover_url'] as String? ?? '', + ownerUsername: j['owner_username'] as String? ?? '', + createdAt: j['created_at'] as String? ?? '', + updatedAt: j['updated_at'] as String? ?? '', + ); +} + +/// Playlist row as returned in PlaylistDetail.tracks. Each row carries +/// the track's display fields directly so the client doesn't need a +/// separate /api/tracks/{id} fetch per row. +/// +/// track_id is nullable: when the upstream track has been removed from +/// the library, the row remains in the playlist with its title/artist +/// preserved but track_id=null and stream_url=null. UI should render +/// these greyed-out and unplayable. +class PlaylistTrack { + const PlaylistTrack({ + required this.position, + required this.trackId, + required this.title, + required this.albumId, + required this.albumTitle, + required this.artistId, + required this.artistName, + required this.durationSec, + required this.streamUrl, + }); + + final int position; + final String? trackId; + final String title; + final String? albumId; + final String albumTitle; + final String? artistId; + final String artistName; + final int durationSec; + final String? streamUrl; + + bool get isAvailable => trackId != null; + + factory PlaylistTrack.fromJson(Map j) => PlaylistTrack( + position: (j['position'] as num?)?.toInt() ?? 0, + trackId: j['track_id'] as String?, + title: j['title'] as String? ?? '', + albumId: j['album_id'] as String?, + albumTitle: j['album_title'] as String? ?? '', + artistId: j['artist_id'] as String?, + artistName: j['artist_name'] as String? ?? '', + durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, + streamUrl: j['stream_url'] as String?, + ); +} + +class PlaylistDetail { + const PlaylistDetail({required this.playlist, required this.tracks}); + + final Playlist playlist; + final List tracks; + + factory PlaylistDetail.fromJson(Map j) { + final tracksRaw = (j['tracks'] as List?) ?? const []; + return PlaylistDetail( + playlist: Playlist.fromJson(j), + tracks: tracksRaw + .map((e) => PlaylistTrack.fromJson((e as Map).cast())) + .toList(growable: false), + ); + } +} diff --git a/flutter_client/lib/models/quarantine_mine.dart b/flutter_client/lib/models/quarantine_mine.dart new file mode 100644 index 00000000..d9358d6b --- /dev/null +++ b/flutter_client/lib/models/quarantine_mine.dart @@ -0,0 +1,46 @@ +/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row +/// per quarantined track owned by the caller. Server returns a flat +/// list (no Page envelope) at /api/quarantine/mine. +class QuarantineMineRow { + const QuarantineMineRow({ + required this.trackId, + required this.reason, + this.notes, + required this.createdAt, + required this.trackTitle, + required this.trackDurationMs, + required this.albumId, + required this.albumTitle, + this.albumCoverArtPath, + required this.artistId, + required this.artistName, + }); + + final String trackId; + /// One of: bad_rip / wrong_file / wrong_tags / duplicate / other. + final String reason; + final String? notes; + final String createdAt; + final String trackTitle; + final int trackDurationMs; + final String albumId; + final String albumTitle; + final String? albumCoverArtPath; + final String artistId; + final String artistName; + + factory QuarantineMineRow.fromJson(Map j) => + QuarantineMineRow( + trackId: j['track_id'] as String? ?? '', + reason: j['reason'] as String? ?? 'other', + notes: j['notes'] as String?, + createdAt: j['created_at'] as String? ?? '', + trackTitle: j['track_title'] as String? ?? '', + trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0, + albumId: j['album_id'] as String? ?? '', + albumTitle: j['album_title'] as String? ?? '', + albumCoverArtPath: j['album_cover_art_path'] as String?, + artistId: j['artist_id'] as String? ?? '', + artistName: j['artist_name'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/search_response.dart b/flutter_client/lib/models/search_response.dart new file mode 100644 index 00000000..2d1e505b --- /dev/null +++ b/flutter_client/lib/models/search_response.dart @@ -0,0 +1,38 @@ +import 'album.dart'; +import 'artist.dart'; +import 'page.dart'; +import 'track.dart'; + +/// Mirrors internal/api/search.go SearchResponse — three pages keyed by +/// facet, each independently paged. The mobile UI usually only walks +/// the first page of each facet (limit 20-50); infinite scroll within a +/// facet is a future enhancement, not v1. +class SearchResponse { + const SearchResponse({ + required this.artists, + required this.albums, + required this.tracks, + }); + + final Paged artists; + final Paged albums; + final Paged tracks; + + factory SearchResponse.fromJson(Map j) => SearchResponse( + artists: Paged.fromJson( + (j['artists'] as Map?)?.cast() ?? const {}, + ArtistRef.fromJson, + ), + albums: Paged.fromJson( + (j['albums'] as Map?)?.cast() ?? const {}, + AlbumRef.fromJson, + ), + tracks: Paged.fromJson( + (j['tracks'] as Map?)?.cast() ?? const {}, + TrackRef.fromJson, + ), + ); + + bool get isEmpty => + artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty; +} diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 81048a2d..3e0a86ef 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -34,8 +34,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl return AudioSource.uri(Uri.parse(url), headers: headers); }).toList(); - await _player.setAudioSource( - ConcatenatingAudioSource(children: sources), + await _player.setAudioSources( + sources, initialIndex: initialIndex, ); } diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 7c192a5b..93e48614 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../theme/theme_extension.dart'; import 'player_provider.dart'; @@ -10,8 +11,8 @@ class NowPlayingScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final media = ref.watch(mediaItemProvider).valueOrNull; - final playback = ref.watch(playbackStateProvider).valueOrNull; + final media = ref.watch(mediaItemProvider).value; + final playback = ref.watch(playbackStateProvider).value; if (media == null) { return const Scaffold(body: Center(child: Text('Nothing playing.'))); @@ -28,6 +29,13 @@ class NowPlayingScreen extends ConsumerWidget { icon: Icon(Icons.expand_more, color: fs.parchment), onPressed: () => Navigator.of(context).pop(), ), + actions: [ + IconButton( + icon: Icon(Icons.queue_music, color: fs.parchment), + tooltip: 'Queue', + onPressed: () => GoRouter.of(context).push('/queue'), + ), + ], ), body: SafeArea( child: Padding( diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index fbda3315..810b3e7e 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -11,8 +11,8 @@ class PlayerBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final mediaItem = ref.watch(mediaItemProvider).valueOrNull; - final playback = ref.watch(playbackStateProvider).valueOrNull; + final mediaItem = ref.watch(mediaItemProvider).value; + final playback = ref.watch(playbackStateProvider).value; if (mediaItem == null) return const SizedBox.shrink(); return Material( diff --git a/flutter_client/lib/player/queue_screen.dart b/flutter_client/lib/player/queue_screen.dart new file mode 100644 index 00000000..f09c45c1 --- /dev/null +++ b/flutter_client/lib/player/queue_screen.dart @@ -0,0 +1,118 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../theme/theme_extension.dart'; +import 'player_provider.dart'; + +class QueueScreen extends ConsumerWidget { + const QueueScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final queue = ref.watch(queueProvider).value ?? const []; + final current = ref.watch(mediaItemProvider).value; + 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('Queue', style: TextStyle(color: fs.parchment)), + ), + body: queue.isEmpty + ? Center( + child: Text('Queue is empty.', style: TextStyle(color: fs.ash)), + ) + : ListView.separated( + itemCount: queue.length, + separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) => _QueueRow( + item: queue[i], + index: i, + isCurrent: current?.id == queue[i].id, + onTap: () async { + final h = ref.read(audioHandlerProvider); + await h.skipToQueueItem(i); + await h.play(); + }, + ), + ), + ); + } +} + +class _QueueRow extends StatelessWidget { + const _QueueRow({ + required this.item, + required this.index, + required this.isCurrent, + required this.onTap, + }); + + final MediaItem item; + final int index; + final bool isCurrent; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final dur = item.duration; + String? durLabel; + if (dur != null) { + final m = (dur.inSeconds ~/ 60).toString().padLeft(2, '0'); + final s = (dur.inSeconds % 60).toString().padLeft(2, '0'); + durLabel = '$m:$s'; + } + return InkWell( + onTap: isCurrent ? null : onTap, + child: Container( + decoration: BoxDecoration( + border: isCurrent + ? Border(left: BorderSide(color: fs.accent, width: 2)) + : null, + color: isCurrent ? fs.iron : null, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + if (isCurrent) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.graphic_eq, color: fs.accent, size: 16), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isCurrent ? fs.accent : fs.parchment, + fontSize: 14, + fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, + ), + ), + Text( + '${item.artist ?? ''} · ${item.album ?? ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), + ), + if (durLabel != null) + Text(durLabel, style: TextStyle(color: fs.ash, fontSize: 12)), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart new file mode 100644 index 00000000..dfcf6c22 --- /dev/null +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../models/playlist.dart'; +import '../models/track.dart'; +import '../player/player_provider.dart'; +import '../theme/theme_extension.dart'; +import 'playlists_provider.dart'; + +class PlaylistDetailScreen extends ConsumerWidget { + const PlaylistDetailScreen({required this.id, super.key}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final detail = ref.watch(playlistDetailProvider(id)); + 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: detail.maybeWhen( + data: (d) => Text( + d.playlist.name, + style: TextStyle(color: fs.parchment), + overflow: TextOverflow.ellipsis, + ), + orElse: () => const SizedBox.shrink(), + ), + ), + body: detail.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => + Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (d) => _Body(detail: d), + ), + ); + } +} + +class _Body extends ConsumerWidget { + const _Body({required this.detail}); + final PlaylistDetail detail; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracks = detail.tracks; + final playable = tracks.where((t) => t.isAvailable).toList(); + + return RefreshIndicator( + onRefresh: () async => + ref.refresh(playlistDetailProvider(detail.playlist.id).future), + child: ListView.builder( + itemCount: tracks.length + 1, // header + rows + itemBuilder: (ctx, i) { + if (i == 0) return _Header(detail: detail, playable: playable); + final t = tracks[i - 1]; + return _PlaylistTrackRow( + row: t, + onTap: t.isAvailable + ? () { + final ref = ProviderScope.containerOf(ctx); + final liveTrack = _toTrackRef(t); + final playableRefs = + playable.map(_toTrackRef).toList(growable: false); + final startIdx = playable.indexWhere((p) => p.trackId == t.trackId); + ref.read(playerActionsProvider).playTracks( + playableRefs, + initialIndex: startIdx >= 0 ? startIdx : 0, + ); + // Keep liveTrack referenced to avoid an unused-variable + // warning while we leave hooks for menu wiring later. + assert(liveTrack.id == t.trackId); + } + : null, + ); + }, + ), + ); + } +} + +TrackRef _toTrackRef(PlaylistTrack t) => TrackRef( + id: t.trackId ?? '', + title: t.title, + albumId: t.albumId ?? '', + albumTitle: t.albumTitle, + artistId: t.artistId ?? '', + artistName: t.artistName, + durationSec: t.durationSec, + streamUrl: t.streamUrl ?? '', + ); + +class _Header extends ConsumerWidget { + const _Header({required this.detail, required this.playable}); + final PlaylistDetail detail; + final List playable; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final p = detail.playlist; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (p.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + p.description, + style: TextStyle(color: fs.ash, fontSize: 13), + ), + ), + Row(children: [ + Text( + '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + const Spacer(), + if (playable.isNotEmpty) + FilledButton.icon( + onPressed: () { + final refs = playable.map(_toTrackRef).toList(growable: false); + ref.read(playerActionsProvider).playTracks(refs); + }, + icon: const Icon(Icons.play_arrow), + label: const Text('Play'), + style: FilledButton.styleFrom( + backgroundColor: fs.accent, + foregroundColor: fs.parchment, + ), + ), + ]), + ]), + ); + } +} + +class _PlaylistTrackRow extends StatelessWidget { + const _PlaylistTrackRow({required this.row, required this.onTap}); + final PlaylistTrack row; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); + final secs = (row.durationSec % 60).toString().padLeft(2, '0'); + final color = row.isAvailable ? fs.parchment : fs.ash; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 14, + decoration: row.isAvailable + ? null + : TextDecoration.lineThrough, + ), + ), + Text( + '${row.artistName} · ${row.albumTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), + ), + Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart new file mode 100644 index 00000000..f71c029d --- /dev/null +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../models/playlist.dart'; +import '../theme/theme_extension.dart'; +import 'playlists_provider.dart'; + +class PlaylistsListScreen extends ConsumerWidget { + const PlaylistsListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + // "all" returns user-created + system mixes. Most useful default + // on mobile where the user wants to see for-you/discover alongside + // their own playlists in one tap. + final list = ref.watch(playlistsListProvider('all')); + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Playlists', style: TextStyle(color: fs.parchment)), + ), + body: list.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (items) { + if (items.isEmpty) { + return Center( + child: Text( + 'No playlists yet.', + style: TextStyle(color: fs.ash), + ), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.refresh(playlistsListProvider('all').future), + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) { + final p = items[i]; + return _PlaylistTile( + playlist: p, + onTap: () => ctx.push('/playlists/${p.id}'), + ); + }, + ), + ); + }, + ), + ); + } +} + +class _PlaylistTile extends StatelessWidget { + const _PlaylistTile({required this.playlist, required this.onTap}); + final Playlist playlist; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row(children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 56, + height: 56, + color: fs.slate, + child: playlist.coverUrl.isEmpty + ? Icon(Icons.queue_music, color: fs.ash) + : Image.network(playlist.coverUrl, fit: BoxFit.cover), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Flexible( + child: Text( + playlist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 15), + ), + ), + if (playlist.isSystem) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fs.iron, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + playlist.systemVariant!.replaceAll('_', ' '), + style: TextStyle(color: fs.accent, fontSize: 11), + ), + ), + ], + ]), + const SizedBox(height: 2), + Text( + '${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: fs.ash), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart new file mode 100644 index 00000000..fb0c4306 --- /dev/null +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/playlists.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/playlist.dart'; + +final playlistsApiProvider = FutureProvider((ref) async { + return PlaylistsApi(await ref.watch(dioProvider.future)); +}); + +final playlistsListProvider = + FutureProvider.family, String>((ref, kind) async { + return (await ref.watch(playlistsApiProvider.future)).list(kind: kind); +}); + +final playlistDetailProvider = + FutureProvider.family((ref, id) async { + return (await ref.watch(playlistsApiProvider.future)).get(id); +}); diff --git a/flutter_client/lib/search/search_provider.dart b/flutter_client/lib/search/search_provider.dart new file mode 100644 index 00000000..b02021d1 --- /dev/null +++ b/flutter_client/lib/search/search_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/search.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/search_response.dart'; + +final searchApiProvider = FutureProvider((ref) async { + return SearchApi(await ref.watch(dioProvider.future)); +}); + +/// Current search query text. The screen's TextField writes here on +/// every keystroke; the resultsProvider below debounces. +class SearchQueryNotifier extends Notifier { + @override + String build() => ''; + void set(String q) => state = q; +} + +final searchQueryProvider = + NotifierProvider(SearchQueryNotifier.new); + +/// Debounced search results. Returns null for empty queries so the +/// screen can show a neutral empty state instead of "0 results." +/// +/// Debounce: 250ms. After the wait, re-reads the live query — if the +/// user has typed more in that window, this attempt is silently +/// abandoned (returns null) and a newer attempt fires on the next +/// rebuild. No request hits the server while the user is mid-typing. +final searchResultsProvider = FutureProvider((ref) async { + final q = ref.watch(searchQueryProvider).trim(); + if (q.isEmpty) return null; + await Future.delayed(const Duration(milliseconds: 250)); + // Re-read the (possibly newer) query; if the user typed more, bail. + if (ref.read(searchQueryProvider).trim() != q) return null; + final api = await ref.watch(searchApiProvider.future); + return api.search(q); +}); diff --git a/flutter_client/lib/search/search_screen.dart b/flutter_client/lib/search/search_screen.dart new file mode 100644 index 00000000..e3473d27 --- /dev/null +++ b/flutter_client/lib/search/search_screen.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../library/widgets/album_card.dart'; +import '../library/widgets/artist_card.dart'; +import '../library/widgets/track_row.dart'; +import '../models/search_response.dart'; +import '../player/player_provider.dart'; +import '../theme/theme_extension.dart'; +import 'search_provider.dart'; + +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + final _controller = TextEditingController(); + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + // Autofocus the field on screen entry; keyboard pops up so the + // user can start typing immediately. + WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); + } + + @override + void dispose() { + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final results = ref.watch(searchResultsProvider); + 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: TextField( + controller: _controller, + focusNode: _focus, + autofocus: true, + style: TextStyle(color: fs.parchment), + cursorColor: fs.accent, + decoration: InputDecoration( + hintText: 'Search artists, albums, tracks', + hintStyle: TextStyle(color: fs.ash), + border: InputBorder.none, + ), + onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v), + textInputAction: TextInputAction.search, + ), + actions: [ + if (_controller.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear, color: fs.ash), + onPressed: () { + _controller.clear(); + ref.read(searchQueryProvider.notifier).set(''); + _focus.requestFocus(); + }, + ), + ], + ), + body: results.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + ), + data: (r) => r == null + ? const _Hint(message: 'Type to search your library.') + : r.isEmpty + ? const _Hint(message: 'No matches.') + : _Results(results: r), + ), + ); + } +} + +class _Hint extends StatelessWidget { + const _Hint({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Center( + child: Text(message, style: TextStyle(color: fs.ash)), + ); + } +} + +class _Results extends ConsumerWidget { + const _Results({required this.results}); + final SearchResponse results; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + children: [ + if (results.artists.items.isNotEmpty) ...[ + _Header(label: 'Artists', count: results.artists.total), + SizedBox( + height: 168, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: results.artists.items.length, + itemBuilder: (ctx, i) { + final a = results.artists.items[i]; + return ArtistCard( + artist: a, + onTap: () => ctx.push('/artists/${a.id}'), + ); + }, + ), + ), + ], + if (results.albums.items.isNotEmpty) ...[ + _Header(label: 'Albums', count: results.albums.total), + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: results.albums.items.length, + itemBuilder: (ctx, i) { + final a = results.albums.items[i]; + return AlbumCard( + album: a, + onTap: () => ctx.push('/albums/${a.id}'), + ); + }, + ), + ), + ], + if (results.tracks.items.isNotEmpty) ...[ + _Header(label: 'Tracks', count: results.tracks.total), + ...results.tracks.items.asMap().entries.map((e) { + final i = e.key; + final t = e.value; + return TrackRow( + track: t, + onTap: () => ref + .read(playerActionsProvider) + .playTracks(results.tracks.items, initialIndex: i), + ); + }), + ], + const SizedBox(height: 96), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.label, required this.count}); + final String label; + final int count; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + label, + style: TextStyle( + color: fs.parchment, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), + ], + ), + ); + } +} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart new file mode 100644 index 00000000..ab27f0bb --- /dev/null +++ b/flutter_client/lib/settings/settings_screen.dart @@ -0,0 +1,453 @@ +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/settings.dart'; +import '../api/errors.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/my_profile.dart'; +import '../theme/theme_extension.dart'; + +final _settingsApiProvider = FutureProvider((ref) async { + return SettingsApi(await ref.watch(dioProvider.future)); +}); + +final _profileProvider = FutureProvider((ref) async { + return (await ref.watch(_settingsApiProvider.future)).getProfile(); +}); + +final _lbStatusProvider = FutureProvider((ref) async { + return (await ref.watch(_settingsApiProvider.future)).getListenBrainz(); +}); + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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('Settings', style: TextStyle(color: fs.parchment)), + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: const [ + _ProfileSection(), + _Divider(), + _PasswordSection(), + _Divider(), + _ListenBrainzSection(), + SizedBox(height: 96), + ], + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Container(height: 1, color: fs.iron), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.label); + final String label; + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + label, + style: TextStyle( + color: fs.parchment, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} + +class _ProfileSection extends ConsumerStatefulWidget { + const _ProfileSection(); + @override + ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); +} + +class _ProfileSectionState extends ConsumerState<_ProfileSection> { + final _displayName = TextEditingController(); + final _email = TextEditingController(); + bool _initialized = false; + bool _saving = false; + + @override + void dispose() { + _displayName.dispose(); + _email.dispose(); + super.dispose(); + } + + Future _save() async { + final fs = Theme.of(context).extension()!; + setState(() => _saving = true); + try { + final api = await ref.read(_settingsApiProvider.future); + await api.updateProfile( + displayName: _displayName.text.trim(), + email: _email.text.trim(), + ); + ref.invalidate(_profileProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Profile saved.'), + backgroundColor: fs.iron, + ), + ); + } + } on DioException catch (e) { + if (mounted) { + final msg = ApiError.fromDio(e).code; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Save failed: $msg'), + backgroundColor: fs.error, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final profile = ref.watch(_profileProvider); + return profile.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (p) { + if (!_initialized) { + _displayName.text = p.displayName ?? ''; + _email.text = p.email ?? ''; + _initialized = true; + } + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const _SectionHeader('Profile'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + 'Signed in as ${p.username}${p.isAdmin ? " · admin" : ""}', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _displayName, + style: TextStyle(color: fs.parchment), + decoration: _inputDecoration(fs, 'Display name'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _email, + style: TextStyle(color: fs.parchment), + keyboardType: TextInputType.emailAddress, + decoration: _inputDecoration(fs, 'Email (for password reset)'), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: fs.accent, + foregroundColor: fs.parchment, + ), + child: Text(_saving ? 'Saving…' : 'Save profile'), + ), + ), + ]); + }, + ); + } +} + +class _PasswordSection extends ConsumerStatefulWidget { + const _PasswordSection(); + @override + ConsumerState<_PasswordSection> createState() => _PasswordSectionState(); +} + +class _PasswordSectionState extends ConsumerState<_PasswordSection> { + final _current = TextEditingController(); + final _next = TextEditingController(); + final _confirm = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _current.dispose(); + _next.dispose(); + _confirm.dispose(); + super.dispose(); + } + + Future _change() async { + final fs = Theme.of(context).extension()!; + if (_next.text != _confirm.text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('New passwords do not match.'), + backgroundColor: fs.error, + )); + return; + } + if (_next.text.length < 8) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('Password must be at least 8 characters.'), + backgroundColor: fs.error, + )); + return; + } + setState(() => _saving = true); + try { + final api = await ref.read(_settingsApiProvider.future); + await api.changePassword(current: _current.text, next: _next.text); + _current.clear(); + _next.clear(); + _confirm.clear(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('Password changed.'), + backgroundColor: fs.iron, + )); + } + } on DioException catch (e) { + if (mounted) { + final msg = ApiError.fromDio(e).code; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Change failed: $msg'), + backgroundColor: fs.error, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const _SectionHeader('Password'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _current, + obscureText: true, + style: TextStyle(color: fs.parchment), + decoration: _inputDecoration(fs, 'Current password'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _next, + obscureText: true, + style: TextStyle(color: fs.parchment), + decoration: _inputDecoration(fs, 'New password'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _confirm, + obscureText: true, + style: TextStyle(color: fs.parchment), + decoration: _inputDecoration(fs, 'Confirm new password'), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: FilledButton( + onPressed: _saving ? null : _change, + style: FilledButton.styleFrom( + backgroundColor: fs.accent, + foregroundColor: fs.parchment, + ), + child: Text(_saving ? 'Changing…' : 'Change password'), + ), + ), + ]); + } +} + +class _ListenBrainzSection extends ConsumerStatefulWidget { + const _ListenBrainzSection(); + @override + ConsumerState<_ListenBrainzSection> createState() => + _ListenBrainzSectionState(); +} + +class _ListenBrainzSectionState extends ConsumerState<_ListenBrainzSection> { + final _token = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _token.dispose(); + super.dispose(); + } + + Future _saveToken() async { + final fs = Theme.of(context).extension()!; + if (_token.text.trim().isEmpty) return; + setState(() => _saving = true); + try { + final api = await ref.read(_settingsApiProvider.future); + await api.setListenBrainzToken(_token.text.trim()); + _token.clear(); + ref.invalidate(_lbStatusProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('Token saved.'), + backgroundColor: fs.iron, + )); + } + } on DioException catch (e) { + if (mounted) { + final msg = ApiError.fromDio(e).code; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Save failed: $msg'), + backgroundColor: fs.error, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _toggleEnabled(bool value) async { + final fs = Theme.of(context).extension()!; + try { + final api = await ref.read(_settingsApiProvider.future); + await api.setListenBrainzEnabled(value); + ref.invalidate(_lbStatusProvider); + } on DioException catch (e) { + if (mounted) { + final msg = ApiError.fromDio(e).code; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Toggle failed: $msg'), + backgroundColor: fs.error, + )); + } + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final status = ref.watch(_lbStatusProvider); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const _SectionHeader('ListenBrainz'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Get a token at listenbrainz.org/profile. Tokens are stored ' + 'unencrypted on this server — treat as sensitive.', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ), + status.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (s) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _token, + obscureText: true, + style: TextStyle(color: fs.parchment), + decoration: _inputDecoration( + fs, + s.tokenSet ? 'Update token (current is set)' : 'Paste token', + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: FilledButton( + onPressed: _saving ? null : _saveToken, + style: FilledButton.styleFrom( + backgroundColor: fs.accent, + foregroundColor: fs.parchment, + ), + child: Text(_saving ? 'Saving…' : 'Save token'), + ), + ), + SwitchListTile( + title: Text( + 'Send my plays to ListenBrainz', + style: TextStyle(color: fs.parchment), + ), + subtitle: s.lastScrobbledAt != null + ? Text( + 'Last scrobble: ${s.lastScrobbledAt}', + style: TextStyle(color: fs.ash, fontSize: 11), + ) + : null, + value: s.enabled, + activeThumbColor: fs.accent, + onChanged: s.tokenSet ? _toggleEnabled : null, + ), + ], + ), + ), + ]); + } +} + +InputDecoration _inputDecoration(FabledSwordTheme fs, String label) { + return InputDecoration( + labelText: label, + labelStyle: TextStyle(color: fs.ash), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: fs.iron), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: fs.accent), + ), + ); +} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index a23a980d..1c30c297 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -7,9 +7,16 @@ 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'; import '../player/player_bar.dart'; +import '../player/queue_screen.dart'; +import '../playlists/playlist_detail_screen.dart'; +import '../playlists/playlists_list_screen.dart'; +import '../search/search_screen.dart'; +import '../settings/settings_screen.dart'; import 'widgets/version_gate.dart'; /// Exposed as a Provider so its single argument is a real `Ref` (the @@ -51,6 +58,16 @@ GoRouter buildRouter(Ref ref) { builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), ), GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), + 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( + path: '/playlists/:id', + builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!), + ), ], ), ], diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index b83fd40c..22e4a936 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" args: dependency: transitive description: @@ -65,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -89,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -170,66 +210,66 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.0.0" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.3.1" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: "8b302d17096ba88f911b7eb317c71d5e691da60a259549f42b38c658d1776d87" url: "https://pub.dev" source: hosted - version: "9.2.4" + version: "10.1.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "3af15a3cb2bf5b8b776832bd01776f8018766aece55623176e28b406481fb320" + url: "https://pub.dev" + source: hosted + version: "0.3.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_svg: dependency: "direct main" description: @@ -248,6 +288,14 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" glob: dependency: transitive description: @@ -260,18 +308,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" url: "https://pub.dev" source: hosted - version: "14.8.1" + version: "17.2.3" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "8.1.0" hooks: dependency: transitive description: @@ -288,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -296,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" jni: dependency: transitive description: @@ -324,10 +388,10 @@ packages: dependency: "direct main" description: name: just_audio - sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" url: "https://pub.dev" source: hosted - version: "0.9.46" + version: "0.10.5" just_audio_platform_interface: dependency: transitive description: @@ -372,10 +436,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -432,6 +496,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" objective_c: dependency: transitive description: @@ -552,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" pub_semver: dependency: "direct main" description: @@ -572,10 +652,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" rxdart: dependency: transitive description: @@ -584,11 +664,59 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -685,6 +813,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" test_api: dependency: transitive description: @@ -693,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" typed_data: dependency: transitive description: @@ -749,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -757,6 +909,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 30e6378d..713f2755 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -10,22 +10,24 @@ environment: dependencies: flutter: sdk: flutter - flutter_riverpod: ^2.5.1 + flutter_riverpod: ^3.3.1 dio: ^5.7.0 - just_audio: ^0.9.40 + just_audio: ^0.10.5 audio_service: ^0.18.15 - flutter_secure_storage: ^9.2.2 - go_router: ^14.6.1 + flutter_secure_storage: ^10.1.0 + go_router: ^17.2.3 flutter_svg: ^2.0.16 - google_fonts: ^6.2.1 - package_info_plus: ^8.0.0 + google_fonts: ^8.1.0 + # 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1 + # until either lib bumps win32 to 6.x. + package_info_plus: ^8.3.1 pub_semver: ^2.1.4 cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^6.0.0 mocktail: ^1.0.4 flutter: diff --git a/flutter_client/test/likes/like_button_test.dart b/flutter_client/test/likes/like_button_test.dart index e499626a..6a0b0cb6 100644 --- a/flutter_client/test/likes/like_button_test.dart +++ b/flutter_client/test/likes/like_button_test.dart @@ -5,6 +5,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/api/endpoints/likes.dart'; import 'package:minstrel/likes/like_button.dart'; import 'package:minstrel/likes/likes_provider.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/models/page.dart'; +import 'package:minstrel/models/track.dart'; import 'package:minstrel/theme/theme_data.dart'; class _ThrowingLikesApi implements LikesApi { @@ -24,6 +28,19 @@ class _ThrowingLikesApi implements LikesApi { Future<({Set artists, Set albums, Set tracks})> ids() async => (artists: {}, albums: {}, tracks: {}); + + // The list-* methods aren't exercised by this test; return empty pages. + @override + Future> listTracks({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); + + @override + Future> listAlbums({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); + + @override + Future> listArtists({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); } void main() { diff --git a/flutter_client/test/player/player_provider_test.dart b/flutter_client/test/player/player_provider_test.dart index be31d391..5ba00e0d 100644 --- a/flutter_client/test/player/player_provider_test.dart +++ b/flutter_client/test/player/player_provider_test.dart @@ -14,7 +14,19 @@ void main() { test('audioHandlerProvider must be overridden — bare read throws', () { final container = ProviderContainer(); addTearDown(container.dispose); - expect(() => container.read(audioHandlerProvider), throwsA(isA())); + // Riverpod 3 wraps the underlying error in a ProviderException. The + // intent of the test is "bare read fails," so accept the wrapper as + // long as the inner error is the UnimplementedError we threw. + expect( + () => container.read(audioHandlerProvider), + throwsA(predicate((e) { + if (e is UnimplementedError) return true; + // ProviderException is private to riverpod; match by name + by + // walking its toString for the inner UnimplementedError text. + return e.runtimeType.toString().contains('ProviderException') && + e.toString().contains('UnimplementedError'); + })), + ); }); test('overridden audioHandlerProvider exposes playbackState stream', () { diff --git a/web/src/lib/auth/publicRoutes.test.ts b/web/src/lib/auth/publicRoutes.test.ts new file mode 100644 index 00000000..1210eca8 --- /dev/null +++ b/web/src/lib/auth/publicRoutes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import { isPublicRoute } from './publicRoutes'; + +describe('isPublicRoute', () => { + test('/login is public — login form must render to unauthenticated visitors', () => { + expect(isPublicRoute('/login')).toBe(true); + }); + + // Regression guard for #376: /register was previously bounced to /login + // by the layout guard, breaking bootstrap-admin self-registration on + // fresh deployments. Do NOT remove /register from the public set unless + // you have a deliberate replacement (e.g. invite-only landing page). + test('/register is public — bootstrap admin must reach the form', () => { + expect(isPublicRoute('/register')).toBe(true); + }); + + test('/ is gated', () => { + expect(isPublicRoute('/')).toBe(false); + }); + + test('/admin/users is gated', () => { + expect(isPublicRoute('/admin/users')).toBe(false); + }); + + test('/settings is gated', () => { + expect(isPublicRoute('/settings')).toBe(false); + }); + + test('paths that merely start with /login are not public', () => { + expect(isPublicRoute('/login/extra')).toBe(false); + expect(isPublicRoute('/loginx')).toBe(false); + }); +}); diff --git a/web/src/lib/auth/publicRoutes.ts b/web/src/lib/auth/publicRoutes.ts new file mode 100644 index 00000000..e79a9c31 --- /dev/null +++ b/web/src/lib/auth/publicRoutes.ts @@ -0,0 +1,9 @@ +// Routes that the +layout.svelte auth guard must NOT redirect away from +// when the visitor is unauthenticated. Bootstrap-admin self-registration +// (#376) requires /register to be reachable without a session — without +// /register here, the login page's "Register" link bounces back to /login. +const PUBLIC_ROUTES = new Set(['/login', '/register']); + +export function isPublicRoute(pathname: string): boolean { + return PUBLIC_ROUTES.has(pathname); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 7d397225..d8d0f19f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/svelte-query'; import { queryClient } from '$lib/query/client'; import { user } from '$lib/auth/store.svelte'; + import { isPublicRoute } from '$lib/auth/publicRoutes'; import Shell from '$lib/components/Shell.svelte'; import QueueDrawer from '$lib/components/QueueDrawer.svelte'; import ToastHost from '$lib/components/ToastHost.svelte'; @@ -27,11 +28,11 @@ // Reactive guard: runs every time page.url or user.value changes. $effect(() => { const path = page.url.pathname; - const isLogin = path === '/login'; - if (user.value === null && !isLogin) { + const isPublic = isPublicRoute(path); + if (user.value === null && !isPublic) { const returnTo = path + page.url.search; goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true }); - } else if (user.value !== null && isLogin) { + } else if (user.value !== null && isPublic) { goto('/', { replaceState: true }); } });