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 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( 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( selector: (context, mq) => mq.orientation, builder: (context, orientation, child) { final bottom = orientation == Orientation.portrait ? .5 : .8; 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) { 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(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().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 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 { 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; }