import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../playlists/playlists.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/hairline_panel.dart'; import '../widgets/toast.dart'; import 'add_to_playlist_sheet.dart'; /// Playlists list — server-backed with an offline mirror. Create from the app /// bar; each row opens its detail. Rename/delete via the row overflow menu. class PlaylistsScreen extends ConsumerWidget { const PlaylistsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final playlists = ref.watch(realPlaylistsProvider); final controller = ref.read(playlistsProvider.notifier); final connected = ref.watch(subsonicClientProvider) != null; return Scaffold( appBar: AppBar( title: const Text('Playlists', style: TextStyle(fontWeight: FontWeight.w700)), actions: [ IconButton( tooltip: 'New playlist', onPressed: connected ? () => _create(context, ref) : null, icon: const Icon(Icons.add), ), ], ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(TimbreSpacing.lg), child: HairlinePanel( title: 'Playlists', active: true, trailing: playlists.isEmpty ? null : '(${playlists.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: playlists.isEmpty ? Center( child: Text( connected ? 'No playlists yet. Tap + to create one.' : 'Connect to a server to see playlists.', textAlign: TextAlign.center, style: const TextStyle(color: TimbreColors.dimmed), ), ) : ListView.builder( padding: EdgeInsets.zero, itemCount: playlists.length, itemBuilder: (context, i) { final p = playlists[i]; return _PlaylistRow( name: p.name, subtitle: p.songCount != null ? '${p.songCount} tracks' : null, onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlaylistDetailScreen(id: p.id), ), ), onRename: connected ? () async { final name = await promptPlaylistName(context, title: 'Rename playlist', initial: p.name); if (name != null && name.isNotEmpty) { controller.rename(p.id, name); } } : null, onDelete: connected ? () => _confirmDelete(context, controller, p) : null, ); }, ), ), ), ), ); } Future _create(BuildContext context, WidgetRef ref) async { final name = await promptPlaylistName(context, title: 'New playlist'); if (name != null && name.isNotEmpty) { await ref.read(playlistsProvider.notifier).create(name); } } Future _confirmDelete( BuildContext context, PlaylistsController controller, Playlist p) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: TimbreColors.surface, title: Text('Delete "${p.name}"?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Delete')), ], ), ); if (ok == true) controller.delete(p.id); } } class _PlaylistRow extends StatelessWidget { const _PlaylistRow({ required this.name, required this.onTap, this.subtitle, this.onRename, this.onDelete, }); final String name; final String? subtitle; final VoidCallback onTap; final VoidCallback? onRename; final VoidCallback? onDelete; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ const Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: TimbreColors.foreground)), if (subtitle != null) Text(subtitle!, style: const TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), if (onRename != null || onDelete != null) PopupMenuButton( icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { if (v == 'rename') onRename?.call(); if (v == 'delete') onDelete?.call(); }, itemBuilder: (_) => [ if (onRename != null) const PopupMenuItem(value: 'rename', child: Text('Rename')), if (onDelete != null) const PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), ], ), ), ); } } /// One playlist's tracks: play all / download all from the app bar, remove a /// track via its trailing control. class PlaylistDetailScreen extends ConsumerStatefulWidget { const PlaylistDetailScreen({super.key, required this.id}); final String id; @override ConsumerState createState() => _PlaylistDetailScreenState(); } class _PlaylistDetailScreenState extends ConsumerState { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(playlistsProvider.notifier).loadDetail(widget.id); }); } @override Widget build(BuildContext context) { final detail = ref.watch( playlistsProvider.select((s) => s.details[widget.id])); final summary = ref.watch(playlistsProvider.select((s) => s.playlists.where((p) => p.id == widget.id).firstOrNull)); final connected = ref.watch(subsonicClientProvider) != null; final playback = ref.read(playbackProvider.notifier); final songs = detail?.songs ?? const []; return Scaffold( appBar: AppBar( title: Text(detail?.name ?? summary?.name ?? 'Playlist', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700)), actions: [ IconButton( tooltip: 'Play all', onPressed: songs.isEmpty ? null : () => playback.playSongs(songs), icon: const Icon(Icons.play_arrow), ), IconButton( tooltip: 'Download all', onPressed: songs.isEmpty ? null : () { ref .read(downloadManagerProvider.notifier) .downloadAll(songs); showToast(context, 'Downloading playlist…'); }, icon: const Icon(Icons.download), ), ], ), body: SafeArea( child: detail == null ? const Center( child: Text('Loading…', style: TextStyle(color: TimbreColors.dimmed)), ) : songs.isEmpty ? const Center( child: Text('This playlist is empty.', style: TextStyle(color: TimbreColors.dimmed)), ) : ListView.builder( padding: const EdgeInsets.symmetric( vertical: TimbreSpacing.md), itemCount: songs.length, itemBuilder: (context, i) { final song = songs[i]; return _TrackRow( index: i + 1, song: song, onTap: () => playback.playSongs(songs, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), onRemove: connected ? () => ref .read(playlistsProvider.notifier) .removeAt(widget.id, i) : null, ); }, ), ), ); } } class _TrackRow extends StatelessWidget { const _TrackRow({ required this.index, required this.song, required this.onTap, required this.onPlayNext, required this.onAddToQueue, this.onRemove, }); final int index; final Song song; final VoidCallback onTap; final VoidCallback onPlayNext; final VoidCallback onAddToQueue; final VoidCallback? onRemove; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ SizedBox( width: 28, child: Text('$index', style: const TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: TimbreColors.foreground)), if (song.artist != null) Text(song.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), PopupMenuButton( icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { switch (v) { case 'next': onPlayNext(); showToast(context, 'Playing next', icon: Icons.check); case 'queue': onAddToQueue(); showToast(context, 'Added to queue', icon: Icons.check); case 'remove': onRemove?.call(); } }, itemBuilder: (_) => [ const PopupMenuItem(value: 'next', child: Text('Play next')), const PopupMenuItem( value: 'queue', child: Text('Add to queue')), if (onRemove != null) const PopupMenuItem( value: 'remove', child: Text('Remove from playlist')), ], ), ], ), ), ); } }