nkbmusic/admin/lib/screens/songs_screen.dart
metatroncubeswdev d994fc733f Cover photos for albums and songs: upload in NK Admin, shown in the app
Songs without their own cover use their album's. Covers live in a public
'covers' bucket that only admins can write to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:23:43 -04:00

302 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SongsScreen> createState() => _SongsScreenState();
}
class _SongsScreenState extends State<SongsScreen> {
static const _pageSize = 50;
late SongFilter _filter = widget.filter;
late String _query = widget.query;
bool _newestFirst = true;
int _page = 0;
final _selected = <String>{};
late Future<ResultPage<AdminTrack>> _data = _load();
Future<ResultPage<AdminTrack>> _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<void> _setStatus(List<String> 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<void> _edit(AdminTrack t) async {
await editSong(context, t);
_reload(); // also after Cancel: the cover photo saves on its own
}
Future<void> _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<AdminTrack> 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)),
],
),
),
);
}
}