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>
97 lines
3.4 KiB
Dart
97 lines
3.4 KiB
Dart
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')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|