video: ass subtitle format (WIP)
This commit is contained in:
parent
123f1c6a79
commit
2ca67f403d
12 changed files with 594 additions and 152 deletions
|
@ -295,42 +295,6 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> 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
|
///This cut 2 lines in arrow shape
|
||||||
class ArrowClipper extends CustomClipper<Path> {
|
class ArrowClipper extends CustomClipper<Path> {
|
||||||
@override
|
@override
|
||||||
|
@ -366,7 +330,7 @@ class ArrowClipper extends CustomClipper<Path> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SlideFadeTransition extends StatelessWidget {
|
class SlideFadeTransition extends StatelessWidget {
|
||||||
|
|
|
@ -36,5 +36,5 @@ class CheckeredPainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -153,5 +153,5 @@ class _SweepClipPath extends CustomClipper<Path> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReclip(CustomClipper<Path> oldClipper) => true;
|
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -188,5 +188,5 @@ class _TransitionImagePainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -289,5 +289,5 @@ class GridPainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,7 +120,7 @@ class MarkerPointerPainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate bitmap from widget, for Google Maps
|
// generate bitmap from widget, for Google Maps
|
||||||
|
|
|
@ -117,5 +117,5 @@ class MinimapPainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:aves/widgets/viewer/visual/subtitle/style.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
|
||||||
|
|
||||||
class AssParser {
|
class AssParser {
|
||||||
static final tagPattern = RegExp(r'('
|
static final tagPattern = RegExp(r'('
|
||||||
|
@ -27,26 +28,34 @@ class AssParser {
|
||||||
// &H<bb><gg><rr>&
|
// &H<bb><gg><rr>&
|
||||||
static final colorPattern = RegExp('&H(..)(..)(..)&');
|
static final colorPattern = RegExp('&H(..)(..)(..)&');
|
||||||
|
|
||||||
|
// (<X>,<Y>)
|
||||||
|
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';
|
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. `And I'm like, "We can't {\i1}not{\i0} see it."`
|
||||||
// e.g. `{\fad(200,200)\blur3}lorem ipsum"`
|
// 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"`
|
// e.g. `{\fnCrapFLTSB\an9\bord5\fs70\c&H403A2D&\3c&HE5E5E8&\pos(1868.286,27.429)}lorem ipsum"`
|
||||||
static List<Tuple2<TextSpan, SubtitleExtraStyle>> parseAss(String text, TextStyle baseStyle, double scale) {
|
static StyledSubtitleLine parse(String text, TextStyle baseStyle, double scale) {
|
||||||
final spans = <Tuple2<TextSpan, SubtitleExtraStyle>>[];
|
final spans = <StyledSubtitleSpan>[];
|
||||||
var extraStyle = const SubtitleExtraStyle();
|
var line = StyledSubtitleLine(spans: spans);
|
||||||
|
var extraStyle = const SubtitleStyle();
|
||||||
var textStyle = baseStyle;
|
var textStyle = baseStyle;
|
||||||
var i = 0;
|
var i = 0;
|
||||||
final matches = RegExp(r'{(.*?)}').allMatches(text);
|
final matches = RegExp(r'{(.*?)}').allMatches(text);
|
||||||
matches.forEach((m) {
|
matches.forEach((m) {
|
||||||
if (i != m.start) {
|
if (i != m.start) {
|
||||||
spans.add(Tuple2(
|
spans.add(StyledSubtitleSpan(
|
||||||
TextSpan(
|
textSpan: TextSpan(
|
||||||
text: _replaceChars(text.substring(i, m.start)),
|
text: extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i, m.start)),
|
||||||
style: textStyle,
|
style: textStyle,
|
||||||
),
|
),
|
||||||
extraStyle,
|
extraStyle: extraStyle,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
i = m.end;
|
i = m.end;
|
||||||
|
@ -56,12 +65,6 @@ class AssParser {
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
final param = tagWithParam.substring(tag.length);
|
final param = tagWithParam.substring(tag.length);
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case 'r':
|
|
||||||
{
|
|
||||||
// \r: reset
|
|
||||||
textStyle = baseStyle;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'alpha':
|
case 'alpha':
|
||||||
{
|
{
|
||||||
// \alpha: alpha of all components at once
|
// \alpha: alpha of all components at once
|
||||||
|
@ -79,41 +82,33 @@ class AssParser {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'a':
|
||||||
|
// \a: line alignment (legacy)
|
||||||
|
extraStyle = _copyWithAlignment(_parseLegacyAlignment(param), extraStyle);
|
||||||
|
break;
|
||||||
case 'an':
|
case 'an':
|
||||||
{
|
// \an: line alignment
|
||||||
// \an: alignment
|
extraStyle = _copyWithAlignment(_parseNewAlignment(param), extraStyle);
|
||||||
var hAlign = TextAlign.center;
|
break;
|
||||||
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':
|
case 'b':
|
||||||
{
|
{
|
||||||
// \b: bold
|
// \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;
|
break;
|
||||||
}
|
}
|
||||||
case 'blur':
|
case 'blur':
|
||||||
{
|
{
|
||||||
// \blur: blurs the edges of the text
|
// \blur: blurs the edges of the text (Gaussian kernel)
|
||||||
final strength = double.tryParse(param);
|
final strength = double.tryParse(param);
|
||||||
if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength);
|
if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength / 2);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'bord':
|
case 'bord':
|
||||||
|
@ -160,6 +155,10 @@ class AssParser {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'clip':
|
||||||
|
// \clip: clip (within rectangle or path)
|
||||||
|
line = line.copyWith(clip: _parseClip(param));
|
||||||
|
break;
|
||||||
case 'fax':
|
case 'fax':
|
||||||
{
|
{
|
||||||
final factor = double.tryParse(param);
|
final factor = double.tryParse(param);
|
||||||
|
@ -175,24 +174,20 @@ class AssParser {
|
||||||
case 'fn':
|
case 'fn':
|
||||||
{
|
{
|
||||||
final name = param;
|
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);
|
if (name.isNotEmpty) textStyle = textStyle.copyWith(fontFamily: name);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'fs':
|
|
||||||
{
|
|
||||||
final size = int.tryParse(param);
|
|
||||||
if (size != null) textStyle = textStyle.copyWith(fontSize: size * scale);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'frx':
|
case 'frx':
|
||||||
{
|
{
|
||||||
|
// \frx: text rotation (X axis)
|
||||||
final amount = double.tryParse(param);
|
final amount = double.tryParse(param);
|
||||||
if (amount != null) extraStyle = extraStyle.copyWith(rotationX: amount);
|
if (amount != null) extraStyle = extraStyle.copyWith(rotationX: amount);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'fry':
|
case 'fry':
|
||||||
{
|
{
|
||||||
|
// \fry: text rotation (Y axis)
|
||||||
final amount = double.tryParse(param);
|
final amount = double.tryParse(param);
|
||||||
if (amount != null) extraStyle = extraStyle.copyWith(rotationY: amount);
|
if (amount != null) extraStyle = extraStyle.copyWith(rotationY: amount);
|
||||||
break;
|
break;
|
||||||
|
@ -200,28 +195,111 @@ class AssParser {
|
||||||
case 'fr':
|
case 'fr':
|
||||||
case 'frz':
|
case 'frz':
|
||||||
{
|
{
|
||||||
|
// \frz: text rotation (Z axis)
|
||||||
final amount = double.tryParse(param);
|
final amount = double.tryParse(param);
|
||||||
if (amount != null) extraStyle = extraStyle.copyWith(rotationZ: amount);
|
if (amount != null) extraStyle = extraStyle.copyWith(rotationZ: amount);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'fs':
|
||||||
|
{
|
||||||
|
// \fs: font size
|
||||||
|
final size = int.tryParse(param);
|
||||||
|
if (size != null) textStyle = textStyle.copyWith(fontSize: size * scale);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'fscx':
|
case 'fscx':
|
||||||
{
|
{
|
||||||
|
// \fscx: font scale (horizontal)
|
||||||
final scale = int.tryParse(param);
|
final scale = int.tryParse(param);
|
||||||
if (scale != null) extraStyle = extraStyle.copyWith(scaleX: scale.toDouble() / 100);
|
if (scale != null) extraStyle = extraStyle.copyWith(scaleX: scale.toDouble() / 100);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'fscy':
|
case 'fscy':
|
||||||
{
|
{
|
||||||
|
// \fscx: font scale (vertical)
|
||||||
final scale = int.tryParse(param);
|
final scale = int.tryParse(param);
|
||||||
if (scale != null) extraStyle = extraStyle.copyWith(scaleY: scale.toDouble() / 100);
|
if (scale != null) extraStyle = extraStyle.copyWith(scaleY: scale.toDouble() / 100);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'i':
|
case 'i':
|
||||||
|
// \i: italics
|
||||||
|
textStyle = textStyle.copyWith(fontStyle: param == '1' ? FontStyle.italic : FontStyle.normal);
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
{
|
{
|
||||||
// \i: italics
|
// \p drawing paths
|
||||||
textStyle = textStyle.copyWith(fontStyle: param == '1' ? FontStyle.italic : FontStyle.normal);
|
final scale = int.tryParse(param);
|
||||||
|
if (scale != null) {
|
||||||
|
extraStyle = extraStyle.copyWith(
|
||||||
|
drawingPaths: scale > 0
|
||||||
|
? _parsePaths(
|
||||||
|
text.substring(m.end),
|
||||||
|
scale,
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
debugPrint('unhandled ASS tag=$tag');
|
debugPrint('unhandled ASS tag=$tag');
|
||||||
}
|
}
|
||||||
|
@ -229,44 +307,40 @@ class AssParser {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (i != text.length) {
|
if (i != text.length) {
|
||||||
spans.add(Tuple2(
|
spans.add(StyledSubtitleSpan(
|
||||||
TextSpan(
|
textSpan: TextSpan(
|
||||||
text: _replaceChars(text.substring(i, text.length)),
|
text: extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i)),
|
||||||
style: textStyle,
|
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 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) {
|
static int? _parseAlpha(String param) {
|
||||||
final match = alphaPattern.firstMatch(param);
|
final match = alphaPattern.firstMatch(param);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
@ -292,4 +366,182 @@ class AssParser {
|
||||||
}
|
}
|
||||||
return null;
|
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<Path> _parsePaths(String commands, int scale) {
|
||||||
|
final paths = <Path>[];
|
||||||
|
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<double>().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<Path>? _parseClip(String param) {
|
||||||
|
List<Path>? 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<double>().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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
49
lib/widgets/viewer/visual/subtitle/line.dart
Normal file
49
lib/widgets/viewer/visual/subtitle/line.dart
Normal file
|
@ -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<StyledSubtitleSpan> spans;
|
||||||
|
final List<Path>? clip;
|
||||||
|
final Offset? position;
|
||||||
|
|
||||||
|
const StyledSubtitleLine({
|
||||||
|
required this.spans,
|
||||||
|
this.clip,
|
||||||
|
this.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
StyledSubtitleLine copyWith({
|
||||||
|
List<StyledSubtitleSpan>? spans,
|
||||||
|
List<Path>? 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<List<StyledSubtitleSpan>>('spans', spans));
|
||||||
|
properties.add(DiagnosticsProperty<List<Path>>('clip', clip));
|
||||||
|
properties.add(DiagnosticsProperty<Offset>('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,
|
||||||
|
);
|
||||||
|
}
|
43
lib/widgets/viewer/visual/subtitle/span.dart
Normal file
43
lib/widgets/viewer/visual/subtitle/span.dart
Normal file
|
@ -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', textSpan));
|
||||||
|
properties.add(DiagnosticsProperty<SubtitleStyle>('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,
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class SubtitleExtraStyle with Diagnosticable {
|
class SubtitleStyle with Diagnosticable {
|
||||||
final TextAlign? hAlign;
|
final TextAlign? hAlign;
|
||||||
final TextAlignVertical? vAlign;
|
final TextAlignVertical? vAlign;
|
||||||
final Color? borderColor;
|
final Color? borderColor;
|
||||||
final double? borderWidth, edgeBlur, rotationX, rotationY, rotationZ, scaleX, scaleY, shearX, shearY;
|
final double? borderWidth, edgeBlur, rotationX, rotationY, rotationZ, scaleX, scaleY, shearX, shearY;
|
||||||
final TransitionBuilder? builder;
|
final List<Path>? drawingPaths;
|
||||||
|
|
||||||
bool get rotating => (rotationX ?? 0) > 0 || (rotationY ?? 0) > 0 || (rotationZ ?? 0) > 0;
|
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;
|
bool get shearing => (shearX ?? 0) > 0 || (shearY ?? 0) > 0;
|
||||||
|
|
||||||
const SubtitleExtraStyle({
|
const SubtitleStyle({
|
||||||
this.hAlign,
|
this.hAlign,
|
||||||
this.vAlign,
|
this.vAlign,
|
||||||
this.borderColor,
|
this.borderColor,
|
||||||
|
@ -28,10 +28,10 @@ class SubtitleExtraStyle with Diagnosticable {
|
||||||
this.scaleY,
|
this.scaleY,
|
||||||
this.shearX,
|
this.shearX,
|
||||||
this.shearY,
|
this.shearY,
|
||||||
this.builder,
|
this.drawingPaths,
|
||||||
});
|
});
|
||||||
|
|
||||||
SubtitleExtraStyle copyWith({
|
SubtitleStyle copyWith({
|
||||||
TextAlign? hAlign,
|
TextAlign? hAlign,
|
||||||
TextAlignVertical? vAlign,
|
TextAlignVertical? vAlign,
|
||||||
Color? borderColor,
|
Color? borderColor,
|
||||||
|
@ -44,9 +44,9 @@ class SubtitleExtraStyle with Diagnosticable {
|
||||||
double? scaleY,
|
double? scaleY,
|
||||||
double? shearX,
|
double? shearX,
|
||||||
double? shearY,
|
double? shearY,
|
||||||
TransitionBuilder? builder,
|
List<Path>? drawingPaths,
|
||||||
}) {
|
}) {
|
||||||
return SubtitleExtraStyle(
|
return SubtitleStyle(
|
||||||
hAlign: hAlign ?? this.hAlign,
|
hAlign: hAlign ?? this.hAlign,
|
||||||
vAlign: vAlign ?? this.vAlign,
|
vAlign: vAlign ?? this.vAlign,
|
||||||
borderColor: borderColor ?? this.borderColor,
|
borderColor: borderColor ?? this.borderColor,
|
||||||
|
@ -59,7 +59,7 @@ class SubtitleExtraStyle with Diagnosticable {
|
||||||
scaleY: scaleY ?? this.scaleY,
|
scaleY: scaleY ?? this.scaleY,
|
||||||
shearX: shearX ?? this.shearX,
|
shearX: shearX ?? this.shearX,
|
||||||
shearY: shearY ?? this.shearY,
|
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('scaleY', scaleY));
|
||||||
properties.add(DoubleProperty('shearX', shearX));
|
properties.add(DoubleProperty('shearX', shearX));
|
||||||
properties.add(DoubleProperty('shearY', shearY));
|
properties.add(DoubleProperty('shearY', shearY));
|
||||||
properties.add(DiagnosticsProperty<TransitionBuilder>('builder', builder));
|
properties.add(DiagnosticsProperty<List<Path>>('drawingPaths', drawingPaths));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
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
|
@override
|
||||||
|
@ -101,6 +101,6 @@ class SubtitleExtraStyle with Diagnosticable {
|
||||||
scaleY,
|
scaleY,
|
||||||
shearX,
|
shearX,
|
||||||
shearY,
|
shearY,
|
||||||
builder,
|
drawingPaths?.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/video/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/state.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/ass_parser.dart';
|
||||||
|
import 'package:aves/widgets/viewer/visual/subtitle/span.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
|
import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
|
||||||
|
|
||||||
class VideoSubtitles extends StatelessWidget {
|
class VideoSubtitles extends StatelessWidget {
|
||||||
final AvesVideoController controller;
|
final AvesVideoController controller;
|
||||||
|
@ -34,12 +35,13 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final videoDisplaySize = controller.entry.videoDisplaySize(controller.sarNotifier.value);
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: Selector<MediaQueryData, Orientation>(
|
child: Selector<MediaQueryData, Orientation>(
|
||||||
selector: (c, mq) => mq.orientation,
|
selector: (c, mq) => mq.orientation,
|
||||||
builder: (c, orientation, child) {
|
builder: (c, orientation, child) {
|
||||||
final bottom = orientation == Orientation.portrait ? .5 : .8;
|
final bottom = orientation == Orientation.portrait ? .5 : .8;
|
||||||
Alignment toVerticalAlignment(SubtitleExtraStyle extraStyle) {
|
Alignment toVerticalAlignment(SubtitleStyle extraStyle) {
|
||||||
switch (extraStyle.vAlign) {
|
switch (extraStyle.vAlign) {
|
||||||
case TextAlignVertical.top:
|
case TextAlignVertical.top:
|
||||||
return Alignment(0, -bottom);
|
return Alignment(0, -bottom);
|
||||||
|
@ -51,9 +53,19 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final viewportSize = context.read<MediaQueryData>().size;
|
||||||
|
|
||||||
return ValueListenableBuilder<ViewState>(
|
return ValueListenableBuilder<ViewState>(
|
||||||
valueListenable: viewStateNotifier,
|
valueListenable: viewStateNotifier,
|
||||||
builder: (context, viewState, child) {
|
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<String?>(
|
return StreamBuilder<String?>(
|
||||||
stream: controller.timedTextStream,
|
stream: controller.timedTextStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
@ -66,27 +78,81 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
textSpans: [TextSpan(text: text)],
|
textSpans: [TextSpan(text: text)],
|
||||||
outlineWidth: 1,
|
outlineWidth: 1,
|
||||||
outlineColor: Colors.black,
|
outlineColor: Colors.black,
|
||||||
// textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final styledSpans = AssParser.parseAss(text, baseStyle, viewState.scale ?? 1);
|
final styledLine = AssParser.parse(text, baseStyle, viewScale);
|
||||||
final byStyle = groupBy<Tuple2<TextSpan, SubtitleExtraStyle>, SubtitleExtraStyle>(styledSpans, (v) => v.item2);
|
final position = styledLine.position;
|
||||||
|
final clip = styledLine.clip;
|
||||||
|
final styledSpans = styledLine.spans;
|
||||||
|
final byExtraStyle = groupBy<StyledSubtitleSpan, SubtitleStyle>(styledSpans, (v) => v.extraStyle);
|
||||||
return Stack(
|
return Stack(
|
||||||
children: byStyle.entries.map((kv) {
|
children: byExtraStyle.entries.map((kv) {
|
||||||
final extraStyle = kv.key;
|
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(
|
final outlineColor = extraStyle.borderColor ?? Colors.black;
|
||||||
textSpans: spans,
|
var child = drawingPaths != null
|
||||||
outlineWidth: extraStyle.borderWidth ?? 1,
|
? CustomPaint(
|
||||||
outlineColor: extraStyle.borderColor ?? Colors.black,
|
painter: SubtitlePathPainter(
|
||||||
outlineBlurSigma: extraStyle.edgeBlur ?? 0,
|
paths: drawingPaths,
|
||||||
textAlign: extraStyle.hAlign ?? TextAlign.center,
|
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();
|
var transform = Matrix4.identity();
|
||||||
|
|
||||||
|
if (position != null) {
|
||||||
|
final para = RenderParagraph(
|
||||||
|
TextSpan(children: spans),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
textScaleFactor: context.read<MediaQueryData>().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) {
|
if (extraStyle.rotating) {
|
||||||
// for perspective
|
// for perspective
|
||||||
transform.setEntry(3, 2, 0.001);
|
transform.setEntry(3, 2, 0.001);
|
||||||
|
@ -107,6 +173,7 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
final y = extraStyle.shearY ?? 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));
|
transform.multiply(Matrix4(1, y, 0, 0, x, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transform.isIdentity()) {
|
if (!transform.isIdentity()) {
|
||||||
child = Transform(
|
child = Transform(
|
||||||
transform: transform,
|
transform: transform,
|
||||||
|
@ -115,10 +182,29 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Align(
|
if (position == null) {
|
||||||
alignment: toVerticalAlignment(extraStyle),
|
child = Align(
|
||||||
child: child,
|
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(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -130,3 +216,51 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SubtitlePathPainter extends CustomPainter {
|
||||||
|
final List<Path> 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<Path> {
|
||||||
|
final List<Path> 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<Path> oldClipper) => true;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue