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>
180 lines
7.7 KiB
Dart
180 lines
7.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api.dart';
|
|
import '../cover_editor.dart';
|
|
import '../pickers.dart';
|
|
import '../theme.dart';
|
|
import '../widgets.dart';
|
|
|
|
class AlbumsScreen extends StatefulWidget {
|
|
const AlbumsScreen({super.key});
|
|
|
|
@override
|
|
State<AlbumsScreen> createState() => _AlbumsScreenState();
|
|
}
|
|
|
|
class _AlbumsScreenState extends State<AlbumsScreen> {
|
|
String _query = '';
|
|
late Future<List<AdminAlbum>> _data = AdminApi.albums();
|
|
|
|
void _reload() => setState(() => _data = AdminApi.albums(query: _query));
|
|
|
|
Future<void> _run(Future<void> job, String done) async {
|
|
try {
|
|
await job;
|
|
if (mounted) showMessage(context, done);
|
|
_reload();
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
}
|
|
}
|
|
|
|
Future<void> _edit([AdminAlbum? album]) async {
|
|
final title = TextEditingController(text: album?.title ?? '');
|
|
final tamil = TextEditingController(text: album?.titleTamil ?? '');
|
|
final year = TextEditingController(text: album?.year?.toString() ?? '');
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(album == null ? 'New album' : 'Edit album'),
|
|
content: DialogBody(children: [
|
|
TextField(controller: title, autofocus: true, decoration: const InputDecoration(labelText: 'Title (English letters)')),
|
|
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
|
|
TextField(controller: year, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Year')),
|
|
if (album != null)
|
|
CoverEditor(
|
|
table: 'albums',
|
|
id: album.id,
|
|
coverUrl: album.coverUrl,
|
|
hint: 'Shown for the album and for its songs that have no cover of their own.',
|
|
)
|
|
else
|
|
const Text('You can add a cover photo after saving.', style: TextStyle(color: NkColors.muted)),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Save')),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true || title.text.trim().isEmpty) {
|
|
if (album != null) _reload(); // the cover may have changed
|
|
return;
|
|
}
|
|
final fields = (title.text.trim(), trimmedOrNull(tamil.text), int.tryParse(year.text.trim()));
|
|
if (album == null) {
|
|
await _run(AdminApi.createAlbum(title: fields.$1, titleTamil: fields.$2, year: fields.$3), 'Album added');
|
|
} else {
|
|
await _run(AdminApi.updateAlbum(album.id, title: fields.$1, titleTamil: fields.$2, year: fields.$3),
|
|
'Album saved');
|
|
}
|
|
}
|
|
|
|
Future<void> _merge(AdminAlbum from, List<AdminAlbum> all) async {
|
|
String? into;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setLocal) => AlertDialog(
|
|
title: Text('Merge "${from.title}"'),
|
|
content: DialogBody(children: [
|
|
Text('Its ${from.trackCount} songs move into the album you choose, then "${from.title}" is deleted. '
|
|
'Use this for duplicates.'),
|
|
AlbumPicker(
|
|
albums: [for (final a in all) if (a.id != from.id) a],
|
|
albumId: into,
|
|
onChanged: (v) => setLocal(() => into = v),
|
|
),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(onPressed: into == null ? null : () => Navigator.pop(context, true), child: const Text('Merge')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (ok == true && into != null) await _run(AdminApi.mergeAlbums(from.id, into!), 'Albums merged');
|
|
}
|
|
|
|
Future<void> _delete(AdminAlbum a) async {
|
|
final body = a.trackCount == 0
|
|
? 'This album has no songs.'
|
|
: 'Its ${a.trackCount} songs stay in the catalog without an album.';
|
|
if (await confirm(context, 'Delete "${a.title}"?', body, action: 'Delete', danger: true)) {
|
|
await _run(AdminApi.deleteAlbum(a.id), 'Album deleted');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
PageHeader('Albums', subtitle: 'Rename, merge duplicates and open their songs', actions: [
|
|
SearchField(
|
|
hint: 'Search albums',
|
|
onChanged: (q) {
|
|
_query = q;
|
|
_reload();
|
|
},
|
|
),
|
|
FilledButton.icon(onPressed: _edit, icon: const Icon(Icons.add), label: const Text('New album')),
|
|
]),
|
|
Expanded(
|
|
child: Loader(
|
|
future: _data,
|
|
onRetry: _reload,
|
|
builder: (context, albums) => albums.isEmpty
|
|
? const EmptyState('No albums found.', icon: Icons.album_outlined)
|
|
: ListView(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
|
children: [
|
|
Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
for (final (i, a) in albums.indexed) ...[
|
|
if (i > 0) const Divider(),
|
|
ListTile(
|
|
leading: a.coverUrl == null
|
|
? const Icon(Icons.album, color: NkColors.muted)
|
|
: ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: Image.network(a.coverUrl!, width: 40, height: 40, fit: BoxFit.cover),
|
|
),
|
|
title: Text(a.title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
|
subtitle: Text([
|
|
?a.titleTamil,
|
|
if (a.year != null) '${a.year}',
|
|
'${a.trackCount} songs, ${a.approvedCount} live',
|
|
].join(' · ')),
|
|
onTap: () => context.go(Uri(path: '/songs',
|
|
queryParameters: {'album': a.id, 'name': a.title}).toString()),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (a.trackCount == 0) const Tag('Empty'),
|
|
IconButton(tooltip: 'Edit', onPressed: () => _edit(a), icon: const Icon(Icons.edit_outlined)),
|
|
PopupMenuButton<String>(
|
|
tooltip: 'More',
|
|
onSelected: (v) => v == 'merge' ? _merge(a, albums) : _delete(a),
|
|
itemBuilder: (context) => const [
|
|
PopupMenuItem(value: 'merge', child: Text('Merge into another album…')),
|
|
PopupMenuItem(value: 'delete', child: Text('Delete')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|