library; import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_svga/src/audio_layer.dart'; import 'package:path_drawing/path_drawing.dart'; import 'parser.dart'; import 'proto/svga.pbserver.dart'; part 'easy_player.dart'; part 'painter.dart'; class SVGAImage extends StatefulWidget { final SVGAAnimationController _controller; final BoxFit fit; final bool clearsAfterStop; /// Used to set the filterQuality of drawing the images inside SVGA. /// /// Defaults to [FilterQuality.low] final FilterQuality filterQuality; /// If `true`, the SVGA painter may draw beyond the expected canvas bounds /// and cause additional memory overhead. /// /// For backwards compatibility, defaults to `null`, /// which means allow drawing to overflow canvas bounds. final bool? allowDrawingOverflow; /// If `null`, the viewbox size of [MovieEntity] will be use. /// /// Defaults to null. final Size? preferredSize; /// Stretchable center area in the SVGA viewBox coordinate space. /// /// When supplied with [ninePatchSourceRect], the painter renders the SVGA like /// a nine-patch image so borders and corners are not distorted. final Rect? centerSlice; /// Source area in the SVGA viewBox coordinate space used for nine-patch /// drawing. Useful for skipping guide pixels around exported nine-patch art. final Rect? ninePatchSourceRect; const SVGAImage( this._controller, { super.key, this.fit = BoxFit.contain, this.filterQuality = FilterQuality.low, this.allowDrawingOverflow, this.clearsAfterStop = true, this.preferredSize, this.centerSlice, this.ninePatchSourceRect, }); @override State createState() => _SVGAImageState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('controller', _controller)); } } class SVGAAnimationController extends AnimationController { MovieEntity? _videoItem; final List _audioLayers = []; bool _canvasNeedsClear = false; double _volume = 1.0; bool _muted = false; SVGAAnimationController({required super.vsync}) : super(duration: Duration.zero); /// Audio volume level (0.0 to 1.0). /// Default is 1.0 (full volume). double get volume => _volume; set volume(double value) { if (_isDisposed) return; _volume = value.clamp(0.0, 1.0); for (final audio in _audioLayers) { audio.setVolume(_muted ? 0.0 : _volume); } } /// Whether audio is muted. /// When muted, volume is set to 0 but the volume setting is preserved. bool get muted => _muted; set muted(bool value) { if (_isDisposed) return; _muted = value; for (final audio in _audioLayers) { audio.setVolume(_muted ? 0.0 : _volume); } } void _disposeAudioLayers() { if (_audioLayers.isEmpty) return; final audioLayers = List.of(_audioLayers); _audioLayers.clear(); for (final audio in audioLayers) { audio.stopAudio(); unawaited(audio.dispose()); } } set videoItem(MovieEntity? value) { assert(!_isDisposed, '$this has been disposed!'); if (_isDisposed) return; if (isAnimating) { stop(); } _disposeAudioLayers(); if (value == null) { clear(); } if (_videoItem != null && _videoItem!.autorelease) { _videoItem!.dispose(); } _videoItem = value; if (value != null) { final movieParams = value.params; assert( movieParams.viewBoxWidth >= 0 && movieParams.viewBoxHeight >= 0 && movieParams.frames >= 1, "Invalid SVGA file!", ); int fps = movieParams.fps; // avoid dividing by 0, use 20 by default // see https://github.com/svga/SVGAPlayer-Web/blob/1c5711db068a25006316f9890b11d6666d531c39/src/videoEntity.js#L51 if (fps == 0) fps = 20; duration = Duration( milliseconds: (movieParams.frames / fps * 1000).toInt(), ); for (var audio in value.audios) { final audioLayer = SVGAAudioLayer(audio, value); audioLayer.setVolume(_muted ? 0.0 : _volume); _audioLayers.add(audioLayer); } } else { duration = Duration.zero; } // reset progress after videoitem changed reset(); } MovieEntity? get videoItem => _videoItem; /// Current drawing frame index of [videoItem], returns 0 if [videoItem] is null. int get currentFrame { final videoItem = _videoItem; if (videoItem == null) return 0; return min( videoItem.params.frames - 1, max(0, (videoItem.params.frames.toDouble() * value).toInt()), ); } /// Total frames of [videoItem], returns 0 if [videoItem] is null. int get frames { final videoItem = _videoItem; if (videoItem == null) return 0; return videoItem.params.frames; } /// mark [_SVGAPainter] needs clear void clear() { _canvasNeedsClear = true; if (!_isDisposed) notifyListeners(); } @override TickerFuture forward({double? from}) { assert( _videoItem != null, 'SVGAAnimationController.forward() called after dispose()?', ); return super.forward(from: from); } @override void stop({bool canceled = true}) { for (final audio in _audioLayers) { audio.pauseAudio(); } super.stop(canceled: canceled); } bool _isDisposed = false; @override void dispose() { // auto dispose _videoItem when set null videoItem = null; _isDisposed = true; super.dispose(); } } class _SVGAImageState extends State { MovieEntity? video; @override void initState() { super.initState(); video = widget._controller.videoItem; widget._controller.addListener(_handleChange); widget._controller.addStatusListener(_handleStatusChange); } @override void didUpdateWidget(SVGAImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget._controller != widget._controller) { oldWidget._controller.removeListener(_handleChange); oldWidget._controller.removeStatusListener(_handleStatusChange); video = widget._controller.videoItem; widget._controller.addListener(_handleChange); widget._controller.addStatusListener(_handleStatusChange); } } void _handleChange() { if (mounted) { if (video == widget._controller.videoItem) { handleAudio(); } else if (!widget._controller._isDisposed) { setState(() { // rebuild video = widget._controller.videoItem; }); } } } void _handleStatusChange(AnimationStatus status) { if (status == AnimationStatus.completed && widget.clearsAfterStop) { widget._controller.clear(); } } void handleAudio() { final controller = widget._controller; final audioLayers = controller._audioLayers; final effectiveVolume = controller._muted ? 0.0 : controller._volume; for (final audio in audioLayers) { if (!audio.isPlaying() && audio.audioItem.startFrame <= controller.currentFrame && audio.audioItem.endFrame >= controller.currentFrame) { audio.playAudio(volume: effectiveVolume); } if (audio.isPlaying() && audio.audioItem.endFrame <= controller.currentFrame) { audio.stopAudio(); } } } @override void dispose() { video = null; widget._controller.removeListener(_handleChange); widget._controller.removeStatusListener(_handleStatusChange); super.dispose(); } @override Widget build(BuildContext context) { final video = this.video; final Size viewBoxSize; if (video == null || !video.isInitialized()) { viewBoxSize = Size.zero; } else { viewBoxSize = Size(video.params.viewBoxWidth, video.params.viewBoxHeight); } if (viewBoxSize.isEmpty) { return const SizedBox.shrink(); } // sugguest the size of CustomPaint Size preferredSize = viewBoxSize; if (widget.preferredSize != null) { preferredSize = BoxConstraints.tight( widget.preferredSize!, ).constrain(viewBoxSize); } return IgnorePointer( child: CustomPaint( painter: _SVGAPainter( // _SVGAPainter will auto repaint on _controller animating widget._controller, fit: widget.fit, filterQuality: widget.filterQuality, centerSlice: widget.centerSlice, ninePatchSourceRect: widget.ninePatchSourceRect, // default is allowing overflow for backward compatibility clipRect: widget.allowDrawingOverflow == false, ), size: preferredSize, ), ); } }