// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:ui' as ui show Image, Codec, FrameInfo; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; /// A [dart:ui.Image] object with its corresponding scale. /// /// ImageInfo objects are used by [ImageStream] objects to represent the /// actual data of the image once it has been obtained. /// /// The receiver of an [ImageInfo] object must call [dispose]. To safely share /// the object with other clients, use the [clone] method before calling /// dispose. @immutable class ImageInfo { /// Creates an [ImageInfo] object for the given [image] and [scale]. /// /// Both the [image] and the [scale] must not be null. /// /// The [debugLabel] may be used to identify the source of this image. const ImageInfo({ required this.image, this.scale = 1.0, this.debugLabel }) : assert(image != null), assert(scale != null); /// Creates an [ImageInfo] with a cloned [image]. /// /// Once all outstanding references to the [image] are disposed, it is no /// longer safe to access properties of it or attempt to draw it. Clones serve /// to create new references to the underlying image data that can safely be /// disposed without knowledge of whether some other reference holder will /// still need access to the underlying image. Once a client disposes of its /// own image reference, it can no longer access the image, but other clients /// will be able to access their own references. /// /// This method must be used in cases where a client holding an [ImageInfo] /// needs to share the image info object with another client and will still /// need to access the underlying image data at some later point, e.g. to /// share it again with another client. /// /// See also: /// /// * [Image.clone], which describes how and why to clone images. ImageInfo clone() { return ImageInfo( image: image.clone(), scale: scale, debugLabel: debugLabel, ); } /// Whether this [ImageInfo] is a [clone] of the `other`. /// /// This method is a convenience wrapper for [Image.isCloneOf], and is useful /// for clients that are trying to determine whether new layout or painting /// logic is required when receiving a new image reference. /// /// {@tool snippet} /// /// The following sample shows how to appropriately check whether the /// [ImageInfo] reference refers to new image data or not. /// /// ```dart /// ImageInfo? _imageInfo; /// set imageInfo (ImageInfo? value) { /// // If the image reference is exactly the same, do nothing. /// if (value == _imageInfo) { /// return; /// } /// // If it is a clone of the current reference, we must dispose of it and /// // can do so immediately. Since the underlying image has not changed, /// // We don't have any additional work to do here. /// if (value != null && _imageInfo != null && value.isCloneOf(_imageInfo!)) { /// value.dispose(); /// return; /// } /// _imageInfo?.dispose(); /// _imageInfo = value; /// // Perform work to determine size, or paint the image. /// } /// ``` /// {@end-tool} bool isCloneOf(ImageInfo other) { return other.image.isCloneOf(image) && scale == scale && other.debugLabel == debugLabel; } /// The raw image pixels. /// /// This is the object to pass to the [Canvas.drawImage], /// [Canvas.drawImageRect], or [Canvas.drawImageNine] methods when painting /// the image. final ui.Image image; /// The size of raw image pixels in bytes. int get sizeBytes => image.height * image.width * 4; /// The linear scale factor for drawing this image at its intended size. /// /// The scale factor applies to the width and the height. /// /// {@template flutter.painting.imageInfo.scale} /// For example, if this is 2.0, it means that there are four image pixels for /// every one logical pixel, and the image's actual width and height (as given /// by the [dart:ui.Image.width] and [dart:ui.Image.height] properties) are /// double the height and width that should be used when painting the image /// (e.g. in the arguments given to [Canvas.drawImage]). /// {@endtemplate} final double scale; /// A string used for debugging purposes to identify the source of this image. final String? debugLabel; /// Disposes of this object. /// /// Once this method has been called, the object should not be used anymore, /// and no clones of it or the image it contains can be made. void dispose() { assert((image.debugGetOpenHandleStackTraces()?.length ?? 1) > 0); image.dispose(); } @override String toString() => '${debugLabel != null ? '$debugLabel ' : ''}$image @ ${debugFormatDouble(scale)}x'; @override int get hashCode => Object.hash(image, scale, debugLabel); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; return other is ImageInfo && other.image == image && other.scale == scale && other.debugLabel == debugLabel; } } /// Interface for receiving notifications about the loading of an image. /// /// This class overrides [operator ==] and [hashCode] to compare the individual /// callbacks in the listener, meaning that if you add an instance of this class /// as a listener (e.g. via [ImageStream.addListener]), you can instantiate a /// _different_ instance of this class when you remove the listener, and the /// listener will be properly removed as long as all associated callbacks are /// equal. /// /// Used by [ImageStream] and [ImageStreamCompleter]. @immutable class ImageStreamListener { /// Creates a new [ImageStreamListener]. /// /// The [onImage] parameter must not be null. const ImageStreamListener( this.onImage, { this.onChunk, this.onError, }) : assert(onImage != null); /// Callback for getting notified that an image is available. /// /// This callback may fire multiple times (e.g. if the [ImageStreamCompleter] /// that drives the notifications fires multiple times). An example of such a /// case would be an image with multiple frames within it (such as an animated /// GIF). /// /// For more information on how to interpret the parameters to the callback, /// see the documentation on [ImageListener]. /// /// See also: /// /// * [onError], which will be called instead of [onImage] if an error occurs /// during loading. final ImageListener onImage; /// Callback for getting notified when a chunk of bytes has been received /// during the loading of the image. /// /// This callback may fire many times (e.g. when used with a [NetworkImage], /// where the image bytes are loaded incrementally over the wire) or not at /// all (e.g. when used with a [MemoryImage], where the image bytes are /// already available in memory). /// /// This callback may also continue to fire after the [onImage] callback has /// fired (e.g. for multi-frame images that continue to load after the first /// frame is available). final ImageChunkListener? onChunk; /// Callback for getting notified when an error occurs while loading an image. /// /// If an error occurs during loading, [onError] will be called instead of /// [onImage]. /// /// If [onError] is called and does not throw, then the error is considered to /// be handled. An error handler can explicitly rethrow the exception reported /// to it to safely indicate that it did not handle the exception. /// /// If an image stream has no listeners that handled the error when the error /// was first encountered, then the error is reported using /// [FlutterError.reportError], with the [FlutterErrorDetails.silent] flag set /// to true. final ImageErrorListener? onError; @override int get hashCode => Object.hash(onImage, onChunk, onError); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; return other is ImageStreamListener && other.onImage == onImage && other.onChunk == onChunk && other.onError == onError; } } /// Signature for callbacks reporting that an image is available. /// /// Used in [ImageStreamListener]. /// /// The `image` argument contains information about the image to be rendered. /// The implementer of [ImageStreamListener.onImage] is expected to call dispose /// on the [ui.Image] it receives. /// /// The `synchronousCall` argument is true if the listener is being invoked /// during the call to `addListener`. This can be useful if, for example, /// [ImageStream.addListener] is invoked during a frame, so that a new rendering /// frame is requested if the call was asynchronous (after the current frame) /// and no rendering frame is requested if the call was synchronous (within the /// same stack frame as the call to [ImageStream.addListener]). typedef ImageListener = void Function(ImageInfo image, bool synchronousCall); /// Signature for listening to [ImageChunkEvent] events. /// /// Used in [ImageStreamListener]. typedef ImageChunkListener = void Function(ImageChunkEvent event); /// Signature for reporting errors when resolving images. /// /// Used in [ImageStreamListener], as well as by [ImageCache.putIfAbsent] and /// [precacheImage], to report errors. typedef ImageErrorListener = void Function(Object exception, StackTrace? stackTrace); /// An immutable notification of image bytes that have been incrementally loaded. /// /// Chunk events represent progress notifications while an image is being /// loaded (e.g. from disk or over the network). /// /// See also: /// /// * [ImageChunkListener], the means by which callers get notified of /// these events. @immutable class ImageChunkEvent with Diagnosticable { /// Creates a new chunk event. const ImageChunkEvent({ required this.cumulativeBytesLoaded, required this.expectedTotalBytes, }) : assert(cumulativeBytesLoaded >= 0), assert(expectedTotalBytes == null || expectedTotalBytes >= 0); /// The number of bytes that have been received across the wire thus far. final int cumulativeBytesLoaded; /// The expected number of bytes that need to be received to finish loading /// the image. /// /// This value is not necessarily equal to the expected _size_ of the image /// in bytes, as the bytes required to load the image may be compressed. /// /// This value will be null if the number is not known in advance. /// /// When this value is null, the chunk event may still be useful as an /// indication that data is loading (and how much), but it cannot represent a /// loading completion percentage. final int? expectedTotalBytes; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(IntProperty('cumulativeBytesLoaded', cumulativeBytesLoaded)); properties.add(IntProperty('expectedTotalBytes', expectedTotalBytes)); } } /// A handle to an image resource. /// /// ImageStream represents a handle to a [dart:ui.Image] object and its scale /// (together represented by an [ImageInfo] object). The underlying image object /// might change over time, either because the image is animating or because the /// underlying image resource was mutated. /// /// ImageStream objects can also represent an image that hasn't finished /// loading. /// /// ImageStream objects are backed by [ImageStreamCompleter] objects. /// /// The [ImageCache] will consider an image to be live until the listener count /// drops to zero after adding at least one listener. The /// [ImageStreamCompleter.addOnLastListenerRemovedCallback] method is used for /// tracking this information. /// /// See also: /// /// * [ImageProvider], which has an example that includes the use of an /// [ImageStream] in a [Widget]. class ImageStream with Diagnosticable { /// Create an initially unbound image stream. /// /// Once an [ImageStreamCompleter] is available, call [setCompleter]. ImageStream(); /// The completer that has been assigned to this image stream. /// /// Generally there is no need to deal with the completer directly. ImageStreamCompleter? get completer => _completer; ImageStreamCompleter? _completer; List<ImageStreamListener>? _listeners; /// Assigns a particular [ImageStreamCompleter] to this [ImageStream]. /// /// This is usually done automatically by the [ImageProvider] that created the /// [ImageStream]. /// /// This method can only be called once per stream. To have an [ImageStream] /// represent multiple images over time, assign it a completer that /// completes several images in succession. void setCompleter(ImageStreamCompleter value) { assert(_completer == null); _completer = value; if (_listeners != null) { final List<ImageStreamListener> initialListeners = _listeners!; _listeners = null; _completer!._addingInitialListeners = true; initialListeners.forEach(_completer!.addListener); _completer!._addingInitialListeners = false; } } /// Adds a listener callback that is called whenever a new concrete [ImageInfo] /// object is available. If a concrete image is already available, this object /// will call the listener synchronously. /// /// If the assigned [completer] completes multiple images over its lifetime, /// this listener will fire multiple times. /// /// {@template flutter.painting.imageStream.addListener} /// The listener will be passed a flag indicating whether a synchronous call /// occurred. If the listener is added within a render object paint function, /// then use this flag to avoid calling [RenderObject.markNeedsPaint] during /// a paint. /// /// If a duplicate `listener` is registered N times, then it will be called N /// times when the image stream completes (whether because a new image is /// available or because an error occurs). Likewise, to remove all instances /// of the listener, [removeListener] would need to called N times as well. /// /// When a `listener` receives an [ImageInfo] object, the `listener` is /// responsible for disposing of the [ImageInfo.image]. /// {@endtemplate} void addListener(ImageStreamListener listener) { if (_completer != null) return _completer!.addListener(listener); _listeners ??= <ImageStreamListener>[]; _listeners!.add(listener); } /// Stops listening for events from this stream's [ImageStreamCompleter]. /// /// If [listener] has been added multiple times, this removes the _first_ /// instance of the listener. void removeListener(ImageStreamListener listener) { if (_completer != null) return _completer!.removeListener(listener); assert(_listeners != null); for (int i = 0; i < _listeners!.length; i += 1) { if (_listeners![i] == listener) { _listeners!.removeAt(i); break; } } } /// Returns an object which can be used with `==` to determine if this /// [ImageStream] shares the same listeners list as another [ImageStream]. /// /// This can be used to avoid un-registering and re-registering listeners /// after calling [ImageProvider.resolve] on a new, but possibly equivalent, /// [ImageProvider]. /// /// The key may change once in the lifetime of the object. When it changes, it /// will go from being different than other [ImageStream]'s keys to /// potentially being the same as others'. No notification is sent when this /// happens. Object get key => _completer ?? this; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(ObjectFlagProperty<ImageStreamCompleter>( 'completer', _completer, ifPresent: _completer?.toStringShort(), ifNull: 'unresolved', )); properties.add(ObjectFlagProperty<List<ImageStreamListener>>( 'listeners', _listeners, ifPresent: '${_listeners?.length} listener${_listeners?.length == 1 ? "" : "s" }', ifNull: 'no listeners', level: _completer != null ? DiagnosticLevel.hidden : DiagnosticLevel.info, )); _completer?.debugFillProperties(properties); } } /// An opaque handle that keeps an [ImageStreamCompleter] alive even if it has /// lost its last listener. /// /// To create a handle, use [ImageStreamCompleter.keepAlive]. /// /// Such handles are useful when an image cache needs to keep a completer alive /// but does not actually have a listener subscribed, or when a widget that /// displays an image needs to temporarily unsubscribe from the completer but /// may re-subscribe in the future, for example when the [TickerMode] changes. class ImageStreamCompleterHandle { ImageStreamCompleterHandle._(ImageStreamCompleter this._completer) { _completer!._keepAliveHandles += 1; } ImageStreamCompleter? _completer; /// Call this method to signal the [ImageStreamCompleter] that it can now be /// disposed when its last listener drops. /// /// This method must only be called once per object. void dispose() { assert(_completer != null); assert(_completer!._keepAliveHandles > 0); assert(!_completer!._disposed); _completer!._keepAliveHandles -= 1; _completer!._maybeDispose(); _completer = null; } } /// Base class for those that manage the loading of [dart:ui.Image] objects for /// [ImageStream]s. /// /// [ImageStreamListener] objects are rarely constructed directly. Generally, an /// [ImageProvider] subclass will return an [ImageStream] and automatically /// configure it with the right [ImageStreamCompleter] when possible. abstract class ImageStreamCompleter with Diagnosticable { final List<ImageStreamListener> _listeners = <ImageStreamListener>[]; ImageInfo? _currentImage; FlutterErrorDetails? _currentError; /// A string identifying the source of the underlying image. String? debugLabel; /// Whether any listeners are currently registered. /// /// Clients should not depend on this value for their behavior, because having /// one listener's logic change when another listener happens to start or stop /// listening will lead to extremely hard-to-track bugs. Subclasses might use /// this information to determine whether to do any work when there are no /// listeners, however; for example, [MultiFrameImageStreamCompleter] uses it /// to determine when to iterate through frames of an animated image. /// /// Typically this is used by overriding [addListener], checking if /// [hasListeners] is false before calling `super.addListener()`, and if so, /// starting whatever work is needed to determine when to notify listeners; /// and similarly, by overriding [removeListener], checking if [hasListeners] /// is false after calling `super.removeListener()`, and if so, stopping that /// same work. @protected @visibleForTesting bool get hasListeners => _listeners.isNotEmpty; /// We must avoid disposing a completer if it has never had a listener, even /// if all [keepAlive] handles get disposed. bool _hadAtLeastOneListener = false; /// Whether the future listeners added to this completer are initial listeners. /// /// This can be set to true when an [ImageStream] adds its initial listeners to /// this completer. This ultimately controls the synchronousCall parameter for /// the listener callbacks. When adding cached listeners to a completer, /// [_addingInitialListeners] can be set to false to indicate to the listeners /// that they are being called asynchronously. bool _addingInitialListeners = false; /// Adds a listener callback that is called whenever a new concrete [ImageInfo] /// object is available or an error is reported. If a concrete image is /// already available, or if an error has been already reported, this object /// will notify the listener synchronously. /// /// If the [ImageStreamCompleter] completes multiple images over its lifetime, /// this listener's [ImageStreamListener.onImage] will fire multiple times. /// /// {@macro flutter.painting.imageStream.addListener} void addListener(ImageStreamListener listener) { _checkDisposed(); _hadAtLeastOneListener = true; _listeners.add(listener); if (_currentImage != null) { try { listener.onImage(_currentImage!.clone(), !_addingInitialListeners); } catch (exception, stack) { reportError( context: ErrorDescription('by a synchronously-called image listener'), exception: exception, stack: stack, ); } } if (_currentError != null && listener.onError != null) { try { listener.onError!(_currentError!.exception, _currentError!.stack); } catch (newException, newStack) { if (newException != _currentError!.exception) { FlutterError.reportError( FlutterErrorDetails( exception: newException, library: 'image resource service', context: ErrorDescription('by a synchronously-called image error listener'), stack: newStack, ), ); } } } } int _keepAliveHandles = 0; /// Creates an [ImageStreamCompleterHandle] that will prevent this stream from /// being disposed at least until the handle is disposed. /// /// Such handles are useful when an image cache needs to keep a completer /// alive but does not itself have a listener subscribed, or when a widget /// that displays an image needs to temporarily unsubscribe from the completer /// but may re-subscribe in the future, for example when the [TickerMode] /// changes. ImageStreamCompleterHandle keepAlive() { _checkDisposed(); return ImageStreamCompleterHandle._(this); } /// Stops the specified [listener] from receiving image stream events. /// /// If [listener] has been added multiple times, this removes the _first_ /// instance of the listener. /// /// Once all listeners have been removed and all [keepAlive] handles have been /// disposed, this image stream is no longer usable. void removeListener(ImageStreamListener listener) { _checkDisposed(); for (int i = 0; i < _listeners.length; i += 1) { if (_listeners[i] == listener) { _listeners.removeAt(i); break; } } if (_listeners.isEmpty) { final List<VoidCallback> callbacks = _onLastListenerRemovedCallbacks.toList(); for (final VoidCallback callback in callbacks) { callback(); } _onLastListenerRemovedCallbacks.clear(); _maybeDispose(); } } bool _disposed = false; @mustCallSuper void _maybeDispose() { if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) { return; } _currentImage?.dispose(); _currentImage = null; _disposed = true; } void _checkDisposed() { if (_disposed) { throw StateError( 'Stream has been disposed.\n' 'An ImageStream is considered disposed once at least one listener has ' 'been added and subsequently all listeners have been removed and no ' 'handles are outstanding from the keepAlive method.\n' 'To resolve this error, maintain at least one listener on the stream, ' 'or create an ImageStreamCompleterHandle from the keepAlive ' 'method, or create a new stream for the image.', ); } } final List<VoidCallback> _onLastListenerRemovedCallbacks = <VoidCallback>[]; /// Adds a callback to call when [removeListener] results in an empty /// list of listeners and there are no [keepAlive] handles outstanding. /// /// This callback will never fire if [removeListener] is never called. void addOnLastListenerRemovedCallback(VoidCallback callback) { assert(callback != null); _checkDisposed(); _onLastListenerRemovedCallbacks.add(callback); } /// Removes a callback previously supplied to /// [addOnLastListenerRemovedCallback]. void removeOnLastListenerRemovedCallback(VoidCallback callback) { assert(callback != null); _checkDisposed(); _onLastListenerRemovedCallbacks.remove(callback); } /// Calls all the registered listeners to notify them of a new image. @protected @pragma('vm:notify-debugger-on-exception') void setImage(ImageInfo image) { _checkDisposed(); _currentImage?.dispose(); _currentImage = image; if (_listeners.isEmpty) return; // Make a copy to allow for concurrent modification. final List<ImageStreamListener> localListeners = List<ImageStreamListener>.of(_listeners); for (final ImageStreamListener listener in localListeners) { try { listener.onImage(image.clone(), false); } catch (exception, stack) { reportError( context: ErrorDescription('by an image listener'), exception: exception, stack: stack, ); } } } /// Calls all the registered error listeners to notify them of an error that /// occurred while resolving the image. /// /// If no error listeners (listeners with an [ImageStreamListener.onError] /// specified) are attached, or if the handlers all rethrow the exception /// verbatim (with `throw exception`), a [FlutterError] will be reported using /// [FlutterError.reportError]. /// /// The `context` should be a string describing where the error was caught, in /// a form that will make sense in English when following the word "thrown", /// as in "thrown while obtaining the image from the network" (for the context /// "while obtaining the image from the network"). /// /// The `exception` is the error being reported; the `stack` is the /// [StackTrace] associated with the exception. /// /// The `informationCollector` is a callback (of type [InformationCollector]) /// that is called when the exception is used by [FlutterError.reportError]. /// It is used to obtain further details to include in the logs, which may be /// expensive to collect, and thus should only be collected if the error is to /// be logged in the first place. /// /// The `silent` argument causes the exception to not be reported to the logs /// in release builds, if passed to [FlutterError.reportError]. (It is still /// sent to error handlers.) It should be set to true if the error is one that /// is expected to be encountered in release builds, for example network /// errors. That way, logs on end-user devices will not have spurious /// messages, but errors during development will still be reported. /// /// See [FlutterErrorDetails] for further details on these values. @pragma('vm:notify-debugger-on-exception') void reportError({ DiagnosticsNode? context, required Object exception, StackTrace? stack, InformationCollector? informationCollector, bool silent = false, }) { _currentError = FlutterErrorDetails( exception: exception, stack: stack, library: 'image resource service', context: context, informationCollector: informationCollector, silent: silent, ); // Make a copy to allow for concurrent modification. final List<ImageErrorListener> localErrorListeners = _listeners .map<ImageErrorListener?>((ImageStreamListener listener) => listener.onError) .whereType<ImageErrorListener>() .toList(); bool handled = false; for (final ImageErrorListener errorListener in localErrorListeners) { try { errorListener(exception, stack); handled = true; } catch (newException, newStack) { if (newException != exception) { FlutterError.reportError( FlutterErrorDetails( context: ErrorDescription('when reporting an error to an image listener'), library: 'image resource service', exception: newException, stack: newStack, ), ); } } } if (!handled) { FlutterError.reportError(_currentError!); } } /// Calls all the registered [ImageChunkListener]s (listeners with an /// [ImageStreamListener.onChunk] specified) to notify them of a new /// [ImageChunkEvent]. @protected void reportImageChunkEvent(ImageChunkEvent event) { _checkDisposed(); if (hasListeners) { // Make a copy to allow for concurrent modification. final List<ImageChunkListener> localListeners = _listeners .map<ImageChunkListener?>((ImageStreamListener listener) => listener.onChunk) .whereType<ImageChunkListener>() .toList(); for (final ImageChunkListener listener in localListeners) { listener(event); } } } /// Accumulates a list of strings describing the object's state. Subclasses /// should override this to have their information included in [toString]. @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); description.add(DiagnosticsProperty<ImageInfo>('current', _currentImage, ifNull: 'unresolved', showName: false)); description.add(ObjectFlagProperty<List<ImageStreamListener>>( 'listeners', _listeners, ifPresent: '${_listeners.length} listener${_listeners.length == 1 ? "" : "s" }', )); description.add(FlagProperty('disposed', value: _disposed, ifTrue: '<disposed>')); } } /// Manages the loading of [dart:ui.Image] objects for static [ImageStream]s (those /// with only one frame). class OneFrameImageStreamCompleter extends ImageStreamCompleter { /// Creates a manager for one-frame [ImageStream]s. /// /// The image resource awaits the given [Future]. When the future resolves, /// it notifies the [ImageListener]s that have been registered with /// [addListener]. /// /// The [InformationCollector], if provided, is invoked if the given [Future] /// resolves with an error, and can be used to supplement the reported error /// message (for example, giving the image's URL). /// /// Errors are reported using [FlutterError.reportError] with the `silent` /// argument on [FlutterErrorDetails] set to true, meaning that by default the /// message is only dumped to the console in debug mode (see [ /// FlutterErrorDetails]). OneFrameImageStreamCompleter(Future<ImageInfo> image, { InformationCollector? informationCollector }) : assert(image != null) { image.then<void>(setImage, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving a single-frame image stream'), exception: error, stack: stack, informationCollector: informationCollector, silent: true, ); }); } } /// Manages the decoding and scheduling of image frames. /// /// New frames will only be emitted while there are registered listeners to the /// stream (registered with [addListener]). /// /// This class deals with 2 types of frames: /// /// * image frames - image frames of an animated image. /// * app frames - frames that the flutter engine is drawing to the screen to /// show the app GUI. /// /// For single frame images the stream will only complete once. /// /// For animated images, this class eagerly decodes the next image frame, /// and notifies the listeners that a new frame is ready on the first app frame /// that is scheduled after the image frame duration has passed. /// /// Scheduling new timers only from scheduled app frames, makes sure we pause /// the animation when the app is not visible (as new app frames will not be /// scheduled). /// /// See the following timeline example: /// /// | Time | Event | Comment | /// |------|--------------------------------------------|---------------------------| /// | t1 | App frame scheduled (image frame A posted) | | /// | t2 | App frame scheduled | | /// | t3 | App frame scheduled | | /// | t4 | Image frame B decoded | | /// | t5 | App frame scheduled | t5 - t1 < frameB_duration | /// | t6 | App frame scheduled (image frame B posted) | t6 - t1 > frameB_duration | /// class MultiFrameImageStreamCompleter extends ImageStreamCompleter { /// Creates a image stream completer. /// /// Immediately starts decoding the first image frame when the codec is ready. /// /// The `codec` parameter is a future for an initialized [ui.Codec] that will /// be used to decode the image. /// /// The `scale` parameter is the linear scale factor for drawing this frames /// of this image at their intended size. /// /// The `tag` parameter is passed on to created [ImageInfo] objects to /// help identify the source of the image. /// /// The `chunkEvents` parameter is an optional stream of notifications about /// the loading progress of the image. If this stream is provided, the events /// produced by the stream will be delivered to registered [ImageChunkListener]s /// (see [addListener]). MultiFrameImageStreamCompleter({ required Future<ui.Codec> codec, required double scale, String? debugLabel, Stream<ImageChunkEvent>? chunkEvents, InformationCollector? informationCollector, }) : assert(codec != null), _informationCollector = informationCollector, _scale = scale { this.debugLabel = debugLabel; codec.then<void>(_handleCodecReady, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving an image codec'), exception: error, stack: stack, informationCollector: informationCollector, silent: true, ); }); if (chunkEvents != null) { _chunkSubscription = chunkEvents.listen(reportImageChunkEvent, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('loading an image'), exception: error, stack: stack, informationCollector: informationCollector, silent: true, ); }, ); } } StreamSubscription<ImageChunkEvent>? _chunkSubscription; ui.Codec? _codec; final double _scale; final InformationCollector? _informationCollector; ui.FrameInfo? _nextFrame; // When the current was first shown. late Duration _shownTimestamp; // The requested duration for the current frame; Duration? _frameDuration; // How many frames have been emitted so far. int _framesEmitted = 0; Timer? _timer; // Used to guard against registering multiple _handleAppFrame callbacks for the same frame. bool _frameCallbackScheduled = false; void _handleCodecReady(ui.Codec codec) { _codec = codec; assert(_codec != null); if (hasListeners) { _decodeNextFrameAndSchedule(); } } void _handleAppFrame(Duration timestamp) { _frameCallbackScheduled = false; if (!hasListeners) return; assert(_nextFrame != null); if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) { _emitFrame(ImageInfo( image: _nextFrame!.image.clone(), scale: _scale, debugLabel: debugLabel, )); _shownTimestamp = timestamp; _frameDuration = _nextFrame!.duration; _nextFrame!.image.dispose(); _nextFrame = null; final int completedCycles = _framesEmitted ~/ _codec!.frameCount; if (_codec!.repetitionCount == -1 || completedCycles <= _codec!.repetitionCount) { _decodeNextFrameAndSchedule(); } return; } final Duration delay = _frameDuration! - (timestamp - _shownTimestamp); _timer = Timer(delay * timeDilation, () { _scheduleAppFrame(); }); } bool _isFirstFrame() { return _frameDuration == null; } bool _hasFrameDurationPassed(Duration timestamp) { return timestamp - _shownTimestamp >= _frameDuration!; } Future<void> _decodeNextFrameAndSchedule() async { // This will be null if we gave it away. If not, it's still ours and it // must be disposed of. _nextFrame?.image.dispose(); _nextFrame = null; try { _nextFrame = await _codec!.getNextFrame(); } catch (exception, stack) { reportError( context: ErrorDescription('resolving an image frame'), exception: exception, stack: stack, informationCollector: _informationCollector, silent: true, ); return; } if (_codec!.frameCount == 1) { // ImageStreamCompleter listeners removed while waiting for next frame to // be decoded. // There's no reason to emit the frame without active listeners. if (!hasListeners) { return; } // This is not an animated image, just return it and don't schedule more // frames. _emitFrame(ImageInfo( image: _nextFrame!.image.clone(), scale: _scale, debugLabel: debugLabel, )); _nextFrame!.image.dispose(); _nextFrame = null; return; } _scheduleAppFrame(); } void _scheduleAppFrame() { if (_frameCallbackScheduled) { return; } _frameCallbackScheduled = true; SchedulerBinding.instance.scheduleFrameCallback(_handleAppFrame); } void _emitFrame(ImageInfo imageInfo) { setImage(imageInfo); _framesEmitted += 1; } @override void addListener(ImageStreamListener listener) { if (!hasListeners && _codec != null && (_currentImage == null || _codec!.frameCount > 1)) _decodeNextFrameAndSchedule(); super.addListener(listener); } @override void removeListener(ImageStreamListener listener) { super.removeListener(listener); if (!hasListeners) { _timer?.cancel(); _timer = null; } } @override void _maybeDispose() { super._maybeDispose(); if (_disposed) { _chunkSubscription?.onData(null); _chunkSubscription?.cancel(); _chunkSubscription = null; } } }