95 lines
3.2 KiB
Dart
95 lines
3.2 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../theme/tokens.dart';
|
|
|
|
/// Cover art that renders from EITHER a local file (`file://` URI) or a network
|
|
/// URL, picking [FileImage] vs [NetworkImage] purely by the URI scheme. This is
|
|
/// the single place all art-rendering call sites go through, so a downloaded
|
|
/// track's art shows offline (the caller hands us the already-built URI string;
|
|
/// resolving download-vs-network happens upstream in `providers.dart`).
|
|
///
|
|
/// Behaviour preserved from the old scattered `Image.network(...)` call sites:
|
|
/// * `fit: BoxFit.cover`, `gaplessPlayback: true`, and a `ValueKey(uri)` so
|
|
/// switching tracks keeps the previous frame until the new art decodes (no
|
|
/// flash) and rebuilds cleanly.
|
|
/// * a surface-filled [placeholder] with a muted album icon for the null,
|
|
/// loading-error, and missing-file cases (via `errorBuilder`).
|
|
///
|
|
/// Sizing is never hardcoded — [width]/[height]/[fit] are respected as passed.
|
|
/// Optional [borderRadius] clips the art with a [ClipRRect]; callers that pass
|
|
/// it should drop their own outer `ClipRRect` so we don't double-clip.
|
|
class ArtImage extends StatelessWidget {
|
|
const ArtImage(
|
|
this.uri, {
|
|
super.key,
|
|
this.fit = BoxFit.cover,
|
|
this.width,
|
|
this.height,
|
|
this.placeholder,
|
|
this.borderRadius,
|
|
});
|
|
|
|
/// The already-built art URI. A `file://` URI loads from disk; anything else
|
|
/// (http/https) loads over the network. `null` → [placeholder].
|
|
final String? uri;
|
|
|
|
final BoxFit fit;
|
|
final double? width;
|
|
final double? height;
|
|
|
|
/// Shown for null/error/missing art. Defaults to [_ArtPlaceholder].
|
|
final Widget? placeholder;
|
|
|
|
/// If set, the art (and placeholder) are clipped to these rounded corners.
|
|
final BorderRadius? borderRadius;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fallback = placeholder ?? const _ArtPlaceholder();
|
|
|
|
Widget child;
|
|
if (uri == null) {
|
|
child = fallback;
|
|
} else {
|
|
final parsed = Uri.tryParse(uri!);
|
|
final ImageProvider provider = (parsed != null && parsed.scheme == 'file')
|
|
? FileImage(File(parsed.toFilePath()))
|
|
: NetworkImage(uri!);
|
|
child = Image(
|
|
image: provider,
|
|
key: ValueKey(uri),
|
|
fit: fit,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => fallback,
|
|
);
|
|
}
|
|
|
|
// Back the art with the surface fill so transparent/loading gaps read as
|
|
// a panel rather than the bare canvas (matches the old ColoredBox wrap).
|
|
child = ColoredBox(color: TimbreColors.surface, child: child);
|
|
|
|
if (width != null || height != null) {
|
|
child = SizedBox(width: width, height: height, child: child);
|
|
}
|
|
if (borderRadius != null) {
|
|
child = ClipRRect(borderRadius: borderRadius!, child: child);
|
|
}
|
|
return child;
|
|
}
|
|
}
|
|
|
|
/// The default art placeholder: a muted album glyph on the surface fill. Used
|
|
/// for null art and as the loading/error fallback.
|
|
class _ArtPlaceholder extends StatelessWidget {
|
|
const _ArtPlaceholder();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ColoredBox(
|
|
color: TimbreColors.surface,
|
|
child: Center(
|
|
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed),
|
|
),
|
|
);
|
|
}
|