diff --git a/lib/src/utils/image.dart b/lib/src/utils/image.dart index 761d230f08..c79603ab7b 100644 --- a/lib/src/utils/image.dart +++ b/lib/src/utils/image.dart @@ -1,17 +1,20 @@ import 'dart:async'; import 'dart:isolate'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:image/image.dart' as img; import 'package:material_color_utilities/material_color_utilities.dart'; typedef ImageColors = ({ + Uint8List image, int primaryContainer, int onPrimaryContainer, int error, }); -/// A worker that calculates the `primaryContainer` color of a remote image. +/// A worker that quantizes an image and returns a minimal color scheme associated +/// with the image. /// /// The worker is created by calling [ImageColorWorker.spawn], and the computation /// is run in a separate isolate. @@ -24,12 +27,21 @@ class ImageColorWorker { bool get closed => _closed; - Future getImageColors(String url) async { + /// Returns a minimal color scheme associated with the image at the given [url]. + /// + /// The [fileExtension] parameter is optional and is used to specify the file + /// extension of the image at the given [url] if it is known. It will speed up + /// the decoding process, as otherwise the worker will check the image data + /// against all supported decoders. + Future getImageColors( + String url, { + String? fileExtension, + }) async { if (_closed) throw StateError('Closed'); final completer = Completer.sync(); final id = _idCounter++; _activeRequests[id] = completer; - _commands.send((id, url)); + _commands.send((id, url, fileExtension)); return await completer.future; } @@ -85,21 +97,28 @@ class ImageColorWorker { receivePort.close(); return; } - final (int id, String url) = message as (int, String); + final (int id, String url, String? extension) = + message as (int, String, String?); try { final bytes = await http.readBytes(Uri.parse(url)); - final image = img.decodeImage(bytes); + // final stopwatch0 = Stopwatch()..start(); + final decoder = extension != null + ? img.findDecoderForNamedImage('.$extension') + : img.findDecoderForData(bytes); + final image = decoder!.decode(bytes); final resized = img.copyResize(image!, width: 112); final QuantizerResult quantizerResult = await QuantizerCelebi().quantize( resized.buffer.asUint32List(), 32, ); + // debugPrint( + // 'Decoding and quantization took: ${stopwatch0.elapsedMilliseconds}ms', + // ); final Map colorToCount = quantizerResult.colorToCount.map( (int key, int value) => MapEntry(_getArgbFromAbgr(key), value), ); - // Score colors for color scheme suitability. final List scoredResults = Score.score( colorToCount, desired: 1, @@ -107,17 +126,21 @@ class ImageColorWorker { filter: false, ); final Hct sourceColor = Hct.fromInt(scoredResults.first); + if (sourceColor.tone > 90.0) { + sourceColor.tone = 90.0; + } final scheme = SchemeFidelity( sourceColorHct: sourceColor, isDark: false, contrastLevel: 0.0, ); - final colors = ( + final result = ( + image: bytes, primaryContainer: scheme.primaryContainer, onPrimaryContainer: scheme.onPrimaryContainer, error: scheme.error, ); - sendPort.send((id, colors)); + sendPort.send((id, result)); } catch (e) { sendPort.send((id, RemoteError(e.toString(), ''))); } diff --git a/lib/src/view/broadcast/broadcast_list_screen.dart b/lib/src/view/broadcast/broadcast_list_screen.dart index a55720a012..5137929664 100644 --- a/lib/src/view/broadcast/broadcast_list_screen.dart +++ b/lib/src/view/broadcast/broadcast_list_screen.dart @@ -134,7 +134,7 @@ class _BodyState extends ConsumerState<_Body> { crossAxisCount: itemsByRow, crossAxisSpacing: 10, mainAxisSpacing: 10, - childAspectRatio: 1.3, + childAspectRatio: 1.45, ); final sections = [ @@ -258,6 +258,8 @@ final Map _colorsCache = {}; class _BroadcastGridItemState extends State { _CardColors? _cardColors; + ImageProvider? _imageProvider; + bool _tapDown = false; String? get imageUrl => widget.broadcast.tour.imageUrl; @@ -270,9 +272,12 @@ class _BroadcastGridItemState extends State { final cachedColors = _colorsCache[image]; if (cachedColors != null) { _cardColors = cachedColors; + _imageProvider = image; } else { if (imageUrl != null) { - _fetchImageAndColors(image as NetworkImage); + _fetchImageAndColors(NetworkImage(imageUrl!)); + } else { + _imageProvider = kDefaultBroadcastImage; } } } @@ -285,9 +290,11 @@ class _BroadcastGridItemState extends State { scheduleMicrotask(() => _fetchImageAndColors(provider)); }); } else if (widget.worker.closed == false) { - final response = await widget.worker.getImageColors(provider.url); + final response = await widget.worker + .getImageColors(provider.url, fileExtension: 'webp'); if (response != null) { - final (:primaryContainer, :onPrimaryContainer, :error) = response; + final (:image, :primaryContainer, :onPrimaryContainer, :error) = + response; final cardColors = ( primaryContainer: Color(primaryContainer), onPrimaryContainer: Color(onPrimaryContainer), @@ -296,6 +303,7 @@ class _BroadcastGridItemState extends State { _colorsCache[provider] = cardColors; if (mounted) { setState(() { + _imageProvider = MemoryImage(image); _cardColors = cardColors; }); } @@ -303,6 +311,14 @@ class _BroadcastGridItemState extends State { } } + void _onTapDown() { + setState(() => _tapDown = true); + } + + void _onTapCancel() { + setState(() => _tapDown = false); + } + @override Widget build(BuildContext context) { final defaultBackgroundColor = @@ -327,98 +343,76 @@ class _BroadcastGridItemState extends State { BroadcastRoundScreen(broadcast: widget.broadcast), ); }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 500), - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: kBroadcastGridItemBorderRadius, - color: backgroundColor, - boxShadow: Theme.of(context).platform == TargetPlatform.iOS - ? null - : kElevationToShadow[1], - ), - foregroundDecoration: BoxDecoration( - border: (widget.broadcast.isLive) - ? Border.all( - color: LichessColors.red.withValues(alpha: 0.7), - width: 3, - ) - : null, - borderRadius: kBroadcastGridItemBorderRadius, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShaderMask( - blendMode: BlendMode.dstOut, - shaderCallback: (bounds) { - return LinearGradient( - begin: Alignment.center, - end: Alignment.bottomCenter, - colors: [ - backgroundColor.withValues(alpha: 0.0), - backgroundColor.withValues(alpha: 1.0), - ], - stops: const [0.7, 1.10], - tileMode: TileMode.clamp, - ).createShader(bounds); - }, - child: AspectRatio( - aspectRatio: 2.0, - child: Image( - image: image, - frameBuilder: - (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded) { - return child; - } - return AnimatedOpacity( - duration: const Duration(milliseconds: 500), - opacity: frame == null ? 0 : 1, - child: child, - ); - }, - errorBuilder: (context, error, stackTrace) => - const Image(image: kDefaultBroadcastImage), + onTapDown: (_) => _onTapDown(), + onTapCancel: _onTapCancel, + onTapUp: (_) => _onTapCancel(), + child: AnimatedOpacity( + opacity: _tapDown ? 1.0 : 0.85, + duration: const Duration(milliseconds: 100), + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: kBroadcastGridItemBorderRadius, + color: backgroundColor, + boxShadow: Theme.of(context).platform == TargetPlatform.iOS + ? null + : kElevationToShadow[1], + ), + child: Stack( + children: [ + ShaderMask( + blendMode: BlendMode.dstOut, + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.center, + end: Alignment.bottomCenter, + colors: [ + backgroundColor.withValues(alpha: 0.0), + backgroundColor.withValues(alpha: 1.0), + ], + stops: const [0.5, 1.10], + tileMode: TileMode.clamp, + ).createShader(bounds); + }, + child: AspectRatio( + aspectRatio: 2.0, + child: _imageProvider != null + ? Image( + image: _imageProvider!, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) { + return child; + } + return AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: frame == null ? 0 : 1, + child: child, + ); + }, + errorBuilder: (context, error, stackTrace) => + const Image(image: kDefaultBroadcastImage), + ) + : const SizedBox.shrink(), ), ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.broadcast.round.startsAt != null || - widget.broadcast.isLive) - Padding( - padding: kBroadcastGridItemContentPadding, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.broadcast.round.name, - style: TextStyle( - fontSize: 12, - color: subTitleColor, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - const SizedBox(width: 4.0), - if (widget.broadcast.isLive) - Text( - 'LIVE', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: liveColor, - ), - overflow: TextOverflow.ellipsis, - ) - else + Positioned( + left: 0, + right: 0, + bottom: 12.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.broadcast.round.startsAt != null || + widget.broadcast.isLive) + Padding( + padding: kBroadcastGridItemContentPadding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ Text( - _formatDate(widget.broadcast.round.startsAt!), + widget.broadcast.round.name, style: TextStyle( fontSize: 12, color: subTitleColor, @@ -426,27 +420,65 @@ class _BroadcastGridItemState extends State { overflow: TextOverflow.ellipsis, maxLines: 1, ), - ], + const SizedBox(width: 4.0), + if (widget.broadcast.isLive) + Text( + 'LIVE', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: liveColor, + ), + overflow: TextOverflow.ellipsis, + ) + else + Text( + _formatDate(widget.broadcast.round.startsAt!), + style: TextStyle( + fontSize: 12, + color: subTitleColor, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), ), - ), - const SizedBox(height: 4.0), - Padding( - padding: kBroadcastGridItemContentPadding, - child: Text( - widget.broadcast.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: titleColor, - fontWeight: FontWeight.bold, - fontSize: 16, + Padding( + padding: kBroadcastGridItemContentPadding.add( + const EdgeInsets.symmetric(vertical: 3.0), + ), + child: Text( + widget.broadcast.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: titleColor, + fontWeight: FontWeight.bold, + height: 1.0, + fontSize: 16, + ), ), ), - ), - ], + if (widget.broadcast.tour.information.players != null) + Padding( + padding: kBroadcastGridItemContentPadding, + child: Text( + widget.broadcast.tour.information.players!, + style: TextStyle( + fontSize: 12, + color: subTitleColor, + letterSpacing: -0.2, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/test/view/broadcast/broadcasts_list_screen_test.dart b/test/view/broadcast/broadcasts_list_screen_test.dart index a60c308f80..59b9c2e87a 100644 --- a/test/view/broadcast/broadcasts_list_screen_test.dart +++ b/test/view/broadcast/broadcasts_list_screen_test.dart @@ -18,7 +18,7 @@ class FakeImageColorWorker implements ImageColorWorker { bool get closed => false; @override - Future getImageColors(String url) { + Future getImageColors(String url, {String? fileExtension}) { return Future.value(null); } }