Files
SpotiFLAC-Mobile/lib/theme/cover_palette.dart
T

276 lines
8.8 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
/// Colour scheme derived from cover art, used to theme detail-screen headers.
///
/// The generated scheme follows app brightness and supplies contrasting
/// on-colours for the artwork-derived surface.
class CoverPalette {
const CoverPalette._();
/// Quantizing an image is not cheap, so results are memoized per
/// URL+brightness. Bounded because a long library-browsing session would
/// otherwise keep every visited album's scheme alive.
static final Map<String, ColorScheme> _cache = <String, ColorScheme>{};
static final Map<String, Color> _sourceColors = {};
static final Map<String, Future<ColorScheme?>> _pending = {};
static final List<String> _cacheOrder = <String>[];
static const int _maxEntries = 32;
static bool _isNetworkSource(String source) =>
source.startsWith('http://') || source.startsWith('https://');
/// Includes the local file version so replacing artwork at the same path
/// cannot reuse a palette derived from the previous image.
static String cacheKeyFor(String source, Brightness brightness) {
var versionedSource = source;
if (!_isNetworkSource(source)) {
try {
final stat = File(source).statSync();
if (stat.type != FileSystemEntityType.notFound) {
versionedSource =
'$source|${stat.modified.microsecondsSinceEpoch}|${stat.size}';
}
} catch (_) {
// Resolution below will return null for inaccessible local files.
}
}
return '$versionedSource|${brightness.name}';
}
/// Cached scheme for [source], or null when it has not been resolved yet.
static ColorScheme? peek(String source, Brightness brightness) =>
_cache[cacheKeyFor(source, brightness)];
/// Average cover colour before Material's accent selection or tonal mapping.
/// Near-monochrome covers stay neutral instead of acquiring a seed hue.
static Color? sourceColor(String source, Brightness brightness) =>
_sourceColors[cacheKeyFor(source, brightness)];
static ColorScheme? _peekByKey(String key) => _cache[key];
/// Resolves the scheme for [source] (a network URL or a local file path).
/// Returns null when the image cannot be decoded.
static Future<ColorScheme?> resolve(
String source,
Brightness brightness, {
String? cacheKey,
}) {
final key = cacheKey ?? cacheKeyFor(source, brightness);
final cached = _cache[key];
if (cached != null) return Future.value(cached);
return _pending.putIfAbsent(
key,
() => _resolve(source, brightness, key).whenComplete(() {
_pending.remove(key);
}),
);
}
static Future<ColorScheme?> _resolve(
String source,
Brightness brightness,
String key,
) async {
final ImageProvider provider;
if (_isNetworkSource(source)) {
provider = cachedCoverImageProvider(source);
} else {
final file = File(source);
if (!file.existsSync()) return null;
provider = FileImage(file);
}
try {
// Both samplers share this bounded decode through Flutter's image cache.
final sample = ResizeImage(
provider,
width: 112,
height: 112,
policy: ResizeImagePolicy.fit,
);
final scheme = await ColorScheme.fromImageProvider(
provider: sample,
brightness: brightness,
);
final sourceColor = await _sampleSourceColor(sample);
if (sourceColor != null) _sourceColors[key] = sourceColor;
_cache[key] = scheme;
_cacheOrder.add(key);
while (_cacheOrder.length > _maxEntries) {
final oldest = _cacheOrder.removeAt(0);
_cache.remove(oldest);
_sourceColors.remove(oldest);
}
return scheme;
} catch (_) {
// Unreachable URL, unsupported format, decode failure: callers fall back
// to the app scheme.
return null;
}
}
static Future<Color?> _sampleSourceColor(ImageProvider provider) {
final result = Completer<Color?>();
final stream = provider.resolve(ImageConfiguration.empty);
late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) async {
stream.removeListener(listener);
try {
final data = await info.image.toByteData(
format: ui.ImageByteFormat.rawStraightRgba,
);
if (data == null) {
result.complete(null);
return;
}
var red = 0.0;
var green = 0.0;
var blue = 0.0;
var weight = 0.0;
for (var index = 0; index < data.lengthInBytes; index += 4) {
final alpha = data.getUint8(index + 3) / 255;
red += data.getUint8(index) * alpha;
green += data.getUint8(index + 1) * alpha;
blue += data.getUint8(index + 2) * alpha;
weight += alpha;
}
if (weight == 0) {
result.complete(null);
return;
}
red /= weight;
green /= weight;
blue /= weight;
// A very dark navy pixel can have high HSL saturation despite being
// visually black. Compare channel differences before using its hue.
final spread =
math.max(red, math.max(green, blue)) -
math.min(red, math.min(green, blue));
if (spread < 16) {
final gray = (red * 0.2126 + green * 0.7152 + blue * 0.0722)
.round();
result.complete(Color.fromARGB(255, gray, gray, gray));
} else {
result.complete(
Color.fromARGB(255, red.round(), green.round(), blue.round()),
);
}
} catch (_) {
result.complete(null);
} finally {
info.dispose();
}
},
onError: (Object error, StackTrace? stack) {
stream.removeListener(listener);
result.complete(null);
},
);
stream.addListener(listener);
return result.future;
}
}
/// Exposes the header's effective [ColorScheme] to descendants.
///
/// Header sub-widgets (meta rows, circle buttons, play actions) live in the
/// screens' own build methods, so they cannot be handed the palette directly;
/// they read it from here and fall back to the app scheme when absent.
class HeaderPalette extends InheritedWidget {
const HeaderPalette({super.key, required this.scheme, required super.child});
final ColorScheme scheme;
static ColorScheme of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<HeaderPalette>()?.scheme ??
Theme.of(context).colorScheme;
@override
bool updateShouldNotify(HeaderPalette oldWidget) =>
scheme != oldWidget.scheme;
}
/// Resolves [imageSource] into a [ColorScheme] and rebuilds when it arrives.
class CoverPaletteBuilder extends StatefulWidget {
const CoverPaletteBuilder({
super.key,
required this.imageSource,
required this.builder,
});
/// Cover URL or local path. Null disables palette extraction.
final String? imageSource;
final Widget Function(BuildContext context, ColorScheme scheme) builder;
@override
State<CoverPaletteBuilder> createState() => _CoverPaletteBuilderState();
}
class _CoverPaletteBuilderState extends State<CoverPaletteBuilder> {
ColorScheme? _scheme;
String? _resolvedKey;
int _resolveGeneration = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_maybeResolve();
}
@override
void didUpdateWidget(CoverPaletteBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-check local file identity even when its path did not change. Metadata
// editing can replace cover bytes in place.
_maybeResolve();
}
void _maybeResolve() {
final source = widget.imageSource;
final brightness = Theme.of(context).brightness;
if (source == null || source.isEmpty) {
_resolveGeneration++;
_resolvedKey = null;
_scheme = null;
return;
}
final key = CoverPalette.cacheKeyFor(source, brightness);
if (_resolvedKey == key) return;
final requestGeneration = ++_resolveGeneration;
_resolvedKey = key;
final cached = CoverPalette._peekByKey(key);
if (cached != null) {
_scheme = cached;
return;
}
_scheme = null;
CoverPalette.resolve(source, brightness, cacheKey: key).then((scheme) {
if (!mounted) return;
if (_resolveGeneration != requestGeneration || _resolvedKey != key) {
return;
}
setState(() => _scheme = scheme);
});
}
@override
Widget build(BuildContext context) {
final scheme = _scheme ?? Theme.of(context).colorScheme;
return HeaderPalette(
scheme: scheme,
child: widget.builder(context, scheme),
);
}
}