From 5541171e94add035ebc91b942b1aa8ca570bbda3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 14:33:53 -0400 Subject: [PATCH] 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. --- .../lib/api/endpoints/playlists.dart | 28 +++ flutter_client/lib/library/home_screen.dart | 5 + flutter_client/lib/models/playlist.dart | 117 +++++++++++ .../lib/playlists/playlist_detail_screen.dart | 191 ++++++++++++++++++ .../lib/playlists/playlists_list_screen.dart | 128 ++++++++++++ .../lib/playlists/playlists_provider.dart | 19 ++ flutter_client/lib/shared/routing.dart | 7 + 7 files changed, 495 insertions(+) create mode 100644 flutter_client/lib/api/endpoints/playlists.dart create mode 100644 flutter_client/lib/models/playlist.dart create mode 100644 flutter_client/lib/playlists/playlist_detail_screen.dart create mode 100644 flutter_client/lib/playlists/playlists_list_screen.dart create mode 100644 flutter_client/lib/playlists/playlists_provider.dart diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart new file mode 100644 index 00000000..6019f858 --- /dev/null +++ b/flutter_client/lib/api/endpoints/playlists.dart @@ -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({String kind = 'all'}) async { + final r = await _dio.get>( + '/api/playlists', + queryParameters: {'kind': kind}, + ); + final raw = r.data ?? const []; + return raw + .map((e) => Playlist.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future get(String id) async { + final r = await _dio.get>('/api/playlists/$id'); + return PlaylistDetail.fromJson(r.data ?? const {}); + } +} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index fc4bfab1..cb6977cd 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -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', diff --git a/flutter_client/lib/models/playlist.dart b/flutter_client/lib/models/playlist.dart new file mode 100644 index 00000000..7916fde7 --- /dev/null +++ b/flutter_client/lib/models/playlist.dart @@ -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 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 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 tracks; + + factory PlaylistDetail.fromJson(Map j) { + final tracksRaw = (j['tracks'] as List?) ?? const []; + return PlaylistDetail( + playlist: Playlist.fromJson(j), + tracks: tracksRaw + .map((e) => PlaylistTrack.fromJson((e as Map).cast())) + .toList(growable: false), + ); + } +} diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart new file mode 100644 index 00000000..dfcf6c22 --- /dev/null +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -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()!; + 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 playable; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + 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()!; + 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)), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart new file mode 100644 index 00000000..f71c029d --- /dev/null +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -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()!; + // "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()!; + 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), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart new file mode 100644 index 00000000..fb0c4306 --- /dev/null +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -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((ref) async { + return PlaylistsApi(await ref.watch(dioProvider.future)); +}); + +final playlistsListProvider = + FutureProvider.family, String>((ref, kind) async { + return (await ref.watch(playlistsApiProvider.future)).list(kind: kind); +}); + +final playlistDetailProvider = + FutureProvider.family((ref, id) async { + return (await ref.watch(playlistsApiProvider.future)).get(id); +}); diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 23baa13e..75a8423a 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -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']!), + ), ], ), ],