Merge pull request 'fix(web): /register reachable for bootstrap admin (closes #376)' (#32) from dev into main

This commit was merged in pull request #32.
This commit is contained in:
2026-05-08 22:01:37 +00:00
36 changed files with 2833 additions and 50 deletions
+1 -1
View File
@@ -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
@@ -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<List<LidarrSearchResult>> search(
String query,
LidarrRequestKind kind,
) async {
final r = await _dio.get<List<dynamic>>(
'/api/lidarr/search',
queryParameters: {'q': query, 'kind': kind.wire},
);
final raw = r.data ?? const [];
return raw
.map((e) =>
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
.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<void> createRequest({
required LidarrRequestKind kind,
required String artistMbid,
required String artistName,
String? albumMbid,
String? albumTitle,
String? trackMbid,
String? trackTitle,
}) async {
final body = <String, dynamic>{
'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<Map<String, dynamic>>('/api/requests', data: body);
}
}
@@ -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<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/library/artists',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
}
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/library/albums',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
}
}
@@ -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<void>('/api/likes/${kind.path}/$id');
}
/// GET /api/likes/tracks?limit=N&offset=N. Paged.
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/likes/tracks',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson);
}
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/likes/albums',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
}
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/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`.
+30
View File
@@ -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<T>` envelope.
Future<HistoryPage> history({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/me/history',
queryParameters: {'limit': limit, 'offset': offset},
);
return HistoryPage.fromJson(r.data ?? const {});
}
/// GET /api/quarantine/mine — flat list (no envelope).
Future<List<QuarantineMineRow>> quarantineMine() async {
final r = await _dio.get<List<dynamic>>('/api/quarantine/mine');
final raw = r.data ?? const [];
return raw
.map((e) =>
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
}
@@ -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<Playlist>> list({String kind = 'all'}) async {
final r = await _dio.get<List<dynamic>>(
'/api/playlists',
queryParameters: {'kind': kind},
);
final raw = r.data ?? const [];
return raw
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<PlaylistDetail> get(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
return PlaylistDetail.fromJson(r.data ?? const {});
}
}
@@ -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<SearchResponse> search(
String query, {
int limit = 20,
int offset = 0,
}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/search',
queryParameters: {
'q': query,
'limit': limit,
'offset': offset,
},
);
return SearchResponse.fromJson(r.data ?? const {});
}
}
@@ -0,0 +1,57 @@
import 'package:dio/dio.dart';
import '../../models/my_profile.dart';
class SettingsApi {
SettingsApi(this._dio);
final Dio _dio;
Future<MyProfile> getProfile() async {
final r = await _dio.get<Map<String, dynamic>>('/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<MyProfile> updateProfile({String? displayName, String? email}) async {
final body = <String, dynamic>{};
if (displayName != null) body['display_name'] = displayName;
if (email != null) body['email'] = email;
final r = await _dio.put<Map<String, dynamic>>(
'/api/me/profile',
data: body,
);
return MyProfile.fromJson(r.data ?? const {});
}
Future<void> changePassword({
required String current,
required String next,
}) async {
await _dio.put<void>('/api/me/password', data: {
'current_password': current,
'new_password': next,
});
}
Future<ListenBrainzStatus> getListenBrainz() async {
final r = await _dio.get<Map<String, dynamic>>('/api/me/listenbrainz');
return ListenBrainzStatus.fromJson(r.data ?? const {});
}
Future<ListenBrainzStatus> setListenBrainzToken(String token) async {
final r = await _dio.put<Map<String, dynamic>>(
'/api/me/listenbrainz',
data: {'token': token},
);
return ListenBrainzStatus.fromJson(r.data ?? const {});
}
Future<ListenBrainzStatus> setListenBrainzEnabled(bool enabled) async {
final r = await _dio.put<Map<String, dynamic>>(
'/api/me/listenbrainz',
data: {'enabled': enabled},
);
return ListenBrainzStatus.fromJson(r.data ?? const {});
}
}
@@ -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<DiscoverApi>((ref) async {
return DiscoverApi(await ref.watch(dioProvider.future));
});
class DiscoverScreen extends ConsumerStatefulWidget {
const DiscoverScreen({super.key});
@override
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
}
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
final _ctrl = TextEditingController();
LidarrRequestKind _kind = LidarrRequestKind.artist;
Future<List<LidarrSearchResult>>? _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<void> _request(LidarrSearchResult row) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<LidarrRequestKind>(
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<List<LidarrSearchResult>>(
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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)),
);
}
}
@@ -24,6 +24,38 @@ class HomeScreen extends ConsumerWidget {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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, _) {
@@ -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<T>.
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<LibraryListsApi>((ref) async {
return LibraryListsApi(await ref.watch(dioProvider.future));
});
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
return LikesApi(await ref.watch(dioProvider.future));
});
final _meApiProvider = FutureProvider<MeApi>((ref) async {
return MeApi(await ref.watch(dioProvider.future));
});
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
});
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
});
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
});
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listTracks();
});
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listAlbums();
});
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listArtists();
});
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
return (await ref.watch(_meApiProvider.future)).quarantineMine();
});
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@override
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen>
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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}';
}
@@ -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<T>` 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<String, dynamic> j) => HistoryEvent(
id: j['id'] as String? ?? '',
playedAt: j['played_at'] as String? ?? '',
track: TrackRef.fromJson(
(j['track'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
}
class HistoryPage {
const HistoryPage({required this.events, required this.hasMore});
final List<HistoryEvent> events;
final bool hasMore;
factory HistoryPage.fromJson(Map<String, dynamic> j) {
final raw = (j['events'] as List?) ?? const [];
return HistoryPage(
events: raw
.map((e) => HistoryEvent.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
hasMore: j['has_more'] as bool? ?? false,
);
}
}
+47
View File
@@ -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<String, dynamic> 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',
};
}
+49
View File
@@ -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<String, dynamic> 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<String, dynamic> j) =>
ListenBrainzStatus(
enabled: j['enabled'] as bool? ?? false,
tokenSet: j['token_set'] as bool? ?? false,
lastScrobbledAt: j['last_scrobbled_at'] as String?,
);
}
+44
View File
@@ -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<T>` rather than `Page<T>`
// 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<T> {
const Paged({
required this.items,
required this.total,
required this.limit,
required this.offset,
});
final List<T> items;
final int total;
final int limit;
final int offset;
factory Paged.fromJson(
Map<String, dynamic> j,
T Function(Map<String, dynamic>) itemFromJson,
) {
final raw = (j['items'] as List?) ?? const [];
return Paged<T>(
items: raw
.map((e) => itemFromJson((e as Map).cast<String, dynamic>()))
.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;
}
+117
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<PlaylistTrack> tracks;
factory PlaylistDetail.fromJson(Map<String, dynamic> j) {
final tracksRaw = (j['tracks'] as List?) ?? const [];
return PlaylistDetail(
playlist: Playlist.fromJson(j),
tracks: tracksRaw
.map((e) => PlaylistTrack.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
}
}
@@ -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<String, dynamic> 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? ?? '',
);
}
@@ -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<ArtistRef> artists;
final Paged<AlbumRef> albums;
final Paged<TrackRef> tracks;
factory SearchResponse.fromJson(Map<String, dynamic> j) => SearchResponse(
artists: Paged.fromJson(
(j['artists'] as Map?)?.cast<String, dynamic>() ?? const {},
ArtistRef.fromJson,
),
albums: Paged.fromJson(
(j['albums'] as Map?)?.cast<String, dynamic>() ?? const {},
AlbumRef.fromJson,
),
tracks: Paged.fromJson(
(j['tracks'] as Map?)?.cast<String, dynamic>() ?? const {},
TrackRef.fromJson,
),
);
bool get isEmpty =>
artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty;
}
+2 -2
View File
@@ -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,
);
}
@@ -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<FabledSwordTheme>()!;
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(
+2 -2
View File
@@ -11,8 +11,8 @@ class PlayerBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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(
+118
View File
@@ -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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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)),
]),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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<PlaylistTrack> playable;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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)),
]),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
// "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<FabledSwordTheme>()!;
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),
]),
),
);
}
}
@@ -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<PlaylistsApi>((ref) async {
return PlaylistsApi(await ref.watch(dioProvider.future));
});
final playlistsListProvider =
FutureProvider.family<List<Playlist>, String>((ref, kind) async {
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
});
final playlistDetailProvider =
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
return (await ref.watch(playlistsApiProvider.future)).get(id);
});
@@ -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<SearchApi>((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<String> {
@override
String build() => '';
void set(String q) => state = q;
}
final searchQueryProvider =
NotifierProvider<SearchQueryNotifier, String>(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<SearchResponse?>((ref) async {
final q = ref.watch(searchQueryProvider).trim();
if (q.isEmpty) return null;
await Future<void>.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);
});
@@ -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<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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)),
],
),
);
}
}
@@ -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<SettingsApi>((ref) async {
return SettingsApi(await ref.watch(dioProvider.future));
});
final _profileProvider = FutureProvider<MyProfile>((ref) async {
return (await ref.watch(_settingsApiProvider.future)).getProfile();
});
final _lbStatusProvider = FutureProvider<ListenBrainzStatus>((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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _save() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _change() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _saveToken() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<void> _toggleEnabled(bool value) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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),
),
);
}
+17
View File
@@ -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']!),
),
],
),
],
+208 -32
View File
@@ -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:
+9 -7
View File
@@ -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:
@@ -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<String> artists, Set<String> albums, Set<String> tracks})>
ids() async =>
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
// The list-* methods aren't exercised by this test; return empty pages.
@override
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async =>
const Paged<TrackRef>(items: [], total: 0, limit: 50, offset: 0);
@override
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async =>
const Paged<AlbumRef>(items: [], total: 0, limit: 50, offset: 0);
@override
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async =>
const Paged<ArtistRef>(items: [], total: 0, limit: 50, offset: 0);
}
void main() {
@@ -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<UnimplementedError>()));
// 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', () {
+33
View File
@@ -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);
});
});
+9
View File
@@ -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);
}
+4 -3
View File
@@ -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 });
}
});