Files
SpotiFLAC-Mobile/lib/widgets/mornye_artwork_contrast.dart
T

136 lines
4.2 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Samples rendered video and its fade, rather than a static album palette.
/// Read back only 48 pixels across, at most three times a second when visible.
class MornyeArtworkContrast extends StatefulWidget {
const MornyeArtworkContrast({
super.key,
required this.enabled,
required this.targets,
required this.onChanged,
required this.child,
});
final bool enabled;
final Map<String, GlobalKey> targets;
final ValueChanged<Map<String, Color>> onChanged;
final Widget child;
@override
State<MornyeArtworkContrast> createState() => _MornyeArtworkContrastState();
}
class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
final _background = GlobalKey();
Timer? _timer;
bool _sampling = false;
Map<String, Color> _colors = {};
@override
void initState() {
super.initState();
_schedule();
}
@override
void didUpdateWidget(MornyeArtworkContrast oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.enabled != widget.enabled) _schedule();
}
void _schedule() {
_timer?.cancel();
if (!widget.enabled) {
_colors = {};
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _sample());
_timer = Timer.periodic(
const Duration(milliseconds: 333),
(_) => _sample(),
);
}
Future<void> _sample() async {
if (!mounted || !widget.enabled || _sampling) return;
final lifecycle = WidgetsBinding.instance.lifecycleState;
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return;
final route = ModalRoute.of(context);
if (route != null &&
(!route.isCurrent || route.animation?.isAnimating == true)) {
return;
}
final boundary = _background.currentContext?.findRenderObject();
if (boundary is! RenderRepaintBoundary ||
!boundary.hasSize ||
boundary.debugNeedsPaint ||
boundary.size.isEmpty) {
return;
}
_sampling = true;
ui.Image? image;
try {
final scale = (48 / boundary.size.width).clamp(0.01, 1.0);
image = await boundary.toImage(pixelRatio: scale);
final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (!mounted || !widget.enabled || pixels == null) return;
final origin = boundary.localToGlobal(Offset.zero);
final next = <String, Color>{};
for (final entry in widget.targets.entries) {
final target = entry.value.currentContext?.findRenderObject();
if (target is! RenderBox || !target.hasSize || !target.attached) {
continue;
}
final rect = target.localToGlobal(Offset.zero) - origin & target.size;
final left = (rect.left * scale).floor().clamp(0, image.width);
final right = (rect.right * scale).ceil().clamp(0, image.width);
final top = (rect.top * scale).floor().clamp(0, image.height);
final bottom = (rect.bottom * scale).ceil().clamp(0, image.height);
var luminance = 0.0;
var count = 0;
for (var y = top; y < bottom; y++) {
for (var x = left; x < right; x++) {
final index = (y * image.width + x) * 4;
luminance += Color.fromARGB(
255,
pixels.getUint8(index),
pixels.getUint8(index + 1),
pixels.getUint8(index + 2),
).computeLuminance();
count++;
}
}
if (count == 0) continue;
// Hysteresis avoids flicker when frames hover around the crossover.
final threshold = _colors[entry.key] == Colors.black ? 0.16 : 0.20;
next[entry.key] = luminance / count > threshold
? Colors.black
: Colors.white;
}
if (next.entries.any((entry) => _colors[entry.key] != entry.value)) {
_colors = next;
widget.onChanged(next);
}
} catch (_) {
// Unreadable surfaces keep the light controls over the dark fade.
} finally {
image?.dispose();
_sampling = false;
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) =>
RepaintBoundary(key: _background, child: widget.child);
}