diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart index b34b096af..89f39dc71 100644 --- a/lib/model/actions/video_actions.dart +++ b/lib/model/actions/video_actions.dart @@ -8,6 +8,7 @@ enum VideoAction { selectStreams, setSpeed, togglePlay, + // TODO TLAD [video] toggle mute } class VideoActions { diff --git a/lib/widgets/common/basic/outlined_text.dart b/lib/widgets/common/basic/outlined_text.dart index 56c141675..81e22985f 100644 --- a/lib/widgets/common/basic/outlined_text.dart +++ b/lib/widgets/common/basic/outlined_text.dart @@ -1,57 +1,66 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; class OutlinedText extends StatelessWidget { - final String text; - final TextStyle style; + final List textSpans; final double outlineWidth; final Color outlineColor; + final double outlineBlurSigma; final TextAlign? textAlign; static const widgetSpanAlignment = PlaceholderAlignment.middle; const OutlinedText({ Key? key, - required this.text, - required this.style, + required this.textSpans, double? outlineWidth, Color? outlineColor, + double? outlineBlurSigma, this.textAlign, }) : outlineWidth = outlineWidth ?? 1, outlineColor = outlineColor ?? Colors.black, + outlineBlurSigma = outlineBlurSigma ?? 0, super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: text, - style: style.copyWith( - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = outlineWidth - ..color = outlineColor, + ImageFiltered( + imageFilter: outlineBlurSigma > 0 + ? ImageFilter.blur( + sigmaX: outlineBlurSigma, + sigmaY: outlineBlurSigma, + ) + : ImageFilter.matrix( + Matrix4.identity().storage, ), - ), - ], + child: Text.rich( + TextSpan( + children: textSpans.map(_toStrokeSpan).toList(), + ), + textAlign: textAlign, ), - textAlign: textAlign, ), Text.rich( TextSpan( - children: [ - TextSpan( - text: text, - style: style, - ), - ], + children: textSpans, ), textAlign: textAlign, ), ], ); } + + TextSpan _toStrokeSpan(TextSpan span) => TextSpan( + text: span.text, + children: span.children, + style: (span.style ?? const TextStyle()).copyWith( + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = outlineWidth + ..color = outlineColor, + ), + ); } diff --git a/lib/widgets/viewer/info/maps/scale_layer.dart b/lib/widgets/viewer/info/maps/scale_layer.dart index e8805975a..ed6808507 100644 --- a/lib/widgets/viewer/info/maps/scale_layer.dart +++ b/lib/widgets/viewer/info/maps/scale_layer.dart @@ -118,11 +118,15 @@ class ScaleBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ OutlinedText( - text: distance, - style: const TextStyle( - color: fillColor, - fontSize: 11, - ), + textSpans: [ + TextSpan( + text: distance, + style: const TextStyle( + color: fillColor, + fontSize: 11, + ), + ) + ], outlineWidth: outlineWidth * 2, outlineColor: outlineColor, ), diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index a2a530e67..3778293c1 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -92,6 +92,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // FFmpeg options // cf https://github.com/Bilibili/ijkplayer/blob/master/ijkmedia/ijkplayer/ff_ffplay_options.h // cf https://www.jianshu.com/p/843c86a9e9ad + // cf https://www.jianshu.com/p/3649c073b346 final options = FijkOption(); @@ -104,10 +105,10 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // so HW acceleration is always disabled for GIF-like videos where the last frames may be significant final hwAccelerationEnabled = settings.enableVideoHardwareAcceleration && entry.durationMillis! > gifLikeVideoDurationThreshold.inMilliseconds; - // TODO TLAD [video] HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR) + // TODO TLAD [video] flaky: HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR) if (hwAccelerationEnabled) { // when HW acceleration is enabled, videos with dimensions that do not fit 16x macroblocks need cropping - // TODO TLAD [video] not all formats/devices need this correction, e.g. 498x278 MP4 on S7, 408x244 WEBM on S10e do not + // TODO TLAD [video] flaky: not all formats/devices need this correction, e.g. 498x278 MP4 on S7, 408x244 WEBM on S10e do not final s = entry.displaySize % 16 * -1 % 16; _macroBlockCrop = Offset(s.width, s.height); } @@ -290,7 +291,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { _applySpeed(); } - // TODO TLAD [video] setting speed fails when there is no audio stream or audio is disabled + // TODO TLAD [video] bug: setting speed fails when there is no audio stream or audio is disabled void _applySpeed() => _instance.setSpeed(speed); ValueNotifier selectedStreamNotifier(StreamType type) { @@ -339,6 +340,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { return Map.fromEntries(_streams.map((stream) => MapEntry(stream, selectedIndices.contains(stream.index)))); } + // TODO TLAD [video] bug: crash when video stream is not supported @override Future captureFrame() => _instance.takeSnapShot(); diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 1fa1c310e..0be4be117 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -20,7 +20,7 @@ import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/visual/error.dart'; import 'package:aves/widgets/viewer/visual/raster.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; -import 'package:aves/widgets/viewer/visual/subtitle.dart'; +import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart'; import 'package:aves/widgets/viewer/visual/vector.dart'; import 'package:aves/widgets/viewer/visual/video.dart'; import 'package:flutter/foundation.dart'; @@ -208,10 +208,12 @@ class _EntryPageViewState extends State { ), VideoSubtitles( controller: videoController, + viewStateNotifier: _viewStateNotifier, ), if (settings.videoShowRawTimedText) VideoSubtitles( controller: videoController, + viewStateNotifier: _viewStateNotifier, debugMode: true, ), ], diff --git a/lib/widgets/viewer/visual/subtitle.dart b/lib/widgets/viewer/visual/subtitle.dart deleted file mode 100644 index 43dcb790e..000000000 --- a/lib/widgets/viewer/visual/subtitle.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:aves/widgets/common/basic/outlined_text.dart'; -import 'package:aves/widgets/viewer/video/controller.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class VideoSubtitles extends StatelessWidget { - final AvesVideoController controller; - final bool debugMode; - - const VideoSubtitles({ - Key? key, - required this.controller, - this.debugMode = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Selector( - selector: (c, mq) => mq.orientation, - builder: (c, orientation, child) { - final y = orientation == Orientation.portrait ? .5 : .8; - return Align( - alignment: Alignment(0, debugMode ? -y : y), - child: child, - ); - }, - child: StreamBuilder( - stream: controller.timedTextStream, - builder: (context, snapshot) { - final text = snapshot.data; - return text != null - ? SubtitleText( - text: text, - debugMode: debugMode, - ) - : const SizedBox(); - }, - ), - ); - } -} - -class SubtitleText extends StatelessWidget { - final String text; - final bool debugMode; - - const SubtitleText({ - Key? key, - required this.text, - this.debugMode = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - late final String displayText; - - if (debugMode) { - displayText = text; - } else { - // TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/ - // e.g. `And I'm like, "We can't {\i1}not{\i0} see it."` - // e.g. `{\fad(200,200)\blur3}lorem ipsum"` - // e.g. `{\fnCrapFLTSB\an9\bord5\fs70\c&H403A2D&\3c&HE5E5E8&\pos(1868.286,27.429)}lorem ipsum"` - // implement these with RegExp + TextSpans: - // \i: italics - // \b: bold - // \c: fill color - // \1c: fill color - // \3c: border color - // \r: reset - displayText = text.replaceAll(RegExp('{.*?}'), ''); - } - - return OutlinedText( - text: displayText, - style: const TextStyle( - fontSize: 20, - shadows: [ - Shadow( - color: Colors.black54, - offset: Offset(1, 1), - ), - ], - ), - outlineWidth: 1, - outlineColor: Colors.black, - textAlign: TextAlign.center, - ); - } -} diff --git a/lib/widgets/viewer/visual/subtitle/ass_parser.dart b/lib/widgets/viewer/visual/subtitle/ass_parser.dart new file mode 100644 index 000000000..1682b29dd --- /dev/null +++ b/lib/widgets/viewer/visual/subtitle/ass_parser.dart @@ -0,0 +1,295 @@ +import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +class AssParser { + static final tagPattern = RegExp(r'(' + r'1a|2a|3a|4a' + r'|1c|2c|3c|4c' + r'|alpha|an|a' + r'|be|blur|bord|b' + r'|clip|c' + r'|fade|fad|fax|fay|fe|fn' + r'|frx|fry|frz|fr|fscx|fscy|fsp|fs' + r'|iclip|i' + r'|kf|ko|k|K' + r'|move|org' + r'|pbo|pos|p' + r'|q|r' + r'|shad|s' + r'|t|u' + r'|xbord|xshad|ybord|yshad' + r')'); + + // &H + static final alphaPattern = RegExp('&H(..)'); + + // &H& + static final colorPattern = RegExp('&H(..)(..)(..)&'); + + static const noBreakSpace = '\u00A0'; + + // TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/ + // e.g. `And I'm like, "We can't {\i1}not{\i0} see it."` + // e.g. `{\fad(200,200)\blur3}lorem ipsum"` + // e.g. `{\fnCrapFLTSB\an9\bord5\fs70\c&H403A2D&\3c&HE5E5E8&\pos(1868.286,27.429)}lorem ipsum"` + static List> parseAss(String text, TextStyle baseStyle, double scale) { + final spans = >[]; + var extraStyle = const SubtitleExtraStyle(); + var textStyle = baseStyle; + var i = 0; + final matches = RegExp(r'{(.*?)}').allMatches(text); + matches.forEach((m) { + if (i != m.start) { + spans.add(Tuple2( + TextSpan( + text: _replaceChars(text.substring(i, m.start)), + style: textStyle, + ), + extraStyle, + )); + } + i = m.end; + final tags = m.group(1); + tags?.split('\\').where((v) => v.isNotEmpty).forEach((tagWithParam) { + final tag = tagPattern.firstMatch(tagWithParam)?.group(1); + if (tag != null) { + final param = tagWithParam.substring(tag.length); + switch (tag) { + case 'r': + { + // \r: reset + textStyle = baseStyle; + break; + } + case 'alpha': + { + // \alpha: alpha of all components at once + final a = _parseAlpha(param); + if (a != null) { + textStyle = textStyle.copyWith( + color: textStyle.color?.withAlpha(a), + shadows: textStyle.shadows + ?.map((v) => Shadow( + color: v.color.withAlpha(a), + offset: v.offset, + blurRadius: v.blurRadius, + )) + .toList()); + } + break; + } + case 'an': + { + // \an: alignment + var hAlign = TextAlign.center; + var vAlign = TextAlignVertical.bottom; + final alignment = _parseAlignment(param); + if (alignment != null) { + if (alignment.x < 0) { + hAlign = TextAlign.left; + } else if (alignment.x > 0) { + hAlign = TextAlign.right; + } + if (alignment.y < 0) { + vAlign = TextAlignVertical.top; + } else if (alignment.y == 0) { + vAlign = TextAlignVertical.center; + } + extraStyle = extraStyle.copyWith( + hAlign: hAlign, + vAlign: vAlign, + ); + } + break; + } + case 'b': + { + // \b: bold + textStyle = textStyle.copyWith(fontWeight: param == '1' ? FontWeight.bold : FontWeight.normal); + break; + } + case 'blur': + { + // \blur: blurs the edges of the text + final strength = double.tryParse(param); + if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength); + break; + } + case 'bord': + { + // \bord: border width + final size = double.tryParse(param); + if (size != null) extraStyle = extraStyle.copyWith(borderWidth: size); + break; + } + case 'c': + case '1c': + { + // \c or \1c: fill color + final color = _parseColor(param); + if (color != null) { + textStyle = textStyle.copyWith(color: color.withAlpha(textStyle.color?.alpha ?? 0xFF)); + } + break; + } + case '3c': + { + // \3c: border color + final color = _parseColor(param); + if (color != null) { + extraStyle = extraStyle.copyWith( + borderColor: color.withAlpha(extraStyle.borderColor?.alpha ?? 0xFF), + ); + } + break; + } + case '4c': + { + // \4c: shadow color + final color = _parseColor(param); + if (color != null) { + textStyle = textStyle.copyWith( + shadows: textStyle.shadows + ?.map((v) => Shadow( + color: color.withAlpha(v.color.alpha), + offset: v.offset, + blurRadius: v.blurRadius, + )) + .toList()); + } + break; + } + case 'fax': + { + final factor = double.tryParse(param); + if (factor != null) extraStyle = extraStyle.copyWith(shearX: factor); + break; + } + case 'fay': + { + final factor = double.tryParse(param); + if (factor != null) extraStyle = extraStyle.copyWith(shearY: factor); + break; + } + case 'fn': + { + final name = param; + // TODO TLAD [video] extract fonts from attachment streams, and load these fonts in Flutter + if (name.isNotEmpty) textStyle = textStyle.copyWith(fontFamily: name); + break; + } + case 'fs': + { + final size = int.tryParse(param); + if (size != null) textStyle = textStyle.copyWith(fontSize: size * scale); + break; + } + case 'frx': + { + final amount = double.tryParse(param); + if (amount != null) extraStyle = extraStyle.copyWith(rotationX: amount); + break; + } + case 'fry': + { + final amount = double.tryParse(param); + if (amount != null) extraStyle = extraStyle.copyWith(rotationY: amount); + break; + } + case 'fr': + case 'frz': + { + final amount = double.tryParse(param); + if (amount != null) extraStyle = extraStyle.copyWith(rotationZ: amount); + break; + } + case 'fscx': + { + final scale = int.tryParse(param); + if (scale != null) extraStyle = extraStyle.copyWith(scaleX: scale.toDouble() / 100); + break; + } + case 'fscy': + { + final scale = int.tryParse(param); + if (scale != null) extraStyle = extraStyle.copyWith(scaleY: scale.toDouble() / 100); + break; + } + case 'i': + { + // \i: italics + textStyle = textStyle.copyWith(fontStyle: param == '1' ? FontStyle.italic : FontStyle.normal); + break; + } + default: + debugPrint('unhandled ASS tag=$tag'); + } + } + }); + }); + if (i != text.length) { + spans.add(Tuple2( + TextSpan( + text: _replaceChars(text.substring(i, text.length)), + style: textStyle, + ), + extraStyle, + )); + } + return spans; + } + + static String _replaceChars(String text) => text.replaceAll(r'\h', noBreakSpace).replaceAll(r'\N', '\n'); + + static Alignment? _parseAlignment(String param) { + switch (int.tryParse(param)) { + case 1: + return Alignment.bottomLeft; + case 2: + return Alignment.bottomCenter; + case 3: + return Alignment.bottomRight; + case 4: + return Alignment.centerLeft; + case 5: + return Alignment.center; + case 6: + return Alignment.centerRight; + case 7: + return Alignment.topLeft; + case 8: + return Alignment.topCenter; + case 9: + return Alignment.topRight; + default: + return null; + } + } + + static int? _parseAlpha(String param) { + final match = alphaPattern.firstMatch(param); + if (match != null) { + final as = match.group(1); + final ai = int.tryParse('$as', radix: 16); + if (ai != null) { + return 0xFF - ai; + } + } + return null; + } + + static Color? _parseColor(String param) { + final match = colorPattern.firstMatch(param); + if (match != null) { + final bs = match.group(1); + final gs = match.group(2); + final rs = match.group(3); + final rgb = int.tryParse('ff$rs$gs$bs', radix: 16); + if (rgb != null) { + return Color(rgb); + } + } + return null; + } +} diff --git a/lib/widgets/viewer/visual/subtitle/style.dart b/lib/widgets/viewer/visual/subtitle/style.dart new file mode 100644 index 000000000..14ca22571 --- /dev/null +++ b/lib/widgets/viewer/visual/subtitle/style.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class SubtitleExtraStyle with Diagnosticable { + final TextAlign? hAlign; + final TextAlignVertical? vAlign; + final Color? borderColor; + final double? borderWidth, edgeBlur, rotationX, rotationY, rotationZ, scaleX, scaleY, shearX, shearY; + final TransitionBuilder? builder; + + bool get rotating => (rotationX ?? 0) > 0 || (rotationY ?? 0) > 0 || (rotationZ ?? 0) > 0; + + bool get scaling => (scaleX ?? 0) > 0 || (scaleY ?? 0) > 0; + + bool get shearing => (shearX ?? 0) > 0 || (shearY ?? 0) > 0; + + const SubtitleExtraStyle({ + this.hAlign, + this.vAlign, + this.borderColor, + this.borderWidth, + this.edgeBlur, + this.rotationX, + this.rotationY, + this.rotationZ, + this.scaleX, + this.scaleY, + this.shearX, + this.shearY, + this.builder, + }); + + SubtitleExtraStyle copyWith({ + TextAlign? hAlign, + TextAlignVertical? vAlign, + Color? borderColor, + double? borderWidth, + double? edgeBlur, + double? rotationX, + double? rotationY, + double? rotationZ, + double? scaleX, + double? scaleY, + double? shearX, + double? shearY, + TransitionBuilder? builder, + }) { + return SubtitleExtraStyle( + hAlign: hAlign ?? this.hAlign, + vAlign: vAlign ?? this.vAlign, + borderColor: borderColor ?? this.borderColor, + borderWidth: borderWidth ?? this.borderWidth, + edgeBlur: edgeBlur ?? this.edgeBlur, + rotationX: rotationX ?? this.rotationX, + rotationY: rotationY ?? this.rotationY, + rotationZ: rotationZ ?? this.rotationZ, + scaleX: scaleX ?? this.scaleX, + scaleY: scaleY ?? this.scaleY, + shearX: shearX ?? this.shearX, + shearY: shearY ?? this.shearY, + builder: builder ?? this.builder, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('hAlign', hAlign)); + properties.add(DiagnosticsProperty('vAlign', vAlign)); + properties.add(ColorProperty('borderColor', borderColor)); + properties.add(DoubleProperty('borderWidth', borderWidth)); + properties.add(DoubleProperty('edgeBlur', edgeBlur)); + properties.add(DoubleProperty('rotationX', rotationX)); + properties.add(DoubleProperty('rotationY', rotationY)); + properties.add(DoubleProperty('rotationZ', rotationZ)); + properties.add(DoubleProperty('scaleX', scaleX)); + properties.add(DoubleProperty('scaleY', scaleY)); + properties.add(DoubleProperty('shearX', shearX)); + properties.add(DoubleProperty('shearY', shearY)); + properties.add(DiagnosticsProperty('builder', builder)); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is SubtitleExtraStyle && other.hAlign == hAlign && other.vAlign == vAlign && other.borderColor == borderColor && other.borderWidth == borderWidth && other.edgeBlur == edgeBlur && other.rotationX == rotationX && other.rotationY == rotationY && other.rotationZ == rotationZ && other.scaleX == scaleX && other.scaleY == scaleY && other.shearX == shearX && other.shearY == shearY && other.builder == builder; + } + + @override + int get hashCode => hashValues( + hAlign, + vAlign, + borderColor, + borderWidth, + edgeBlur, + rotationX, + rotationY, + rotationZ, + scaleX, + scaleY, + shearX, + shearY, + builder, + ); +} diff --git a/lib/widgets/viewer/visual/subtitle/subtitle.dart b/lib/widgets/viewer/visual/subtitle/subtitle.dart new file mode 100644 index 000000000..070513645 --- /dev/null +++ b/lib/widgets/viewer/visual/subtitle/subtitle.dart @@ -0,0 +1,132 @@ +import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/widgets/viewer/visual/subtitle/ass_parser.dart'; +import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class VideoSubtitles extends StatelessWidget { + final AvesVideoController controller; + final ValueNotifier viewStateNotifier; + final bool debugMode; + + static const baseStyle = TextStyle( + color: Colors.white, + fontSize: 20, + shadows: [ + Shadow( + color: Colors.black54, + offset: Offset(1, 1), + ), + ], + ); + + const VideoSubtitles({ + Key? key, + required this.controller, + required this.viewStateNotifier, + this.debugMode = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Selector( + selector: (c, mq) => mq.orientation, + builder: (c, orientation, child) { + final bottom = orientation == Orientation.portrait ? .5 : .8; + Alignment toVerticalAlignment(SubtitleExtraStyle extraStyle) { + switch (extraStyle.vAlign) { + case TextAlignVertical.top: + return Alignment(0, -bottom); + case TextAlignVertical.center: + return Alignment.center; + case TextAlignVertical.bottom: + default: + return Alignment(0, bottom); + } + } + + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + return StreamBuilder( + stream: controller.timedTextStream, + builder: (context, snapshot) { + final text = snapshot.data; + if (text == null) return const SizedBox(); + + if (debugMode) { + return Center( + child: OutlinedText( + textSpans: [TextSpan(text: text)], + outlineWidth: 1, + outlineColor: Colors.black, + // textAlign: TextAlign.center, + ), + ); + } + + final styledSpans = AssParser.parseAss(text, baseStyle, viewState.scale ?? 1); + final byStyle = groupBy, SubtitleExtraStyle>(styledSpans, (v) => v.item2); + return Stack( + children: byStyle.entries.map((kv) { + final extraStyle = kv.key; + final spans = kv.value.map((v) => v.item1).toList(); + + Widget child = OutlinedText( + textSpans: spans, + outlineWidth: extraStyle.borderWidth ?? 1, + outlineColor: extraStyle.borderColor ?? Colors.black, + outlineBlurSigma: extraStyle.edgeBlur ?? 0, + textAlign: extraStyle.hAlign ?? TextAlign.center, + ); + + var transform = Matrix4.identity(); + if (extraStyle.rotating) { + // for perspective + transform.setEntry(3, 2, 0.001); + final x = -toRadians(extraStyle.rotationX ?? 0); + final y = -toRadians(extraStyle.rotationY ?? 0); + final z = -toRadians(extraStyle.rotationZ ?? 0); + if (x != 0) transform.rotateX(x); + if (y != 0) transform.rotateY(y); + if (z != 0) transform.rotateZ(z); + } + if (extraStyle.scaling) { + final x = extraStyle.scaleX ?? 1; + final y = extraStyle.scaleY ?? 1; + transform.scale(x, y); + } + if (extraStyle.shearing) { + final x = extraStyle.shearX ?? 0; + final y = extraStyle.shearY ?? 0; + transform.multiply(Matrix4(1, y, 0, 0, x, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)); + } + if (!transform.isIdentity()) { + child = Transform( + transform: transform, + alignment: Alignment.center, + child: child, + ); + } + + return Align( + alignment: toVerticalAlignment(extraStyle), + child: child, + ); + }).toList(), + ); + }, + ); + }, + ); + }, + ), + ); + } +}