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