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>
216 lines
6.7 KiB
Dart
216 lines
6.7 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../app/theme.dart';
|
|
import '../../core/models.dart';
|
|
|
|
/// Dark fade behind text on photos so it stays readable on any image.
|
|
class PhotoScrim extends StatelessWidget {
|
|
const PhotoScrim({super.key, this.begin = Alignment.topCenter, this.end = Alignment.bottomCenter, this.stops});
|
|
|
|
final Alignment begin;
|
|
final Alignment end;
|
|
final List<double>? stops;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: begin,
|
|
end: end,
|
|
stops: stops ?? const [0.3, 1],
|
|
colors: [NkColors.ground.withValues(alpha: 0), NkColors.ground.withValues(alpha: 0.9)],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Cover for a song: remote cover, bundled photo, or the generated occasion cover.
|
|
class TrackArtwork extends StatelessWidget {
|
|
const TrackArtwork({
|
|
super.key,
|
|
required this.coverUrl,
|
|
required this.coverAsset,
|
|
required this.occasion,
|
|
this.title,
|
|
this.size = 56,
|
|
this.radius = 10,
|
|
});
|
|
|
|
TrackArtwork.track(Track t, {Key? key, double size = 56, double radius = 10, bool showTitle = false})
|
|
: this(
|
|
key: key,
|
|
coverUrl: t.coverUrl,
|
|
coverAsset: t.coverAsset,
|
|
occasion: t.occasion,
|
|
title: showTitle ? t.titleLatin : null,
|
|
size: size,
|
|
radius: radius,
|
|
);
|
|
|
|
final String? coverUrl;
|
|
final String? coverAsset;
|
|
final Occasion occasion;
|
|
final String? title;
|
|
final double size;
|
|
final double radius;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Widget child;
|
|
if (coverUrl != null) {
|
|
child = CachedNetworkImage(
|
|
imageUrl: coverUrl!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => OccasionCover(occasion: occasion, size: size),
|
|
errorWidget: (_, _, _) => OccasionCover(occasion: occasion, size: size),
|
|
);
|
|
} else if (coverAsset != null) {
|
|
child = Image.asset(coverAsset!, fit: BoxFit.cover, cacheWidth: (size * 3).round());
|
|
} else {
|
|
child = OccasionCover(occasion: occasion, size: size, title: title);
|
|
}
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(radius),
|
|
child: SizedBox.square(dimension: size, child: child),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Generated cover: occasion colour, label and the white-cloth border stripe.
|
|
class OccasionCover extends StatelessWidget {
|
|
const OccasionCover({super.key, required this.occasion, required this.size, this.title, this.label});
|
|
|
|
final Occasion occasion;
|
|
final double size;
|
|
final String? title;
|
|
|
|
/// Top line; defaults to the occasion's name.
|
|
final String? label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final band = math.max(4.0, size * 0.06);
|
|
final showText = size >= 96;
|
|
return Container(
|
|
color: occasion.color,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (showText)
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(size * 0.08, size * 0.08, size * 0.08, band * 2.5),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
(label ?? occasion.label).toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: size * 0.07,
|
|
letterSpacing: 1.6,
|
|
fontWeight: FontWeight.w800,
|
|
color: NkColors.cream.withValues(alpha: 0.85),
|
|
),
|
|
),
|
|
if (title != null)
|
|
Text(
|
|
title!,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.11),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
Center(child: Icon(occasion.icon, color: NkColors.cream.withValues(alpha: 0.7), size: size * 0.4)),
|
|
Positioned(left: 0, right: 0, bottom: 0, height: band, child: const ColoredBox(color: NkColors.cream)),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: band * 0.3,
|
|
height: band * 0.3,
|
|
child: const ColoredBox(color: NkColors.ground),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Singer photo, or initials in a dashed ring until a permitted photo exists.
|
|
/// 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});
|
|
|
|
final Album album;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = Occasion.values;
|
|
final hash = album.title.codeUnits.fold<int>(7, (h, c) => (h * 31 + c) & 0x7fffffff);
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
class SingerAvatar extends StatelessWidget {
|
|
const SingerAvatar({super.key, required this.artist, this.size = 76});
|
|
|
|
final Artist artist;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (artist.photoUrl != null) {
|
|
return ClipOval(
|
|
child: CachedNetworkImage(imageUrl: artist.photoUrl!, width: size, height: size, fit: BoxFit.cover),
|
|
);
|
|
}
|
|
return CustomPaint(
|
|
painter: _DashedRingPainter(),
|
|
child: Container(
|
|
width: size,
|
|
height: size,
|
|
alignment: Alignment.center,
|
|
decoration: const BoxDecoration(color: NkColors.emblemGreen, shape: BoxShape.circle),
|
|
child: Text(
|
|
artist.initials,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.3),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashedRingPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = NkColors.teaLeaf
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2;
|
|
final rect = Offset.zero & size;
|
|
const dashes = 24;
|
|
const sweep = 2 * math.pi / dashes;
|
|
for (var i = 0; i < dashes; i++) {
|
|
canvas.drawArc(rect.deflate(1), i * sweep, sweep * 0.6, false, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|