diff --git a/cspell.json b/cspell.json index f8d8234dc74..523a4972335 100644 --- a/cspell.json +++ b/cspell.json @@ -4,7 +4,8 @@ "useGitignore": true, "enableGlobDot": false, "words": [ - "OpenAPI" + "OpenAPI", + "cacherine" ], "ignorePaths": [ "**.svg", diff --git a/packages/neon_framework/lib/blocs.dart b/packages/neon_framework/lib/blocs.dart index 375537c17ed..dd2c8621310 100644 --- a/packages/neon_framework/lib/blocs.dart +++ b/packages/neon_framework/lib/blocs.dart @@ -1,6 +1,7 @@ export 'package:neon_framework/src/bloc/bloc.dart'; export 'package:neon_framework/src/bloc/result.dart'; export 'package:neon_framework/src/blocs/apps.dart'; +export 'package:neon_framework/src/blocs/blur.dart'; export 'package:neon_framework/src/blocs/capabilities.dart'; export 'package:neon_framework/src/blocs/references.dart'; export 'package:neon_framework/src/blocs/timer.dart'; diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index faf1fd0b7b9..94a7352d8a9 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -11,6 +11,7 @@ import 'package:logging/logging.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/app.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:neon_framework/src/blocs/blur.dart'; import 'package:neon_framework/src/blocs/first_launch.dart'; import 'package:neon_framework/src/blocs/next_push.dart'; import 'package:neon_framework/src/blocs/push_notifications.dart'; @@ -120,6 +121,8 @@ Future runNeon({ globalOptions: globalOptions, ); + final blurBloc = BlurBloc(); + runApp( MultiProvider( providers: [ @@ -128,6 +131,7 @@ Future runNeon({ NeonProvider.value(value: accountsBloc), NeonProvider.value(value: firstLaunchBloc), NeonProvider.value(value: nextPushBloc), + NeonProvider.value(value: blurBloc), Provider>( create: (_) => appImplementations, dispose: (_, appImplementations) => appImplementations.disposeAll(), diff --git a/packages/neon_framework/lib/src/blocs/blur.dart b/packages/neon_framework/lib/src/blocs/blur.dart new file mode 100644 index 00000000000..afad9214dc7 --- /dev/null +++ b/packages/neon_framework/lib/src/blocs/blur.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cacherine/cacherine.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_blurhash/flutter_blurhash.dart'; +import 'package:logging/logging.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:queue/queue.dart'; + +/// A bloc that manages the decoding of blur hashes into images. +/// It uses a [Queue] to limit the maximum number of concurrent decoding tasks, +/// and a LRU-Cache to cache the results of previously decoded blur hashes so that they can be reused without re-decoding. +class BlurBloc extends Bloc { + final _blurHashQueue = Queue(parallel: 10); + final _blurHashCash = SimpleLRUCache(1000); + + @override + void dispose() { + _blurHashQueue.dispose(); + _blurHashCash.clear(); + } + + @override + Logger get log => Logger('BlurBloc'); + + /// Gets the decoded image for the given [blurHash] and [size]. + /// If the image is already cached, it returns the cached image. + /// Otherwise, it creates a new [BlurHashDecodeTask] to decode the blur hash, and returns the result of that task. + /// If [cache] is `true`, the result will be cached. [cache] does not have any effect on lookup. + Future getBlurHash(String blurHash, ui.Size size, {bool cache = true}) { + final task = BlurHashDecodeTask(blurHash: blurHash, size: size); + + if (_blurHashCash.get(task.key) != null) { + return _blurHashCash.get(task.key)!.result.future; + } + + // We are offloading the decoding process to the schedular to allow for pre-caching of the blur hashes, + // and to ensure that the decoding process does not block UI refreshes. + // Please note that this only works as long as the decoding process is not too heavy, + // as it could potentially still block the UI if it takes longer then a few milliseconds. + unawaited(SchedulerBinding.instance.scheduleTask(task.execute, Priority.animation)); + + if (cache) { + _blurHashCash.set(task.key, task); + } + + return task.result.future; + } +} + +/// A task to decode a blur hash into an image. The result is stored in a [Completer] so that it can be awaited by multiple callers. +class BlurHashDecodeTask { + /// Creates a new [BlurHashDecodeTask] with the given [blurHash] and [size]. + /// The result is stored in a [Completer] so that it can be awaited by multiple callers. + BlurHashDecodeTask({ + required this.blurHash, + required this.size, + }); + + /// The blur hash to decode. + final String blurHash; + + /// The size of the resulting image. + final ui.Size size; + + /// The result of the decoding process, stored in a [Completer] so that it can be awaited by multiple callers. + Completer result = Completer(); + + /// Executes the task by decoding the blur hash into an image and completing the [result] completer with the decoded image. + Future execute() async { + result.complete( + blurHashDecodeImage( + blurHash: blurHash, + width: size.width.toInt(), + height: size.height.toInt(), + ), + ); + } + + /// A unique key for this task, based on the blur hash and the size of the resulting image. + /// This is used to ensure that multiple callers can await the same task without creating duplicate tasks. + String get key => '$blurHash-${size.width}x${size.height}'; +} diff --git a/packages/neon_framework/lib/src/widgets/image.dart b/packages/neon_framework/lib/src/widgets/image.dart index 57086cad9e1..d3b9ea9ed5a 100644 --- a/packages/neon_framework/lib/src/widgets/image.dart +++ b/packages/neon_framework/lib/src/widgets/image.dart @@ -1,16 +1,18 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/bloc/result.dart'; +import 'package:neon_framework/src/blocs/blur.dart'; import 'package:neon_framework/src/utils/account_client_extension.dart'; import 'package:neon_framework/src/utils/image_utils.dart'; +import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/utils/request_manager.dart'; import 'package:neon_framework/src/widgets/error.dart'; import 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; @@ -108,22 +110,17 @@ class NeonImage extends StatelessWidget { // If the data is not UTF-8 } - return Image.memory( - data, - height: size?.height, - width: size?.width, - fit: fit ?? BoxFit.contain, - gaplessPlayback: true, - errorBuilder: (context, error, stacktrace) => _buildError(context, error), - ); - } - - if (blurHash != null) { - return BlurHash( - hash: blurHash!, - imageFit: fit ?? BoxFit.cover, - decodingHeight: size?.height.toInt() ?? 32, - decodingWidth: size?.width.toInt() ?? 32, + return _buildImageWithBlur( + context, + child: Image.memory( + data, + height: size?.height, + width: size?.width, + fit: fit ?? BoxFit.contain, + gaplessPlayback: true, + errorBuilder: (context, error, stacktrace) => _buildError(context, error), + ), + isLoading: imageResult.isLoading, ); } @@ -131,9 +128,47 @@ class NeonImage extends StatelessWidget { return _buildError(context, imageResult.error); } + return _buildBlur(context, isLoading: imageResult.isLoading); + }, + ); + } + + /// Replacing the blurhash with the actual image leads to UI flickering when scrolling very fast. + /// To mitigate this, we keep the blurhash underneath the actual image. + Widget _buildImageWithBlur(BuildContext context, {required Widget child, bool isLoading = true}) => Stack( + fit: StackFit.passthrough, + children: [ + _buildBlur(context, isLoading: isLoading), + child, + ], + ); + + Widget _buildBlur(BuildContext context, {bool isLoading = true}) { + final blurBloc = NeonProvider.of(context); + return FutureBuilder( + // Key is important to ensure that we can move it without cost in the widget tree. + key: ValueKey(blurHash), + // We are not caching the blurHash result because we do not want to take care of cleanup in here. + // If pre-caching is required, the encapsulating widget should take care of it and also of the cleanup. + future: blurHash != null + ? blurBloc.getBlurHash( + blurHash!, + size ?? const Size.square(32), + cache: false, + ) + : null, + builder: (context, snapshot) { + if (snapshot.hasData) { + return RawImage( + image: snapshot.data, + ); + } + return SizedBox( width: size?.width, - child: const NeonLinearProgressIndicator(), + child: NeonLinearProgressIndicator( + visible: isLoading, + ), ); }, ); diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 46044b6a4bb..a7937434eea 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: bloc_concurrency: ^0.3.0 built_collection: ^5.0.0 built_value: ^8.9.0 + cacherine: ^1.1.4 collection: ^1.0.0 cookie_store: path: ../cookie_store @@ -56,6 +57,7 @@ dependencies: path_provider: ^2.1.0 permission_handler: ^12.0.0 provider: ^6.0.0 + queue: ^3.0.0 quick_actions: ^1.0.0 rxdart: ^0.28.0 scrollable_positioned_list: ^0.3.0 diff --git a/pubspec.lock b/pubspec.lock index 1c4c8f53715..dd6776876d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.11.1" + cacherine: + dependency: transitive + description: + name: cacherine + sha256: "1921d157632330de4207f689d760e05b37b0a6b29dc7e3633e14cb038ee2388f" + url: "https://pub.dev" + source: hosted + version: "1.1.4" camera: dependency: transitive description: