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:
parent
7207da911c
commit
d994fc733f
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import 'env.dart';
|
import 'env.dart';
|
||||||
@ -60,6 +62,7 @@ class AdminTrack {
|
|||||||
year = (r['year'] as num?)?.toInt(),
|
year = (r['year'] as num?)?.toInt(),
|
||||||
duration = Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
|
duration = Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
|
||||||
audioKey = r['audio_key_128'] as String?,
|
audioKey = r['audio_key_128'] as String?,
|
||||||
|
coverUrl = r['cover_url'] as String?,
|
||||||
status = SongStatus.fromId(r['status'] as String?),
|
status = SongStatus.fromId(r['status'] as String?),
|
||||||
sourceNote = r['source_note'] as String?,
|
sourceNote = r['source_note'] as String?,
|
||||||
reviewNote = r['review_note'] as String?,
|
reviewNote = r['review_note'] as String?,
|
||||||
@ -77,6 +80,7 @@ class AdminTrack {
|
|||||||
final int? year;
|
final int? year;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
final String? audioKey;
|
final String? audioKey;
|
||||||
|
final String? coverUrl;
|
||||||
final SongStatus status;
|
final SongStatus status;
|
||||||
final String? sourceNote;
|
final String? sourceNote;
|
||||||
final String? reviewNote;
|
final String? reviewNote;
|
||||||
@ -321,6 +325,35 @@ abstract final class AdminApi {
|
|||||||
static Future<void> mergeAlbums(String from, String into) =>
|
static Future<void> mergeAlbums(String from, String into) =>
|
||||||
_db.rpc('admin_merge_albums', params: {'p_from': from, 'p_into': 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
|
// ------------------------------------------------------------ singers
|
||||||
static Future<List<AdminArtist>> artists({String query = ''}) async {
|
static Future<List<AdminArtist>> artists({String query = ''}) async {
|
||||||
var q = _db.from('admin_artists').select();
|
var q = _db.from('admin_artists').select();
|
||||||
|
|||||||
96
admin/lib/cover_editor.dart
Normal file
96
admin/lib/cover_editor.dart
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
|
import '../cover_editor.dart';
|
||||||
import '../pickers.dart';
|
import '../pickers.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets.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: title, autofocus: true, decoration: const InputDecoration(labelText: 'Title (English letters)')),
|
||||||
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
|
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
|
||||||
TextField(controller: year, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Year')),
|
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: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
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()));
|
final fields = (title.text.trim(), trimmedOrNull(tamil.text), int.tryParse(year.text.trim()));
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
await _run(AdminApi.createAlbum(title: fields.$1, titleTamil: fields.$2, year: fields.$3), 'Album added');
|
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) ...[
|
for (final (i, a) in albums.indexed) ...[
|
||||||
if (i > 0) const Divider(),
|
if (i > 0) const Divider(),
|
||||||
ListTile(
|
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)),
|
title: Text(a.title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||||
subtitle: Text([
|
subtitle: Text([
|
||||||
?a.titleTamil,
|
?a.titleTamil,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
|
import '../cover_editor.dart';
|
||||||
import '../pickers.dart';
|
import '../pickers.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets.dart';
|
import '../widgets.dart';
|
||||||
@ -160,6 +161,12 @@ class _SongFormState extends State<_SongForm> {
|
|||||||
if (t.status == SongStatus.pending && _status == SongStatus.pending)
|
if (t.status == SongStatus.pending && _status == SongStatus.pending)
|
||||||
const Text('Tip: set the status to "Live" to publish this song in the app.',
|
const Text('Tip: set the status to "Live" to publish this song in the app.',
|
||||||
style: TextStyle(color: NkColors.muted)),
|
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),
|
_Facts(t),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -72,7 +72,8 @@ class _SongsScreenState extends State<SongsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _edit(AdminTrack t) async {
|
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 {
|
Future<void> _bulkEdit() async {
|
||||||
|
|||||||
@ -105,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -153,6 +161,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -179,6 +219,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -245,6 +293,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -16,6 +16,7 @@ dependencies:
|
|||||||
google_fonts: ^9.0.0
|
google_fonts: ^9.0.0
|
||||||
just_audio: ^0.10.6
|
just_audio: ^0.10.6
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
|
image_picker: ^1.2.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -130,6 +130,8 @@ class SupabaseCatalogRepository implements CatalogRepository {
|
|||||||
bio: r['bio'] as String?,
|
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 = [
|
final tracks = [
|
||||||
for (final r in trackRows)
|
for (final r in trackRows)
|
||||||
Track(
|
Track(
|
||||||
@ -145,7 +147,7 @@ class SupabaseCatalogRepository implements CatalogRepository {
|
|||||||
occasion: Occasion.fromId(r['occasion'] as String?),
|
occasion: Occasion.fromId(r['occasion'] as String?),
|
||||||
duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
|
duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
|
||||||
audioUrl: '${Env.mediaBaseUrl}/${r['audio_key_128']}',
|
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,
|
tier: r['tier'] == 'premium' ? Tier.premium : Tier.free,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -142,7 +142,8 @@ class OccasionCover extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Singer photo, or initials in a dashed ring until a permitted photo exists.
|
/// 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 {
|
class AlbumCover extends StatelessWidget {
|
||||||
const AlbumCover({super.key, required this.album, required this.size});
|
const AlbumCover({super.key, required this.album, required this.size});
|
||||||
|
|
||||||
@ -153,8 +154,15 @@ class AlbumCover extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = Occasion.values;
|
final palette = Occasion.values;
|
||||||
final hash = album.title.codeUnits.fold<int>(7, (h, c) => (h * 31 + c) & 0x7fffffff);
|
final hash = album.title.codeUnits.fold<int>(7, (h, c) => (h * 31 + c) & 0x7fffffff);
|
||||||
final pick = palette[hash % palette.length];
|
final generated = OccasionCover(occasion: palette[hash % palette.length], size: size, label: 'Album', title: album.title);
|
||||||
return OccasionCover(occasion: pick, 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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
supabase/migrations/20261102000000_covers.sql
Normal file
9
supabase/migrations/20261102000000_covers.sql
Normal 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());
|
||||||
Loading…
x
Reference in New Issue
Block a user