311 lines
8.6 KiB
Dart
311 lines
8.6 KiB
Dart
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<StatefulWidget> createState() => _SVGAImageState();
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(DiagnosticsProperty<Listenable>('controller', _controller));
|
|
}
|
|
}
|
|
|
|
class SVGAAnimationController extends AnimationController {
|
|
MovieEntity? _videoItem;
|
|
final List<SVGAAudioLayer> _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<SVGAAudioLayer>.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<SVGAImage> {
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|