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:
2026-05-08 14:33:53 -04:00
parent 147d6e280e
commit 5541171e94
7 changed files with 495 additions and 0 deletions
@@ -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,
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
actions: [
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',
+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,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);
});
+7
View File
@@ -10,6 +10,8 @@ import '../library/artist_detail_screen.dart';
import '../library/home_screen.dart';
import '../player/now_playing_screen.dart';
import '../player/player_bar.dart';
import '../playlists/playlist_detail_screen.dart';
import '../playlists/playlists_list_screen.dart';
import '../search/search_screen.dart';
import 'widgets/version_gate.dart';
@@ -53,6 +55,11 @@ GoRouter buildRouter(Ref ref) {
),
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
GoRoute(
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
),
],
),
],