import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../api.dart'; import '../player.dart'; import '../theme.dart'; import '../widgets.dart'; import 'song_editor.dart'; class SongsScreen extends StatefulWidget { const SongsScreen({ super.key, this.filter = SongFilter.all, this.query = '', this.albumId, this.artistId, this.filterName, }); final SongFilter filter; final String query; final String? albumId; final String? artistId; /// Album or singer name, shown when the list is limited to one of them. final String? filterName; @override State createState() => _SongsScreenState(); } class _SongsScreenState extends State { static const _pageSize = 50; late SongFilter _filter = widget.filter; late String _query = widget.query; bool _newestFirst = true; int _page = 0; final _selected = {}; late Future> _data = _load(); Future> _load() async { final page = await AdminApi.tracks( query: _query, filter: _filter, albumId: widget.albumId, artistId: widget.artistId, newestFirst: _newestFirst, page: _page, pageSize: _pageSize, ); AdminPlayer.instance.refresh(page.items); return page; } void _reload({bool resetPage = false, bool keepSelection = true}) => setState(() { if (resetPage) _page = 0; if (!keepSelection) _selected.clear(); _data = _load(); }); Future _setStatus(List ids, SongStatus status) async { try { await AdminApi.setStatus(ids, status); if (!mounted) return; showMessage(context, '${ids.length == 1 ? 'Song' : '${ids.length} songs'}: ${status.label.toLowerCase()}'); _selected.removeAll(ids); _reload(); } catch (e) { if (mounted) showError(context, e); } } Future _edit(AdminTrack t) async { if (await editSong(context, t)) _reload(); } Future _bulkEdit() async { if (await bulkEditSongs(context, _selected.toList())) { if (!mounted) return; showMessage(context, 'Updated ${_selected.length} songs'); _reload(keepSelection: false); } } @override Widget build(BuildContext context) { final scoped = widget.albumId != null || widget.artistId != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PageHeader( 'Songs', subtitle: scoped ? '${widget.albumId != null ? 'Album' : 'Singer'}: ${widget.filterName ?? ''}' : 'Search, listen, fix details and publish', actions: [ if (scoped) OutlinedButton.icon( onPressed: () => context.go('/songs'), icon: const Icon(Icons.close, size: 18), label: const Text('Show all songs'), ), SearchField( hint: 'Title, singer, album…', initial: _query, onChanged: (q) { _query = q; _reload(resetPage: true, keepSelection: false); }, ), ], ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final f in SongFilter.values) ChoiceChip( label: Text(f.label), selected: _filter == f, onSelected: (_) { _filter = f; _reload(resetPage: true, keepSelection: false); }, ), const SizedBox(width: 8), TextButton.icon( onPressed: () { _newestFirst = !_newestFirst; _reload(resetPage: true); }, icon: const Icon(Icons.sort, size: 18), label: Text(_newestFirst ? 'Newest first' : 'A to Z'), ), ], ), ), const SizedBox(height: 8), if (_selected.isNotEmpty) _bulkBar(), Expanded( child: Loader( future: _data, onRetry: _reload, builder: (context, page) => page.items.isEmpty ? const EmptyState('No songs match.', icon: Icons.music_off_outlined) : _list(page), ), ), ], ); } Widget _bulkBar() => Container( margin: const EdgeInsets.fromLTRB(24, 4, 24, 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration(color: NkColors.raised, borderRadius: BorderRadius.circular(14)), child: Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('${_selected.length} selected', style: const TextStyle(fontWeight: FontWeight.w800)), const SizedBox(width: 8), FilledButton.icon( onPressed: () => _setStatus(_selected.toList(), SongStatus.approved), icon: const Icon(Icons.check, size: 18), label: const Text('Approve'), ), OutlinedButton.icon( onPressed: () => _setStatus(_selected.toList(), SongStatus.takenDown), icon: const Icon(Icons.visibility_off_outlined, size: 18), label: const Text('Hide'), ), OutlinedButton.icon( onPressed: _bulkEdit, icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Edit…'), ), TextButton(onPressed: () => setState(_selected.clear), child: const Text('Clear selection')), ], ), ); Widget _list(ResultPage page) { final pageIds = page.items.map((t) => t.id).toSet(); final allOnPage = pageIds.every(_selected.contains); final wide = MediaQuery.sizeOf(context).width >= 1100; final first = _page * _pageSize + 1; final last = first + page.items.length - 1; return ListView( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), children: [ Card( clipBehavior: Clip.antiAlias, child: Column( children: [ ListTile( dense: true, leading: Checkbox( value: allOnPage, onChanged: (v) => setState(() => v == true ? _selected.addAll(pageIds) : _selected.removeAll(pageIds)), ), title: Text('$first–$last of ${page.total}', style: Theme.of(context).textTheme.bodySmall), ), for (final t in page.items) ...[const Divider(), _row(t, wide)], ], ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton( onPressed: _page == 0 ? null : () { _page--; _reload(); }, child: const Text('Previous'), ), const SizedBox(width: 8), OutlinedButton( onPressed: last >= page.total ? null : () { _page++; _reload(); }, child: const Text('Next'), ), ], ), ], ); } Widget _row(AdminTrack t, bool wide) { final small = Theme.of(context).textTheme.bodySmall; final quick = t.status == SongStatus.approved ? IconButton( tooltip: 'Hide from the app', onPressed: () => _setStatus([t.id], SongStatus.takenDown), icon: const Icon(Icons.visibility_off_outlined), ) : IconButton( tooltip: 'Approve (publish in the app)', onPressed: t.audioKey == null ? null : () => _setStatus([t.id], SongStatus.approved), icon: const Icon(Icons.check_circle_outline, color: NkColors.teaLeaf), ); return InkWell( onTap: () => _edit(t), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ Checkbox( value: _selected.contains(t.id), onChanged: (v) => setState(() => v == true ? _selected.add(t.id) : _selected.remove(t.id)), ), PlayButton(t), const SizedBox(width: 8), Expanded( flex: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700)), Text( wide ? creditLine(t) : '${creditLine(t)} · ${t.albumTitle ?? 'No album'}', maxLines: 1, overflow: TextOverflow.ellipsis, style: small?.copyWith(color: t.singerNames.isEmpty ? NkColors.info : null), ), ], ), ), if (wide) ...[ Expanded( flex: 3, child: Text(t.albumTitle ?? 'No album', maxLines: 1, overflow: TextOverflow.ellipsis, style: small?.copyWith(color: t.albumId == null ? NkColors.info : null)), ), SizedBox(width: 150, child: Text(occasions[t.occasion] ?? t.occasion, style: small)), SizedBox(width: 50, child: Text(formatDuration(t.duration), style: small)), ], SizedBox(width: 150, child: Align(alignment: Alignment.centerLeft, child: StatusChip(t.status))), quick, IconButton(tooltip: 'Edit', onPressed: () => _edit(t), icon: const Icon(Icons.edit_outlined)), ], ), ), ); } }