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,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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user