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 mine = ref.watch(myPlaylistsProvider); final shared = ref.watch(sharedPlaylistsProvider); final controller = ref.read(playlistsProvider.notifier); final connected = ref.watch(subsonicClientProvider) != null; void open(Playlist p) => Navigator.of(context).push( MaterialPageRoute(builder: (_) => PlaylistDetailScreen(id: p.id)), ); 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: ListView( padding: const EdgeInsets.all(TimbreSpacing.lg), children: [ HairlinePanel( title: 'Playlists', active: true, trailing: mine.isEmpty ? null : '(${mine.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: mine.isEmpty ? Padding( padding: const EdgeInsets.all(TimbreSpacing.lg), child: Text( connected ? 'No playlists yet. Tap + to create one.' : 'Connect to a server to see playlists.', textAlign: TextAlign.center, style: TextStyle(color: TimbreColors.dimmed), ), ) : Column( children: [ for (final p in mine) _PlaylistRow( name: p.name, subtitle: p.songCount != null ? '${p.songCount} tracks' : null, badge: (p.public ?? false) ? 'Public' : null, isPublic: p.public ?? false, onTap: () => open(p), onToggleShare: connected ? () => _toggleShare(context, controller, p) : null, onRename: connected ? () => _rename(context, controller, p) : null, onDelete: connected ? () => _confirmDelete(context, controller, p) : null, ), ], ), ), if (shared.isNotEmpty) ...[ const SizedBox(height: TimbreSpacing.xl), HairlinePanel( title: 'Shared', active: true, trailing: '(${shared.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: Column( children: [ for (final p in shared) _PlaylistRow( name: p.name, subtitle: p.owner != null ? 'by ${p.owner}' : null, onTap: () => open(p), onSaveCopy: connected ? () => _saveCopy(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 _rename( BuildContext context, PlaylistsController controller, Playlist p) async { final name = await promptPlaylistName(context, title: 'Rename playlist', initial: p.name); if (name != null && name.isNotEmpty) controller.rename(p.id, name); } Future _toggleShare( BuildContext context, PlaylistsController controller, Playlist p) async { final next = !(p.public ?? false); await controller.setPublic(p.id, next); if (context.mounted) { showToast(context, next ? 'Shared — now public' : 'No longer shared', icon: Icons.check); } } Future _saveCopy( BuildContext context, PlaylistsController controller, Playlist p) async { final id = await controller.saveCopy(p); if (!context.mounted) return; showToast(context, id != null ? 'Saved a copy' : 'Could not save copy', icon: id != null ? Icons.check : null); } 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.badge, this.isPublic = false, this.onRename, this.onDelete, this.onToggleShare, this.onSaveCopy, }); final String name; final String? subtitle; /// Optional pill after the name, e.g. "Public". final String? badge; /// Current share state, so the toggle can label itself correctly. final bool isPublic; final VoidCallback onTap; final VoidCallback? onRename; final VoidCallback? onDelete; final VoidCallback? onToggleShare; final VoidCallback? onSaveCopy; @override Widget build(BuildContext context) { final hasMenu = onRename != null || onDelete != null || onToggleShare != null || onSaveCopy != null; return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Flexible( child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: TimbreColors.foreground)), ), if (badge != null) ...[ const SizedBox(width: TimbreSpacing.sm), _Pill(badge!), ], ], ), if (subtitle != null) Text(subtitle!, style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), if (hasMenu) PopupMenuButton( icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { switch (v) { case 'share': onToggleShare?.call(); case 'copy': onSaveCopy?.call(); case 'rename': onRename?.call(); case 'delete': onDelete?.call(); } }, itemBuilder: (_) => [ if (onToggleShare != null) PopupMenuItem( value: 'share', child: Text(isPublic ? 'Make private' : 'Make public')), if (onSaveCopy != null) const PopupMenuItem( value: 'copy', child: Text('Save a copy')), if (onRename != null) const PopupMenuItem(value: 'rename', child: Text('Rename')), if (onDelete != null) const PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), ], ), ), ); } } /// A small accent-outlined label — used to flag a playlist as "Public". class _Pill extends StatelessWidget { const _Pill(this.text); final String text; @override Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; return Container( padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.sm, vertical: 1), decoration: BoxDecoration( border: Border.all(color: accent), color: accent.withValues(alpha: 0.12), ), child: Text(text, style: TextStyle( color: accent, fontSize: 11, fontWeight: FontWeight.w700)), ); } } /// 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 me = ref.watch(currentUsernameProvider); final playback = ref.read(playbackCommandsProvider); final songs = detail?.songs ?? const []; // Ownership drives which sharing affordance shows: owner → share toggle; // someone else's shared playlist → save-a-copy. final isMine = summary == null || isOwnedBy(summary, me); final isPublic = summary?.public ?? false; 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), ), if (connected && summary != null) if (isMine) IconButton( tooltip: isPublic ? 'Make private' : 'Make public', onPressed: () => _toggleShare(summary, isPublic), icon: Icon(isPublic ? Icons.public : Icons.public_off), ) else IconButton( tooltip: 'Save a copy', onPressed: () => _saveCopy(summary), icon: const Icon(Icons.save_alt), ), ], ), body: SafeArea( child: detail == null ? Center( child: Text('Loading…', style: TextStyle(color: TimbreColors.dimmed)), ) : songs.isEmpty ? 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, ); }, ), ), ); } Future _toggleShare(Playlist p, bool isPublic) async { await ref.read(playlistsProvider.notifier).setPublic(p.id, !isPublic); if (mounted) { showToast(context, !isPublic ? 'Shared — now public' : 'No longer shared', icon: Icons.check); } } Future _saveCopy(Playlist p) async { final id = await ref.read(playlistsProvider.notifier).saveCopy(p); if (!mounted) return; showToast(context, id != null ? 'Saved a copy' : 'Could not save copy', icon: id != null ? Icons.check : 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: TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: TimbreColors.foreground)), if (song.artist != null) Text(song.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), PopupMenuButton( icon: 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')), ], ), ], ), ), ); } }