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>
This commit is contained in:
metatroncubeswdev 2026-10-30 14:23:43 -04:00
parent 7207da911c
commit d994fc733f
10 changed files with 294 additions and 7 deletions

View File

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'env.dart';
@ -60,6 +62,7 @@ class AdminTrack {
year = (r['year'] as num?)?.toInt(),
duration = Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
audioKey = r['audio_key_128'] as String?,
coverUrl = r['cover_url'] as String?,
status = SongStatus.fromId(r['status'] as String?),
sourceNote = r['source_note'] as String?,
reviewNote = r['review_note'] as String?,
@ -77,6 +80,7 @@ class AdminTrack {
final int? year;
final Duration duration;
final String? audioKey;
final String? coverUrl;
final SongStatus status;
final String? sourceNote;
final String? reviewNote;
@ -321,6 +325,35 @@ abstract final class AdminApi {
static Future<void> mergeAlbums(String from, String into) =>
_db.rpc('admin_merge_albums', params: {'p_from': from, 'p_into': into});
// ------------------------------------------------------------ cover photos
/// Uploads a cover for an album or song ([table] is 'albums' or 'tracks'), saves it and
/// deletes the old file. Each upload gets a new name so phones never show a stale cached cover.
static Future<String> setCover(String table, String id, Uint8List bytes, String mimeType) async {
final ext = switch (mimeType) { 'image/png' => 'png', 'image/webp' => 'webp', _ => 'jpg' };
final path = '${table == 'albums' ? 'album' : 'track'}/$id-${DateTime.now().millisecondsSinceEpoch}.$ext';
final bucket = _db.storage.from('covers');
await bucket.uploadBinary(path, bytes,
fileOptions: FileOptions(contentType: mimeType, cacheControl: '31536000'));
final url = bucket.getPublicUrl(path);
final old = await _db.from(table).select('cover_url').eq('id', id).single();
await _db.from(table).update({'cover_url': url}).eq('id', id);
await _deleteCoverFile(old['cover_url'] as String?);
return url;
}
static Future<void> removeCover(String table, String id) async {
final old = await _db.from(table).select('cover_url').eq('id', id).single();
await _db.from(table).update({'cover_url': null}).eq('id', id);
await _deleteCoverFile(old['cover_url'] as String?);
}
static Future<void> _deleteCoverFile(String? url) async {
const marker = '/storage/v1/object/public/covers/';
final at = url?.indexOf(marker) ?? -1;
if (at < 0) return;
await _db.storage.from('covers').remove([url!.substring(at + marker.length)]);
}
// ------------------------------------------------------------ singers
static Future<List<AdminArtist>> artists({String query = ''}) async {
var q = _db.from('admin_artists').select();

View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'api.dart';
import 'theme.dart';
import 'widgets.dart';
/// Cover photo for an album or song. Uploads and removes straight away (not on Save),
/// so the photo is live in the app as soon as it shows here.
class CoverEditor extends StatefulWidget {
const CoverEditor({super.key, required this.table, required this.id, required this.coverUrl, this.hint});
/// 'albums' or 'tracks'.
final String table;
final String id;
final String? coverUrl;
final String? hint;
@override
State<CoverEditor> createState() => _CoverEditorState();
}
class _CoverEditorState extends State<CoverEditor> {
late String? _url = widget.coverUrl;
bool _busy = false;
Future<void> _pick() async {
// Resized in the browser: covers show at most ~400 px wide in the app.
final file = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 1200, maxHeight: 1200, imageQuality: 85);
if (file == null) return;
await _run(() async {
final bytes = await file.readAsBytes();
if (bytes.length > 5 << 20) throw 'That photo is too large. Please choose one under 5 MB.';
final url = await AdminApi.setCover(widget.table, widget.id, bytes, file.mimeType ?? 'image/jpeg');
setState(() => _url = url);
}, 'Cover photo saved');
}
Future<void> _remove() => _run(() async {
await AdminApi.removeCover(widget.table, widget.id);
setState(() => _url = null);
}, 'Cover photo removed');
Future<void> _run(Future<void> Function() job, String done) async {
setState(() => _busy = true);
try {
await job();
if (mounted) showMessage(context, done);
} catch (e) {
if (mounted) showError(context, e);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.square(
dimension: 88,
child: _busy
? const Center(child: CircularProgressIndicator())
: _url == null
? const ColoredBox(color: NkColors.surface, child: Icon(Icons.image_outlined, color: NkColors.muted, size: 36))
: Image.network(_url!, fit: BoxFit.cover),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cover photo', style: Theme.of(context).textTheme.titleMedium),
Text(widget.hint ?? 'Square photos look best. JPG, PNG or WebP.',
style: const TextStyle(color: NkColors.muted)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _pick,
icon: const Icon(Icons.upload_outlined),
label: Text(_url == null ? 'Upload photo' : 'Change photo'),
),
if (_url != null)
TextButton(onPressed: _busy ? null : _remove, child: const Text('Remove')),
],
),
],
),
),
],
);
}

