aves/lib/widgets/viewer/visual/subtitle/subtitle.dart
2022-11-23 11:25:10 +01:00

343 lines
15 KiB
Dart

import 'package:aves/model/settings/enums/subtitle_position.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/basic/text_background_painter.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:latlong2/latlong.dart' as angles;
import 'package:provider/provider.dart';
class VideoSubtitles extends StatelessWidget {
final AvesVideoController controller;
final ValueNotifier<ViewState> viewStateNotifier;
final bool debugMode;
static const baseShadowOffset = Offset(1, 1);
const VideoSubtitles({
super.key,
required this.controller,
required this.viewStateNotifier,
this.debugMode = false,
});
@override
Widget build(BuildContext context) {
final videoDisplaySize = controller.entry.videoDisplaySize(controller.sarNotifier.value);
return IgnorePointer(
child: Consumer<Settings>(
builder: (context, settings, child) {
final baseTextAlign = settings.subtitleTextAlignment;
final baseTextAlignY = settings.subtitleTextPosition.toTextAlignVertical();
final baseOutlineWidth = settings.subtitleShowOutline ? 1 : 0;
final baseOutlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
final baseShadows = [
Shadow(
color: baseOutlineColor,
offset: baseShadowOffset,
),
];
final baseStyle = TextStyle(
color: settings.subtitleTextColor,
fontSize: settings.subtitleFontSize,
shadows: settings.subtitleShowOutline ? baseShadows : null,
);
return Selector<MediaQueryData, Orientation>(
selector: (context, mq) => mq.orientation,
builder: (context, orientation, child) {
final bottom = orientation == Orientation.portrait ? .5 : .8;
final viewportSize = context.read<MediaQueryData>().size;
return ValueListenableBuilder<ViewState>(
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<String?>(
stream: controller.timedTextStream,
builder: (context, snapshot) {
final text = snapshot.data;
if (text == null) return const SizedBox();
if (debugMode) {
return Padding(
padding: const EdgeInsets.only(top: 100.0),
child: Align(
alignment: Alignment.topLeft,
child: OutlinedText(
textSpans: [
TextSpan(
text: text,
style: const TextStyle(fontSize: 14),
)
],
outlineWidth: 1,
outlineColor: Colors.black,
),
),
);
}
final styledLine = AssParser.parse(text, baseStyle, viewScale);
final position = styledLine.position;
final clip = styledLine.clip;
final styledSpans = styledLine.spans;
final byExtraStyle = groupBy<StyledSubtitleSpan, SubtitleStyle>(styledSpans, (v) => v.extraStyle);
return Stack(
children: byExtraStyle.entries.map((kv) {
final extraStyle = kv.key;
final spans = kv.value.map((v) {
final span = v.textSpan;
final style = span.style;
if (position == null || style == null) return span;
final letterSpacing = style.letterSpacing;
final shadows = style.shadows;
return TextSpan(
text: span.text,
style: style.copyWith(
letterSpacing: letterSpacing != null ? letterSpacing * viewScale : null,
shadows: shadows
?.map((v) => Shadow(
color: v.color,
offset: v.offset * viewScale,
blurRadius: v.blurRadius * viewScale,
))
.toList(),
),
);
}).toList();
final drawingPaths = extraStyle.drawingPaths;
final textHAlign = extraStyle.hAlign ?? (position != null ? TextAlign.center : baseTextAlign);
final textVAlign = extraStyle.vAlign ?? (position != null ? TextAlignVertical.bottom : baseTextAlignY);
Widget child;
if (drawingPaths != null) {
child = CustomPaint(
painter: SubtitlePathPainter(
paths: drawingPaths,
scale: viewScale,
fillColor: spans.firstOrNull?.style?.color ?? Colors.white,
strokeColor: extraStyle.borderColor,
),
);
} else {
final outlineWidth = extraStyle.borderWidth ?? (extraStyle.edgeBlur != null ? 2 : 1);
child = OutlinedText(
textSpans: spans,
outlineWidth: outlineWidth * (position != null ? viewScale : baseOutlineWidth),
outlineColor: extraStyle.borderColor ?? baseOutlineColor,
outlineBlurSigma: extraStyle.edgeBlur ?? 0,
textAlign: textHAlign,
);
}
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 (textHAlign) {
case TextAlign.left:
anchorOffsetX = 0;
break;
case TextAlign.right:
anchorOffsetX = -textWidth;
break;
case TextAlign.center:
default:
anchorOffsetX = -textWidth / 2;
break;
}
switch (textVAlign) {
case TextAlignVertical.top:
anchorOffsetY = 0;
break;
case TextAlignVertical.center:
anchorOffsetY = -textHeight / 2;
break;
case TextAlignVertical.bottom:
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);
final x = -angles.degToRadian(extraStyle.rotationX ?? 0);
final y = -angles.degToRadian(extraStyle.rotationY ?? 0);
final z = -angles.degToRadian(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,
);
}
if (position == null) {
late double alignX;
switch (textHAlign) {
case TextAlign.left:
alignX = -1;
break;
case TextAlign.right:
alignX = 1;
break;
case TextAlign.center:
default:
alignX = 0;
break;
}
late double alignY;
switch (textVAlign) {
case TextAlignVertical.top:
alignY = -bottom;
break;
case TextAlignVertical.center:
alignY = 0;
break;
case TextAlignVertical.bottom:
default:
alignY = bottom;
break;
}
child = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: Alignment(alignX, alignY),
child: TextBackgroundPainter(
spans: spans,
style: DefaultTextStyle.of(context).style.merge(spans.first.style!.copyWith(
backgroundColor: settings.subtitleBackgroundColor,
)),
textAlign: textHAlign,
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(),
);
},
);
},
);
},
);
},
),
);
}
}
class SubtitlePathPainter extends CustomPainter {
final List<Path> paths;
final double scale;
final Paint? fillPaint, strokePaint;
SubtitlePathPainter({
required this.paths,
required this.scale,
required Color? fillColor,
required Color? strokeColor,
}) : fillPaint = fillColor != null
? (Paint()
..style = PaintingStyle.fill
..color = fillColor)
: null,
strokePaint = strokeColor != null
? (Paint()
..style = PaintingStyle.stroke
..color = strokeColor)
: null;
@override
void paint(Canvas canvas, Size size) {
canvas.scale(scale, scale);
paths.forEach((path) {
if (fillPaint != null) {
canvas.drawPath(path, fillPaint!);
}
if (strokePaint != null) {
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;
}