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 _cache = {}; static final Map _sourceColors = {}; static final Map> _pending = {}; static final List _cacheOrder = []; 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 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 _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 _sampleSourceColor(ImageProvider provider) { final result = Completer(); 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()?.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 createState() => _CoverPaletteBuilderState(); } class _CoverPaletteBuilderState extends State { 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), ); } }