View File

@ -2,6 +2,7 @@ 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';
@ -41,6 +42,15 @@ class _AlbumsScreenState extends State<AlbumsScreen> {
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')),
@ -48,7 +58,10 @@ class _AlbumsScreenState extends State<AlbumsScreen> {
],
),
);
if (ok != true || title.text.trim().isEmpty) return;
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');
@ -123,7 +136,12 @@ class _AlbumsScreenState extends State<AlbumsScreen> {
for (final (i, a) in albums.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: const Icon(Icons.album, color: NkColors.muted),
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,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../cover_editor.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
@ -160,6 +161,12 @@ class _SongFormState extends State<_SongForm> {
if (t.status == SongStatus.pending && _status == SongStatus.pending)
const Text('Tip: set the status to "Live" to publish this song in the app.',
style: TextStyle(color: NkColors.muted)),
CoverEditor(
table: 'tracks',
id: t.id,
coverUrl: t.coverUrl,
hint: 'Optional. Without one, the app shows the album cover.',
),
_Facts(t),
],
),

View File

@ -72,7 +72,8 @@ class _SongsScreenState extends State<SongsScreen> {
}
Future<void> _edit(AdminTrack t) async {
if (await editSong(context, t)) _reload();
await editSong(context, t);
_reload(); // also after Cancel: the cover photo saves on its own
}
Future<void> _bulkEdit() async {

View File

@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
@ -153,6 +161,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba
url: "https://pub.dev"
source: hosted
version: "0.9.5+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
fixnum:
dependency: transitive
description:
@ -179,6 +219,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_test:
dependency: "direct dev"
description: flutter
@ -245,6 +293,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a
url: "https://pub.dev"
source: hosted
version: "1.2.4"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4"
url: "https://pub.dev"
source: hosted
version: "0.8.13+25"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b8f508306db2bf71141aba2de4ec13456338bf01e13f034bd2594f341a5f8eb2
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: transitive
description:

View File

@ -16,6 +16,7 @@ dependencies:
google_fonts: ^9.0.0
just_audio: ^0.10.6
url_launcher: ^6.3.0
image_picker: ^1.2.4
dev_dependencies:
flutter_test:

View File

@ -130,6 +130,8 @@ class SupabaseCatalogRepository implements CatalogRepository {
bio: r['bio'] as String?,
),
];
// Songs without their own cover show their album's.
final albumCovers = {for (final r in albumRows) r['id'] as String: r['cover_url'] as String?};
final tracks = [
for (final r in trackRows)
Track(
@ -145,7 +147,7 @@ class SupabaseCatalogRepository implements CatalogRepository {
occasion: Occasion.fromId(r['occasion'] as String?),
duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
audioUrl: '${Env.mediaBaseUrl}/${r['audio_key_128']}',
coverUrl: r['cover_url'] as String?,
coverUrl: r['cover_url'] as String? ?? albumCovers[r['album_id']],
tier: r['tier'] == 'premium' ? Tier.premium : Tier.free,
),
];

View File

@ -142,7 +142,8 @@ class OccasionCover extends StatelessWidget {
}
/// Singer photo, or initials in a dashed ring until a permitted photo exists.
/// Generated album cover in the occasion style, with a colour that stays the same per album.
/// Album cover photo, or a generated cover in the occasion style with a colour that stays
/// the same per album.
class AlbumCover extends StatelessWidget {
const AlbumCover({super.key, required this.album, required this.size});
@ -153,8 +154,15 @@ class AlbumCover extends StatelessWidget {
Widget build(BuildContext context) {
final palette = Occasion.values;
final hash = album.title.codeUnits.fold<int>(7, (h, c) => (h * 31 + c) & 0x7fffffff);
final pick = palette[hash % palette.length];
return OccasionCover(occasion: pick, size: size, label: 'Album', title: album.title);
final generated = OccasionCover(occasion: palette[hash % palette.length], size: size, label: 'Album', title: album.title);
if (album.coverUrl == null) return generated;
return CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (size * 3).round(),
placeholder: (_, _) => generated,
errorWidget: (_, _, _) => generated,
);
}
}

View File

@ -0,0 +1,9 @@
-- Cover photos for albums and songs: covers/album/<id>-<time>.<ext>, covers/track/<id>-<time>.<ext>.
-- Anyone can view them (the app shows them to guests too); only admins upload, replace or delete.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('covers', 'covers', true, 5242880, array['image/jpeg','image/png','image/webp'])
on conflict (id) do nothing;
create policy nk_admin_covers on storage.objects for all to authenticated
using (bucket_id = 'covers' and public.is_admin())
with check (bucket_id = 'covers' and public.is_admin());