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,
|
||||
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',
|
||||
|
||||
@@ -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 '../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']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user