2026-05-08 21:30:06 +08:00

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,
),
);
}
}