From d994fc733fb7dafd926b53a0a648f9d4ae3b5184 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 30 Oct 2026 14:23:43 -0400 Subject: [PATCH] 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 --- admin/lib/api.dart | 33 ++++++ admin/lib/cover_editor.dart | 96 +++++++++++++++ admin/lib/screens/albums_screen.dart | 22 +++- admin/lib/screens/song_editor.dart | 7 ++ admin/lib/screens/songs_screen.dart | 3 +- admin/pubspec.lock | 112 ++++++++++++++++++ admin/pubspec.yaml | 1 + lib/data/catalog_repository.dart | 4 +- lib/shared/widgets/artwork.dart | 14 ++- supabase/migrations/20261102000000_covers.sql | 9 ++ 10 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 admin/lib/cover_editor.dart create mode 100644 supabase/migrations/20261102000000_covers.sql diff --git a/admin/lib/api.dart b/admin/lib/api.dart index 5774bbd..e1860b1 100644 --- a/admin/lib/api.dart +++ b/admin/lib/api.dart @@ -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 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 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 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 _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> artists({String query = ''}) async { var q = _db.from('admin_artists').select(); diff --git a/admin/lib/cover_editor.dart b/admin/lib/cover_editor.dart new file mode 100644 index 0000000..dc468af --- /dev/null +++ b/admin/lib/cover_editor.dart @@ -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 createState() => _CoverEditorState(); +} + +class _CoverEditorState extends State { + late String? _url = widget.coverUrl; + bool _busy = false; + + Future _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 _remove() => _run(() async { + await AdminApi.removeCover(widget.table, widget.id); + setState(() => _url = null); + }, 'Cover photo removed'); + + Future _run(Future 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')), + ], + ), + ], + ), + ), + ], + ); +} diff --git a/admin/lib/screens/albums_screen.dart b/admin/lib/screens/albums_screen.dart index 88c519e..f236560 100644 --- a/admin/lib/screens/albums_screen.dart +++ b/admin/lib/screens/albums_screen.dart @@ -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 { 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 { ], ), ); - 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 { 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, diff --git a/admin/lib/screens/song_editor.dart b/admin/lib/screens/song_editor.dart index 2d45483..0da9f94 100644 --- a/admin/lib/screens/song_editor.dart +++ b/admin/lib/screens/song_editor.dart @@ -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), ], ), diff --git a/admin/lib/screens/songs_screen.dart b/admin/lib/screens/songs_screen.dart index 94b7461..0fa163c 100644 --- a/admin/lib/screens/songs_screen.dart +++ b/admin/lib/screens/songs_screen.dart @@ -72,7 +72,8 @@ class _SongsScreenState extends State { } Future _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 _bulkEdit() async { diff --git a/admin/pubspec.lock b/admin/pubspec.lock index cd68da4..1fc2c96 100644 --- a/admin/pubspec.lock +++ b/admin/pubspec.lock @@ -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: diff --git a/admin/pubspec.yaml b/admin/pubspec.yaml index 707ccce..8008f72 100644 --- a/admin/pubspec.yaml +++ b/admin/pubspec.yaml @@ -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: diff --git a/lib/data/catalog_repository.dart b/lib/data/catalog_repository.dart index 2efb5cf..e32e8ae 100644 --- a/lib/data/catalog_repository.dart +++ b/lib/data/catalog_repository.dart @@ -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, ), ]; diff --git a/lib/shared/widgets/artwork.dart b/lib/shared/widgets/artwork.dart index 32349ea..6b42d9b 100644 --- a/lib/shared/widgets/artwork.dart +++ b/lib/shared/widgets/artwork.dart @@ -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(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, + ); } } diff --git a/supabase/migrations/20261102000000_covers.sql b/supabase/migrations/20261102000000_covers.sql new file mode 100644 index 0000000..2f2e1e0 --- /dev/null +++ b/supabase/migrations/20261102000000_covers.sql @@ -0,0 +1,9 @@ +-- Cover photos for albums and songs: covers/album/-