video: ass subtitle format (WIP)
This commit is contained in:
parent
38422d85aa
commit
f328a7ae7a
9 changed files with 583 additions and 122 deletions
|
@ -8,6 +8,7 @@ enum VideoAction {
|
||||||
selectStreams,
|
selectStreams,
|
||||||
setSpeed,
|
setSpeed,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
|
// TODO TLAD [video] toggle mute
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoActions {
|
class VideoActions {
|
||||||
|
|
|
@ -1,57 +1,66 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class OutlinedText extends StatelessWidget {
|
class OutlinedText extends StatelessWidget {
|
||||||
final String text;
|
final List<TextSpan> textSpans;
|
||||||
final TextStyle style;
|
|
||||||
final double outlineWidth;
|
final double outlineWidth;
|
||||||
final Color outlineColor;
|
final Color outlineColor;
|
||||||
|
final double outlineBlurSigma;
|
||||||
final TextAlign? textAlign;
|
final TextAlign? textAlign;
|
||||||
|
|
||||||
static const widgetSpanAlignment = PlaceholderAlignment.middle;
|
static const widgetSpanAlignment = PlaceholderAlignment.middle;
|
||||||
|
|
||||||
const OutlinedText({
|
const OutlinedText({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.text,
|
required this.textSpans,
|
||||||
required this.style,
|
|
||||||
double? outlineWidth,
|
double? outlineWidth,
|
||||||
Color? outlineColor,
|
Color? outlineColor,
|
||||||
|
double? outlineBlurSigma,
|
||||||
this.textAlign,
|
this.textAlign,
|
||||||
}) : outlineWidth = outlineWidth ?? 1,
|
}) : outlineWidth = outlineWidth ?? 1,
|
||||||
outlineColor = outlineColor ?? Colors.black,
|
outlineColor = outlineColor ?? Colors.black,
|
||||||
|
outlineBlurSigma = outlineBlurSigma ?? 0,
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Text.rich(
|
ImageFiltered(
|
||||||
TextSpan(
|
imageFilter: outlineBlurSigma > 0
|
||||||
children: [
|
? ImageFilter.blur(
|
||||||
TextSpan(
|
sigmaX: outlineBlurSigma,
|
||||||
text: text,
|
sigmaY: outlineBlurSigma,
|
||||||
style: style.copyWith(
|
)
|
||||||
foreground: Paint()
|
: ImageFilter.matrix(
|
||||||
..style = PaintingStyle.stroke
|
Matrix4.identity().storage,
|
||||||
..strokeWidth = outlineWidth
|
|
||||||
..color = outlineColor,
|
|
||||||
),
|
),
|
||||||
),
|
child: Text.rich(
|
||||||
],
|
TextSpan(
|
||||||
|
children: textSpans.map(_toStrokeSpan).toList(),
|
||||||
|
),
|
||||||
|
textAlign: textAlign,
|
||||||
),
|
),
|
||||||
textAlign: textAlign,
|
|
||||||
),
|
),
|
||||||
Text.rich(
|
Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: [
|
children: textSpans,
|
||||||
TextSpan(
|
|
||||||
text: text,
|
|
||||||
style: style,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
textAlign: textAlign,
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,11 +118,15 @@ class ScaleBar extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
OutlinedText(
|
OutlinedText(
|
||||||
text: distance,
|
textSpans: [
|
||||||
style: const TextStyle(
|
TextSpan(
|
||||||
color: fillColor,
|
text: distance,
|
||||||
fontSize: 11,
|
style: const TextStyle(
|
||||||
),
|
color: fillColor,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
outlineWidth: outlineWidth * 2,
|
outlineWidth: outlineWidth * 2,
|
||||||
outlineColor: outlineColor,
|
outlineColor: outlineColor,
|
||||||
),
|
),
|
||||||
|
|
|
@ -92,6 +92,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
// FFmpeg options
|
// FFmpeg options
|
||||||
// cf https://github.com/Bilibili/ijkplayer/blob/master/ijkmedia/ijkplayer/ff_ffplay_options.h
|
// 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/843c86a9e9ad
|
||||||
|
// cf https://www.jianshu.com/p/3649c073b346
|
||||||
|
|
||||||
final options = FijkOption();
|
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
|
// 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;
|
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) {
|
if (hwAccelerationEnabled) {
|
||||||
// when HW acceleration is enabled, videos with dimensions that do not fit 16x macroblocks need cropping
|
// 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;
|
final s = entry.displaySize % 16 * -1 % 16;
|
||||||
_macroBlockCrop = Offset(s.width, s.height);
|
_macroBlockCrop = Offset(s.width, s.height);
|
||||||
}
|
}
|
||||||
|
@ -290,7 +291,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
_applySpeed();
|
_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);
|
void _applySpeed() => _instance.setSpeed(speed);
|
||||||
|
|
||||||
ValueNotifier<StreamSummary?> selectedStreamNotifier(StreamType type) {
|
ValueNotifier<StreamSummary?> selectedStreamNotifier(StreamType type) {
|
||||||
|
@ -339,6 +340,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
return Map.fromEntries(_streams.map((stream) => MapEntry(stream, selectedIndices.contains(stream.index))));
|
return Map.fromEntries(_streams.map((stream) => MapEntry(stream, selectedIndices.contains(stream.index))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO TLAD [video] bug: crash when video stream is not supported
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> captureFrame() => _instance.takeSnapShot();
|
Future<Uint8List> captureFrame() => _instance.takeSnapShot();
|
||||||
|
|
||||||
|
|
|
@ -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/error.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/raster.dart';
|
import 'package:aves/widgets/viewer/visual/raster.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/state.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/vector.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/video.dart';
|
import 'package:aves/widgets/viewer/visual/video.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -208,10 +208,12 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
),
|
),
|
||||||
VideoSubtitles(
|
VideoSubtitles(
|
||||||
controller: videoController,
|
controller: videoController,
|
||||||
|
viewStateNotifier: _viewStateNotifier,
|
||||||
),
|
),
|
||||||
if (settings.videoShowRawTimedText)
|
if (settings.videoShowRawTimedText)
|
||||||
VideoSubtitles(
|
VideoSubtitles(
|
||||||
controller: videoController,
|
controller: videoController,
|
||||||
|
viewStateNotifier: _viewStateNotifier,
|
||||||
debugMode: true,
|
debugMode: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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<MediaQueryData, Orientation>(
|
|
||||||
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<String?>(
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
295
lib/widgets/viewer/visual/subtitle/ass_parser.dart
Normal file
295
lib/widgets/viewer/visual/subtitle/ass_parser.dart
Normal file
|
@ -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<aa>
|
||||||
|
static final alphaPattern = RegExp('&H(..)');
|
||||||
|
|
||||||
|
// &H<bb><gg><rr>&
|
||||||
|
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<Tuple2<TextSpan, SubtitleExtraStyle>> parseAss(String text, TextStyle baseStyle, double scale) {
|
||||||
|
final spans = <Tuple2<TextSpan, SubtitleExtraStyle>>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
106
lib/widgets/viewer/visual/subtitle/style.dart
Normal file
106
lib/widgets/viewer/visual/subtitle/style.dart
Normal file
|
@ -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<TextAlign>('hAlign', hAlign));
|
||||||
|
properties.add(DiagnosticsProperty<TextAlignVertical>('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<TransitionBuilder>('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,
|
||||||
|
);
|
||||||
|
}
|
132
lib/widgets/viewer/visual/subtitle/subtitle.dart
Normal file
132
lib/widgets/viewer/visual/subtitle/subtitle.dart
Normal file
|
@ -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<ViewState> 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<MediaQueryData, Orientation>(
|
||||||
|
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<ViewState>(
|
||||||
|
valueListenable: viewStateNotifier,
|
||||||
|
builder: (context, viewState, child) {
|
||||||
|
return StreamBuilder<String?>(
|
||||||
|
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<Tuple2<TextSpan, SubtitleExtraStyle>, 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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue