import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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 '../shared/live_events_provider.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; class PlaylistDetailScreen extends ConsumerWidget { const PlaylistDetailScreen({required this.id, this.seed, super.key}); final String id; /// Optional Playlist passed via go_router extra so the header /// (title, cover, track count, play/download CTAs) can render /// before the full detail fetch resolves. Same pattern as album /// + artist nav hydration. final Playlist? seed; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; // #402 wire-up: invalidate the detail provider on playlist.updated / // .tracks_changed events whose payload matches the visible id. On // .deleted matching this id, navigate back so the user isn't left // staring at a gone-from-server playlist. ref.listen>(liveEventsProvider, (_, next) { final e = next.asData?.value; if (e == null) return; final eventPlaylistId = e.data['playlist_id'] as String?; if (eventPlaylistId != id) return; switch (e.kind) { case 'playlist.updated': case 'playlist.tracks_changed': ref.invalidate(playlistDetailProvider(id)); case 'playlist.deleted': if (context.mounted) context.pop(); } }); final detail = ref.watch(playlistDetailProvider(id)); // Resolve the best playlist info available for the AppBar title. final livePlaylist = detail.value?.playlist; final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty) ? livePlaylist.name : (seed?.name ?? ''); return Scaffold( backgroundColor: fs.obsidian, appBar: AppBar( backgroundColor: fs.obsidian, elevation: 0, leading: IconButton( icon: Icon(LucideIcons.arrow_left, color: fs.parchment), onPressed: () => context.pop(), ), title: headerName.isEmpty ? const SizedBox.shrink() : Text( headerName, style: TextStyle(color: fs.parchment), overflow: TextOverflow.ellipsis, ), ), // AnimatedSwitcher between the skeleton body and the real body // smooths the cold-visit moment when bulk detail lands. 220ms // matches the per-tile reveal feel used on home / liked tabs. body: AnimatedSwitcher( duration: const Duration(milliseconds: 220), switchInCurve: Curves.easeOut, child: detail.when( loading: () => _SkeletonBody( key: const ValueKey('skeleton'), seed: seed, ), error: (e, _) => Center( key: const ValueKey('error'), child: Text('$e', style: TextStyle(color: fs.error)), ), data: (d) => _Body(key: const ValueKey('body'), detail: d), ), ), ); } } /// Cold-visit body: header from the seed (if any) + N skeleton rows. /// N comes from the seed's trackCount so the row count matches the /// real list when it lands — no layout jump on swap. Without a seed /// (deep link straight to a playlist with no prior cache) the /// skeleton renders a small default and grows when real data arrives. class _SkeletonBody extends StatelessWidget { const _SkeletonBody({super.key, this.seed}); final Playlist? seed; static const _defaultSkeletonCount = 8; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final count = seed?.trackCount ?? _defaultSkeletonCount; return ListView.builder( itemCount: count + 1, // +1 for header itemBuilder: (ctx, i) { if (i == 0) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (seed != null && seed!.description.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( seed!.description, style: TextStyle(color: fs.ash, fontSize: 13), ), ), Text( seed == null ? 'Loading…' : '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}', style: TextStyle(color: fs.ash, fontSize: 12), ), ], ), ); } return const _PlaylistTrackSkeleton(); }, ); } } /// Skeleton matched to _PlaylistTrackRow's layout (no cover image — /// playlist rows are text-only). Two text-shaped placeholders for /// title + secondary line, plus a duration block on the right. class _PlaylistTrackSkeleton extends StatelessWidget { const _PlaylistTrackSkeleton(); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container(width: 200, height: 14, color: fs.slate), const SizedBox(height: 6), Container(width: 140, height: 12, color: fs.slate), ], ), ), const SizedBox(width: 16), Container(width: 32, height: 12, color: fs.slate), ], ), ); } } class _Body extends ConsumerWidget { const _Body({super.key, required this.detail}); final PlaylistDetail detail; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; 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( // Header + (track rows | empty hint). itemCount: tracks.isEmpty ? 2 : tracks.length + 1, itemBuilder: (ctx, i) { if (i == 0) return _Header(detail: detail, playable: playable); if (tracks.isEmpty) { return Padding( padding: const EdgeInsets.all(24), child: Center( child: Text( 'No tracks yet. Add some via the "Add to playlist…" entry on any track row.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center, ), ), ); } 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, source: detail.playlist.refreshable ? detail.playlist.systemVariant : null, ); // 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) ...[ if (p.refreshable) ...[ OutlinedButton.icon( key: const Key('regenerate_playlist_button'), onPressed: () => _regenerate(context, ref), icon: const Icon(LucideIcons.refresh_cw, size: 16), label: const Text('Regenerate'), ), const SizedBox(width: 8), ], FilledButton.icon( onPressed: () { final refs = playable.map(_toTrackRef).toList(growable: false); ref.read(playerActionsProvider).playTracks( refs, source: p.refreshable ? p.systemVariant : null, ); }, icon: const Icon(LucideIcons.play), label: const Text('Play'), style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), ), ], ]), ]), ); } /// Forces a server-side rebuild of this system playlist. The /// rebuild rotates the playlist UUID, so the old detail route now /// 404s — rebind by pushReplacement-ing to the new id. The /// aggregate list is invalidated too so the home row's tile points /// at the fresh UUID. ScaffoldMessenger + router captured before /// the await so we don't touch a stale BuildContext after. Future _regenerate(BuildContext context, WidgetRef ref) async { final messenger = ScaffoldMessenger.of(context); final router = GoRouter.of(context); final p = detail.playlist; try { final api = await ref.read(playlistsApiProvider.future); final newId = await api.refreshSystem(p.systemVariant!); ref.invalidate(playlistsListProvider); if (newId == null) { messenger.showSnackBar(const SnackBar( content: Text('Nothing to build yet — library is empty.'), )); return; } messenger.showSnackBar( SnackBar(content: Text('${p.name} regenerated')), ); router.pushReplacement('/playlists/$newId'); } catch (e) { messenger.showSnackBar( SnackBar(content: Text("Couldn't regenerate: $e")), ); } } } class _PlaylistTrackRow extends ConsumerWidget { const _PlaylistTrackRow({required this.row, required this.onTap}); final PlaylistTrack row; final VoidCallback? onTap; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); final secs = (row.durationSec % 60).toString().padLeft(2, '0'); // "Now playing" highlight — matches _QueueRow and TrackRow so the // user sees which playlist row is current without reading the // player bar. Unavailable rows never match. final currentId = ref.watch(mediaItemProvider).value?.id; final isCurrent = row.isAvailable && currentId != null && currentId == row.trackId; final baseColor = row.isAvailable ? fs.parchment : fs.ash; final titleColor = isCurrent ? fs.accent : baseColor; return Container( decoration: BoxDecoration( color: isCurrent ? fs.iron : null, border: isCurrent ? Border(left: BorderSide(color: fs.accent, width: 2)) : null, ), child: 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: titleColor, fontSize: 14, fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, 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)), if (row.trackId != null) TrackActionsButton(track: _toTrackRef(row)), ]), ), ), ); } }