diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index 1f061a425..16dbb6537 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -295,42 +295,6 @@ class _DraggableScrollbarState extends State with TickerProv } } -/// Draws 2 triangles like arrow up and arrow down -class ArrowCustomPainter extends CustomPainter { - Color color; - - ArrowCustomPainter(this.color); - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint()..color = color; - const width = 12.0; - const height = 8.0; - final baseX = size.width / 2; - final baseY = size.height / 2; - - canvas.drawPath( - _trianglePath(Offset(baseX, baseY - 2.0), width, height, true), - paint, - ); - canvas.drawPath( - _trianglePath(Offset(baseX, baseY + 2.0), width, height, false), - paint, - ); - } - - static Path _trianglePath(Offset o, double width, double height, bool isUp) { - return Path() - ..moveTo(o.dx, o.dy) - ..lineTo(o.dx + width, o.dy) - ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height) - ..close(); - } -} - ///This cut 2 lines in arrow shape class ArrowClipper extends CustomClipper { @override @@ -366,7 +330,7 @@ class ArrowClipper extends CustomClipper { } @override - bool shouldReclip(CustomClipper oldClipper) => false; + bool shouldReclip(covariant CustomClipper oldClipper) => false; } class SlideFadeTransition extends StatelessWidget { diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart index c5bc26520..d1d7bdb99 100644 --- a/lib/widgets/common/fx/checkered_decoration.dart +++ b/lib/widgets/common/fx/checkered_decoration.dart @@ -36,5 +36,5 @@ class CheckeredPainter extends CustomPainter { } @override - bool shouldRepaint(CustomPainter oldDelegate) => false; + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index a724f0ddb..1c3c6851a 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -153,5 +153,5 @@ class _SweepClipPath extends CustomClipper { } @override - bool shouldReclip(CustomClipper oldClipper) => true; + bool shouldReclip(covariant CustomClipper oldClipper) => true; } diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index 9c96574f1..f75c65bab 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -188,5 +188,5 @@ class _TransitionImagePainter extends CustomPainter { } @override - bool shouldRepaint(CustomPainter oldDelegate) => true; + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; } diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 6cf59b501..c6077383e 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -289,5 +289,5 @@ class GridPainter extends CustomPainter { } @override - bool shouldRepaint(CustomPainter oldDelegate) => true; + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; } diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart index f055b4d15..3a1eb1b52 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -120,7 +120,7 @@ class MarkerPointerPainter extends CustomPainter { } @override - bool shouldRepaint(CustomPainter oldDelegate) => false; + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } // generate bitmap from widget, for Google Maps diff --git a/lib/widgets/viewer/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart index e8c2845de..42584db2d 100644 --- a/lib/widgets/viewer/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -117,5 +117,5 @@ class MinimapPainter extends CustomPainter { } @override - bool shouldRepaint(CustomPainter oldDelegate) => true; + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; } diff --git a/lib/widgets/viewer/visual/subtitle/ass_parser.dart b/lib/widgets/viewer/visual/subtitle/ass_parser.dart index 1682b29dd..0f1a8a473 100644 --- a/lib/widgets/viewer/visual/subtitle/ass_parser.dart +++ b/lib/widgets/viewer/visual/subtitle/ass_parser.dart @@ -1,6 +1,7 @@ +import 'package:aves/widgets/viewer/visual/subtitle/line.dart'; +import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; 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'(' @@ -27,26 +28,34 @@ class AssParser { // &H& static final colorPattern = RegExp('&H(..)(..)(..)&'); + // (,) + static final multiParamPattern = RegExp('\\((.*)\\)'); + + // e.g. m 50 0 b 100 0 100 100 50 100 b 0 100 0 0 50 0 + static final pathPattern = RegExp(r'([mnlbspc])([\s\d]+)'); + static const noBreakSpace = '\u00A0'; - // TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/ + // Parse text with ASS format 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(); + static StyledSubtitleLine parse(String text, TextStyle baseStyle, double scale) { + final spans = []; + var line = StyledSubtitleLine(spans: spans); + var extraStyle = const SubtitleStyle(); 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)), + spans.add(StyledSubtitleSpan( + textSpan: TextSpan( + text: extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i, m.start)), style: textStyle, ), - extraStyle, + extraStyle: extraStyle, )); } i = m.end; @@ -56,12 +65,6 @@ class AssParser { 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 @@ -79,41 +82,33 @@ class AssParser { } break; } + case 'a': + // \a: line alignment (legacy) + extraStyle = _copyWithAlignment(_parseLegacyAlignment(param), extraStyle); + 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; - } + // \an: line alignment + extraStyle = _copyWithAlignment(_parseNewAlignment(param), extraStyle); + break; case 'b': { // \b: bold - textStyle = textStyle.copyWith(fontWeight: param == '1' ? FontWeight.bold : FontWeight.normal); + final weight = _parseFontWeight(param); + if (weight != null) textStyle = textStyle.copyWith(fontWeight: weight); + break; + } + case 'be': + { + // \be: blurs the edges of the text + final times = int.tryParse(param); + if (times != null) extraStyle = extraStyle.copyWith(edgeBlur: times == 0 ? 0 : 1); break; } case 'blur': { - // \blur: blurs the edges of the text + // \blur: blurs the edges of the text (Gaussian kernel) final strength = double.tryParse(param); - if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength); + if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength / 2); break; } case 'bord': @@ -160,6 +155,10 @@ class AssParser { } break; } + case 'clip': + // \clip: clip (within rectangle or path) + line = line.copyWith(clip: _parseClip(param)); + break; case 'fax': { final factor = double.tryParse(param); @@ -175,24 +174,20 @@ class AssParser { case 'fn': { final name = param; - // TODO TLAD [video] extract fonts from attachment streams, and load these fonts in Flutter + // TODO TLAD [subtitles] 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': { + // \frx: text rotation (X axis) final amount = double.tryParse(param); if (amount != null) extraStyle = extraStyle.copyWith(rotationX: amount); break; } case 'fry': { + // \fry: text rotation (Y axis) final amount = double.tryParse(param); if (amount != null) extraStyle = extraStyle.copyWith(rotationY: amount); break; @@ -200,28 +195,111 @@ class AssParser { case 'fr': case 'frz': { + // \frz: text rotation (Z axis) final amount = double.tryParse(param); if (amount != null) extraStyle = extraStyle.copyWith(rotationZ: amount); break; } + case 'fs': + { + // \fs: font size + final size = int.tryParse(param); + if (size != null) textStyle = textStyle.copyWith(fontSize: size * scale); + break; + } case 'fscx': { + // \fscx: font scale (horizontal) final scale = int.tryParse(param); if (scale != null) extraStyle = extraStyle.copyWith(scaleX: scale.toDouble() / 100); break; } case 'fscy': { + // \fscx: font scale (vertical) 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; + case 'p': { - // \i: italics - textStyle = textStyle.copyWith(fontStyle: param == '1' ? FontStyle.italic : FontStyle.normal); + // \p drawing paths + final scale = int.tryParse(param); + if (scale != null) { + extraStyle = extraStyle.copyWith( + drawingPaths: scale > 0 + ? _parsePaths( + text.substring(m.end), + scale, + ) + : null); + } break; } + case 'pos': + { + // \pos: line position + final match = multiParamPattern.firstMatch(param); + if (match != null) { + final g = match.group(1); + if (g != null) { + final params = g.split(','); + if (params.length == 2) { + final x = double.tryParse(params[0]); + final y = double.tryParse(params[1]); + if (x != null && y != null) { + line = line.copyWith(position: Offset(x, y)); + } + } + } + } + break; + } + case 'r': + // \r: reset + textStyle = baseStyle; + break; + case 's': + // \s: strikeout + textStyle = textStyle.copyWith(decoration: param == '1' ? TextDecoration.lineThrough : TextDecoration.none); + break; + case 'u': + // \u: underline + textStyle = textStyle.copyWith(decoration: param == '1' ? TextDecoration.underline : TextDecoration.none); + break; + // TODO TLAD [subtitles] SHOULD support the following + case '1a': + case '3a': + case '4a': + case 'shad': + case 't': // \t: animated transform + case 'xshad': + case 'yshad': + // line props: \pos, \move, \clip, \iclip, \org, \fade and \fad + case 'iclip': // \iclip: clip (inverse) + case 'fad': // \fad: fade + case 'fade': // \fade: fade (complex) + case 'move': // \move: movement + case 'org': // \org: rotation origin + // TODO TLAD [subtitles] MAY support the following + case 'fe': // \fe: font encoding + case 'fsp': // \fsp: letter spacing + case 'pbo': // \pbo: baseline offset + case 'q': // \q: wrap style + // border size + case 'xbord': + case 'ybord': + // karaoke + case '2a': + case '2c': + case 'k': + case 'K': + case 'kf': + case 'ko': default: debugPrint('unhandled ASS tag=$tag'); } @@ -229,44 +307,40 @@ class AssParser { }); }); if (i != text.length) { - spans.add(Tuple2( - TextSpan( - text: _replaceChars(text.substring(i, text.length)), + spans.add(StyledSubtitleSpan( + textSpan: TextSpan( + text: extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i)), style: textStyle, ), - extraStyle, + extraStyle: extraStyle, )); } - return spans; + return line; + } + + static SubtitleStyle _copyWithAlignment(Alignment? alignment, SubtitleStyle extraStyle) { + if (alignment == null) return extraStyle; + + var hAlign = TextAlign.center; + var vAlign = TextAlignVertical.bottom; + 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; + } + return extraStyle.copyWith( + hAlign: hAlign, + vAlign: vAlign, + ); } 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) { @@ -292,4 +366,182 @@ class AssParser { } return null; } + + static FontWeight? _parseFontWeight(String param) { + switch (int.tryParse(param)) { + case 0: + return FontWeight.normal; + case 1: + return FontWeight.bold; + case 100: + return FontWeight.w100; + case 200: + return FontWeight.w200; + case 300: + return FontWeight.w300; + case 400: + return FontWeight.w400; + case 500: + return FontWeight.w500; + case 600: + return FontWeight.w600; + case 700: + return FontWeight.w700; + case 800: + return FontWeight.w800; + case 900: + return FontWeight.w900; + default: + return null; + } + } + + static Alignment? _parseNewAlignment(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 Alignment? _parseLegacyAlignment(String param) { + switch (int.tryParse(param)) { + case 1: + return Alignment.bottomLeft; + case 2: + return Alignment.bottomCenter; + case 3: + return Alignment.bottomRight; + case 5: + return Alignment.topLeft; + case 6: + return Alignment.topCenter; + case 7: + return Alignment.topRight; + case 9: + return Alignment.centerLeft; + case 10: + return Alignment.center; + case 11: + return Alignment.centerRight; + default: + return null; + } + } + + static List _parsePaths(String commands, int scale) { + final paths = []; + Path? path; + pathPattern.allMatches(commands).forEach((match) { + if (match.groupCount == 2) { + final command = match.group(1)!; + final params = match.group(2)!.trim().split(' ').map(double.tryParse).where((v) => v != null).cast().map((v) => v / scale).toList(); + switch (command) { + case 'b': + if (path != null) { + const bParamCount = 6; + final steps = (params.length / bParamCount).floor(); + for (var i = 0; i < steps; i++) { + final points = params.skip(i * bParamCount).take(bParamCount).toList(); + path!.cubicTo(points[0], points[1], points[2], points[3], points[4], points[5]); + } + } + break; + case 'c': + if (path != null) { + path!.close(); + } + path = null; + break; + case 'l': + if (path != null) { + const lParamCount = 2; + final steps = (params.length / lParamCount).floor(); + for (var i = 0; i < steps; i++) { + final points = params.skip(i * lParamCount).take(lParamCount).toList(); + path!.lineTo(points[0], points[1]); + } + } + break; + case 'm': + if (params.length == 2) { + if (path != null) { + path!.close(); + } + path = Path(); + paths.add(path!); + path!.moveTo(params[0], params[1]); + } + break; + case 'n': + if (params.length == 2 && path != null) { + path!.moveTo(params[0], params[1]); + } + break; + case 's': + case 'p': + debugPrint('unhandled ASS drawing command=$command'); + break; + } + } + }); + if (path != null) { + path!.close(); + } + return paths; + } + + static List? _parseClip(String param) { + List? paths; + final match = multiParamPattern.firstMatch(param); + if (match != null) { + final g = match.group(1); + if (g != null) { + final params = g.split(','); + if (params.length == 4) { + final points = params.map(double.tryParse).where((v) => v != null).cast().toList(); + if (points.length == 4) { + paths = [ + Path() + ..addRect(Rect.fromPoints( + Offset(points[0], points[1]), + Offset(points[2], points[3]), + )) + ]; + } + } else { + int? scale; + String? commands; + if (params.length == 1) { + scale = 1; + commands = params[0]; + } else if (params.length == 2) { + scale = int.tryParse(params[0]); + commands = params[1]; + } + if (scale != null && commands != null) { + paths = _parsePaths(commands, scale); + } + } + } + } + return paths; + } } diff --git a/lib/widgets/viewer/visual/subtitle/line.dart b/lib/widgets/viewer/visual/subtitle/line.dart new file mode 100644 index 000000000..c7fb5c60a --- /dev/null +++ b/lib/widgets/viewer/visual/subtitle/line.dart @@ -0,0 +1,49 @@ +import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class StyledSubtitleLine with Diagnosticable { + final List spans; + final List? clip; + final Offset? position; + + const StyledSubtitleLine({ + required this.spans, + this.clip, + this.position, + }); + + StyledSubtitleLine copyWith({ + List? spans, + List? clip, + Offset? position, + }) { + return StyledSubtitleLine( + spans: spans ?? this.spans, + clip: clip ?? this.clip, + position: position ?? this.position, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty>('spans', spans)); + properties.add(DiagnosticsProperty>('clip', clip)); + properties.add(DiagnosticsProperty('position', position)); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is StyledSubtitleLine && other.spans == spans && other.clip == clip && other.position == position; + } + + @override + int get hashCode => hashValues( + spans, + clip, + position, + ); +} diff --git a/lib/widgets/viewer/visual/subtitle/span.dart b/lib/widgets/viewer/visual/subtitle/span.dart new file mode 100644 index 000000000..b83389591 --- /dev/null +++ b/lib/widgets/viewer/visual/subtitle/span.dart @@ -0,0 +1,43 @@ +import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class StyledSubtitleSpan with Diagnosticable { + final TextSpan textSpan; + final SubtitleStyle extraStyle; + + const StyledSubtitleSpan({ + required this.textSpan, + required this.extraStyle, + }); + + StyledSubtitleSpan copyWith({ + TextSpan? textSpan, + SubtitleStyle? extraStyle, + }) { + return StyledSubtitleSpan( + textSpan: textSpan ?? this.textSpan, + extraStyle: extraStyle ?? this.extraStyle, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('textSpan', textSpan)); + properties.add(DiagnosticsProperty('extraStyle', extraStyle)); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is StyledSubtitleSpan && other.textSpan == textSpan && other.extraStyle == extraStyle; + } + + @override + int get hashCode => hashValues( + textSpan, + extraStyle, + ); +} diff --git a/lib/widgets/viewer/visual/subtitle/style.dart b/lib/widgets/viewer/visual/subtitle/style.dart index 14ca22571..478d47405 100644 --- a/lib/widgets/viewer/visual/subtitle/style.dart +++ b/lib/widgets/viewer/visual/subtitle/style.dart @@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class SubtitleExtraStyle with Diagnosticable { +class SubtitleStyle 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; + final List? drawingPaths; bool get rotating => (rotationX ?? 0) > 0 || (rotationY ?? 0) > 0 || (rotationZ ?? 0) > 0; @@ -15,7 +15,7 @@ class SubtitleExtraStyle with Diagnosticable { bool get shearing => (shearX ?? 0) > 0 || (shearY ?? 0) > 0; - const SubtitleExtraStyle({ + const SubtitleStyle({ this.hAlign, this.vAlign, this.borderColor, @@ -28,10 +28,10 @@ class SubtitleExtraStyle with Diagnosticable { this.scaleY, this.shearX, this.shearY, - this.builder, + this.drawingPaths, }); - SubtitleExtraStyle copyWith({ + SubtitleStyle copyWith({ TextAlign? hAlign, TextAlignVertical? vAlign, Color? borderColor, @@ -44,9 +44,9 @@ class SubtitleExtraStyle with Diagnosticable { double? scaleY, double? shearX, double? shearY, - TransitionBuilder? builder, + List? drawingPaths, }) { - return SubtitleExtraStyle( + return SubtitleStyle( hAlign: hAlign ?? this.hAlign, vAlign: vAlign ?? this.vAlign, borderColor: borderColor ?? this.borderColor, @@ -59,7 +59,7 @@ class SubtitleExtraStyle with Diagnosticable { scaleY: scaleY ?? this.scaleY, shearX: shearX ?? this.shearX, shearY: shearY ?? this.shearY, - builder: builder ?? this.builder, + drawingPaths: drawingPaths ?? this.drawingPaths, ); } @@ -78,13 +78,13 @@ class SubtitleExtraStyle with Diagnosticable { properties.add(DoubleProperty('scaleY', scaleY)); properties.add(DoubleProperty('shearX', shearX)); properties.add(DoubleProperty('shearY', shearY)); - properties.add(DiagnosticsProperty('builder', builder)); + properties.add(DiagnosticsProperty>('drawingPaths', drawingPaths)); } @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; + return other is SubtitleStyle && 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.drawingPaths == drawingPaths; } @override @@ -101,6 +101,6 @@ class SubtitleExtraStyle with Diagnosticable { scaleY, shearX, shearY, - builder, + drawingPaths?.length, ); } diff --git a/lib/widgets/viewer/visual/subtitle/subtitle.dart b/lib/widgets/viewer/visual/subtitle/subtitle.dart index 070513645..9896a3664 100644 --- a/lib/widgets/viewer/visual/subtitle/subtitle.dart +++ b/lib/widgets/viewer/visual/subtitle/subtitle.dart @@ -3,11 +3,12 @@ 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/span.dart'; import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class VideoSubtitles extends StatelessWidget { final AvesVideoController controller; @@ -34,12 +35,13 @@ class VideoSubtitles extends StatelessWidget { @override Widget build(BuildContext context) { + final videoDisplaySize = controller.entry.videoDisplaySize(controller.sarNotifier.value); return IgnorePointer( child: Selector( selector: (c, mq) => mq.orientation, builder: (c, orientation, child) { final bottom = orientation == Orientation.portrait ? .5 : .8; - Alignment toVerticalAlignment(SubtitleExtraStyle extraStyle) { + Alignment toVerticalAlignment(SubtitleStyle extraStyle) { switch (extraStyle.vAlign) { case TextAlignVertical.top: return Alignment(0, -bottom); @@ -51,9 +53,19 @@ class VideoSubtitles extends StatelessWidget { } } + final viewportSize = context.read().size; + return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { + final viewPosition = viewState.position; + final viewScale = viewState.scale ?? 1; + final viewSize = videoDisplaySize * viewScale; + final viewOffset = Offset( + (viewportSize.width - viewSize.width) / 2, + (viewportSize.height - viewSize.height) / 2, + ); + return StreamBuilder( stream: controller.timedTextStream, builder: (context, snapshot) { @@ -66,27 +78,81 @@ class VideoSubtitles extends StatelessWidget { 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); + final styledLine = AssParser.parse(text, baseStyle, viewScale); + final position = styledLine.position; + final clip = styledLine.clip; + final styledSpans = styledLine.spans; + final byExtraStyle = groupBy(styledSpans, (v) => v.extraStyle); return Stack( - children: byStyle.entries.map((kv) { + children: byExtraStyle.entries.map((kv) { final extraStyle = kv.key; - final spans = kv.value.map((v) => v.item1).toList(); + final spans = kv.value.map((v) => v.textSpan).toList(); + final drawingPaths = extraStyle.drawingPaths; - Widget child = OutlinedText( - textSpans: spans, - outlineWidth: extraStyle.borderWidth ?? 1, - outlineColor: extraStyle.borderColor ?? Colors.black, - outlineBlurSigma: extraStyle.edgeBlur ?? 0, - textAlign: extraStyle.hAlign ?? TextAlign.center, - ); + final outlineColor = extraStyle.borderColor ?? Colors.black; + var child = drawingPaths != null + ? CustomPaint( + painter: SubtitlePathPainter( + paths: drawingPaths, + scale: viewScale, + fillColor: spans.firstOrNull?.style?.color ?? Colors.white, + strokeColor: outlineColor, + ), + ) + : OutlinedText( + textSpans: spans, + outlineWidth: extraStyle.borderWidth ?? (extraStyle.edgeBlur != null ? 2 : 1), + outlineColor: outlineColor, + outlineBlurSigma: extraStyle.edgeBlur ?? 0, + textAlign: extraStyle.hAlign ?? TextAlign.center, + ); var transform = Matrix4.identity(); + + if (position != null) { + final para = RenderParagraph( + TextSpan(children: spans), + textDirection: TextDirection.ltr, + textScaleFactor: context.read().textScaleFactor, + )..layout(const BoxConstraints()); + final textWidth = para.getMaxIntrinsicWidth(double.infinity); + final textHeight = para.getMaxIntrinsicHeight(double.infinity); + + late double anchorOffsetX, anchorOffsetY; + switch (extraStyle.hAlign) { + case TextAlign.left: + anchorOffsetX = 0; + break; + case TextAlign.right: + anchorOffsetX = -textWidth; + break; + case TextAlign.center: + default: + anchorOffsetX = -textWidth / 2; + break; + } + switch (extraStyle.vAlign) { + case TextAlignVertical.top: + anchorOffsetY = 0; + break; + case TextAlignVertical.center: + anchorOffsetY = -textHeight / 2; + break; + case TextAlignVertical.bottom: + default: + anchorOffsetY = -textHeight; + break; + } + final alignOffset = Offset(anchorOffsetX, anchorOffsetY); + final lineOffset = position * viewScale + viewPosition; + final translateOffset = viewOffset + lineOffset + alignOffset; + transform.translate(translateOffset.dx, translateOffset.dy); + } + if (extraStyle.rotating) { // for perspective transform.setEntry(3, 2, 0.001); @@ -107,6 +173,7 @@ class VideoSubtitles extends StatelessWidget { 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, @@ -115,10 +182,29 @@ class VideoSubtitles extends StatelessWidget { ); } - return Align( - alignment: toVerticalAlignment(extraStyle), - child: child, - ); + if (position == null) { + child = Align( + alignment: toVerticalAlignment(extraStyle), + child: child, + ); + } + + if (clip != null) { + final clipOffset = viewOffset + viewPosition; + final matrix = Matrix4.identity() + ..translate(clipOffset.dx, clipOffset.dy) + ..scale(viewScale, viewScale); + final transform = matrix.storage; + child = ClipPath( + clipper: SubtitlePathClipper( + paths: clip.map((v) => v.transform(transform)).toList(), + scale: viewScale, + ), + child: child, + ); + } + + return child; }).toList(), ); }, @@ -130,3 +216,51 @@ class VideoSubtitles extends StatelessWidget { ); } } + +class SubtitlePathPainter extends CustomPainter { + final List paths; + final double scale; + final Color fillColor, strokeColor; + + const SubtitlePathPainter({ + required this.paths, + required this.scale, + required this.fillColor, + required this.strokeColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final fillPaint = Paint() + ..style = PaintingStyle.fill + ..color = fillColor; + final strokePaint = Paint() + ..style = PaintingStyle.stroke + ..color = strokeColor; + + canvas.scale(scale, scale); + paths.forEach((path) { + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, strokePaint); + }); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class SubtitlePathClipper extends CustomClipper { + final List paths; + final double scale; + + const SubtitlePathClipper({ + required this.paths, + required this.scale, + }); + + @override + Path getClip(Size size) => paths.firstOrNull ?? Path(); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => true; +}