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>
302 lines
10 KiB
Dart
302 lines
10 KiB
Dart
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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|