feat(flutter): playlists list + detail screens
- models/playlist.dart (Playlist, PlaylistTrack, PlaylistDetail) - api/endpoints/playlists.dart (list with kind=user|system|all, get detail) - playlists/playlists_provider.dart (Riverpod family providers) - playlists/playlists_list_screen.dart (with system-variant pill) - playlists/playlist_detail_screen.dart (header + Play button + rows) - /playlists + /playlists/:id routes wired - Home AppBar: queue_music icon next to Search Tracks with track_id=null render greyed-out + struck-through (matches web's PlaylistTrackRow behavior). Tap a row plays from that position; top-level Play button plays the full playable subset from track 0.
This commit is contained in:
@@ -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 {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||||
|
tooltip: 'Playlists',
|
||||||
|
onPressed: () => context.push('/playlists'),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.search, color: fs.parchment),
|
icon: Icon(Icons.search, color: fs.parchment),
|
||||||
tooltip: 'Search',
|
tooltip: 'Search',
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import '../library/artist_detail_screen.dart';
|
|||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../player/now_playing_screen.dart';
|
import '../player/now_playing_screen.dart';
|
||||||
import '../player/player_bar.dart';
|
import '../player/player_bar.dart';
|
||||||
|
import '../playlists/playlist_detail_screen.dart';
|
||||||
|
import '../playlists/playlists_list_screen.dart';
|
||||||
import '../search/search_screen.dart';
|
import '../search/search_screen.dart';
|
||||||
import 'widgets/version_gate.dart';
|
import 'widgets/version_gate.dart';
|
||||||
|
|
||||||
@@ -53,6 +55,11 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
|
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/playlists/:id',
|
||||||
|
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user