Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active February 6, 2024 15:59
Show Gist options
  • Save pskink/d4133f951b84e77864eabba88338cdea to your computer and use it in GitHub Desktop.
Save pskink/d4133f951b84e77864eabba88338cdea to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:async/async.dart';
main() => runApp(MaterialApp(home: Scaffold(body: Prompter())));
// format: (text's end timestamp in ms, text)
const happy = [
(1860, "Let me tell you now"),
(2080, "Here we go"),
(7870, "It might seem crazy what I'm about to say"),
(13940, "Sunshine she's here, you can take a break"),
(18900, "I'm a hot air balloon that can go to space"),
(24540, "With the air, like I don't care baby by the way (come on)"),
(26110, "Because I'm happy"),
(30630, "Clap along if you feel like a room without a roof"),
(31930, "Because I'm happy"),
(36640, "Clap along if you feel like happiness is the truth"),
(37950, "Because I'm happy"),
(42720, "Clap along if you know what happiness is to you"),
(44060, "Because I'm happy"),
(49890, "Clap along if you feel like that's what you wanna do (hey)"),
(55960, "Here come bad news talking this and that (yeah)"),
(61500, "Well, give me all you got, and don't hold it back (yeah)"),
(67370, "Well, I should probably warn you, you'll be just fine (yeah)"),
(71560, "No offense to you, don't waste your time"),
];
const testhappy = [
(3000, "short"),
(6000, "Ipsum officia labore magna velit occaecat eu cillum quis consequat proident incididunt magna elit. Officia sunt enim esse adipisicing eu qui sint Lorem."),
(9000, "short"),
(12000, "Eiusmod id deserunt nisi nisi dolor culpa ex sit eu tempor enim."),
(12001, "short"),
];
class Prompter extends StatefulWidget {
@override
State<Prompter> createState() => _PrompterState();
}
class _PrompterState extends State<Prompter> with TickerProviderStateMixin {
late final animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
late final wordAnimationController = AnimationController(vsync: this);
late RenderEditable renderEditable;
final scrollController = ScrollController();
final script = Script(happy);
bool playing = false;
CancelableOperation snooze = CancelableOperation.fromValue(null);
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
context.findRenderObject()?.visitChildren(_visitor);
});
}
@override
Widget build(BuildContext context) {
final controller = TextEditingController(text: script.lines.map((l) => l.text).join('\n'));
return Column(
children: [
ElevatedButton(
onPressed: () {
if (!playing) {
playing = true;
_loop(renderEditable.foregroundPainter as _PrompterPainter, renderEditable);
setState(() {});
} else {
snooze.cancel();
}
},
child: Text(!playing? 'sing "Happy" with Pharrell Williams' : 'stop singing...'),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(4),
child: ClipRect(
child: Padding(
padding: const EdgeInsets.all(2),
child: EditableText(
controller: controller,
scrollController: scrollController,
focusNode: FocusNode(),
cursorColor: Colors.transparent,
backgroundCursorColor: Colors.transparent,
readOnly: true,
style: Theme.of(context).textTheme.headlineSmall!,
minLines: null,
maxLines: null,
expands: true,
clipBehavior: Clip.none,
),
),
),
),
),
],
);
}
@override
void dispose() {
scrollController.dispose();
animationController.dispose();
wordAnimationController.dispose();
super.dispose();
}
_loop(_PrompterPainter painter, RenderEditable renderEditable) async {
scrollController.jumpTo(0);
animationController.value = 1;
painter.update();
final ranges = script.lines.map((line) => line.range);
final rects = {
for (final range in ranges)
range: _getRects(range, renderEditable),
};
Tween<TextRange> tween = Tween<TextRange>(begin: ranges.first, end: ranges.first);
final startTimestamp = DateTime.now().millisecondsSinceEpoch;
bool first = true;
for (final line in script.lines) {
final range = line.range;
painter.tween = tween = Tween<TextRange>(begin: tween.end, end: range);
if (!first) {
animationController.value = 0;
await animationController.forward();
}
scrollController.animateTo(rects[range]!.first.top, duration: const Duration(milliseconds: 200), curve: Curves.ease);
final now = DateTime.now().millisecondsSinceEpoch;
final ms = max(0, line.timestamp - (now - startTimestamp));
// print('ms: $ms');
final wordAnimationFuture = wordAnimationController.animateTo(1,
duration: Duration(milliseconds: ms),
);
snooze = CancelableOperation.fromFuture(wordAnimationFuture);
await snooze.valueOrCancellation(null);
if (snooze.isCanceled) break;
wordAnimationController.value = 0;
first = false;
}
// print('done! ${DateTime.now().millisecondsSinceEpoch - startTimestamp}');
scrollController.animateTo(0, duration: Duration(milliseconds: scrollController.offset.floor() * 4), curve: Curves.bounceOut);
painter.tween = null;
setState(() => playing = false);
}
void _visitor(RenderObject child) {
if (child is RenderEditable) {
renderEditable = child;
final painter = _PrompterPainter(animationController, wordAnimationController);
child.foregroundPainter = painter;
animationController.addListener(painter.update);
wordAnimationController.addListener(painter.update);
return;
}
child.visitChildren(_visitor);
}
}
class _PrompterPainter extends RenderEditablePainter {
_PrompterPainter(this.animation, this.wordAnimation);
Animation<double> animation;
Animation<double> wordAnimation;
final _paint = Paint();
final _movingPaint = Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
final _highlightPaint = Paint()
..color = Colors.black38;
Tween<TextRange>? _tween;
set tween(Tween<TextRange>? value) {
// print(value);
_tween = value;
notifyListeners();
}
// the most important method of this code
@override
void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
if ((_tween?.begin, _tween?.end) case (TextRange begin, TextRange end)) {
final beginRects = _getRects(begin, renderEditable);
final endRects = _getRects(end, renderEditable);
final effectivePaint = animation.value == 0 || animation.value == 1? _paint : _movingPaint
..color = Color.lerp(_color(beginRects.length), _color(endRects.length), animation.value)!;
final numRects = max(beginRects.length, endRects.length);
// draw background rects
for (int i = 0; i < numRects; i++) {
final beginRectsIdx = numRects > 1? (i * (beginRects.length - 1) / (numRects - 1)).round() : 0;
final endRectsIdx = numRects > 1? (i * (endRects.length - 1) / (numRects - 1)).round() : 0;
final t = Curves.ease.transform(animation.value);
final rect = Rect.lerp(beginRects[beginRectsIdx], endRects[endRectsIdx], t)!.inflate(2);
final radius = endRectsIdx == 0? const Radius.circular(8) : Radius.zero;
final rrect = RRect.fromRectAndCorners(rect, topLeft: radius, topRight: radius);
canvas.drawRRect(rrect, effectivePaint);
}
final style = renderEditable.text?.style?.copyWith(
color: Color.lerp(Colors.black, Colors.grey.shade100, animation.value),
);
final highlightStyle = style?.copyWith(color: Colors.orange);
// draw highlighted text
final text = renderEditable.text?.toPlainText();
const highlightWidth = 100.0;
final combinedWidth = endRects.fold(highlightWidth, (acc, r) => acc + r.width);
final combinedSize = Size(combinedWidth, endRects.last.bottom - endRects.first.top);
final combinedRect = endRects.first.topLeft.translate(-highlightWidth, 0) & combinedSize;
final alignment = Alignment(lerpDouble(-1, 1, wordAnimation.value)!, 0);
Rect movingRect = alignment.inscribe(Size(highlightWidth, combinedRect.height), combinedRect);
for (final rect in endRects) {
final start = renderEditable.getPositionForPoint(renderEditable.localToGlobal(rect.topLeft));
final end = renderEditable.getPositionForPoint(renderEditable.localToGlobal(rect.topRight));
final painter = TextPainter()
..textDirection = renderEditable.textDirection
..text = TextSpan(
text: text?.substring(start.offset, end.offset),
style: style,
);
painter
..layout()
..paint(canvas, Alignment.center.inscribe(painter.size, rect).topLeft);
if (movingRect.overlaps(rect) && wordAnimation.value != 0) {
final highlightPainter = TextPainter()
..textDirection = renderEditable.textDirection
..text = TextSpan(
text: text?.substring(start.offset, end.offset),
style: highlightStyle,
);
final r = movingRect.intersect(rect);
final deltaX = r.height * 0.6;
final path = Path()
..moveTo((movingRect.left + deltaX).clamp(rect.left, rect.right), r.top)
..lineTo(r.right, r.top)
..lineTo((movingRect.right - deltaX).clamp(rect.left, rect.right), r.bottom)
..lineTo(r.left, r.bottom);
canvas
..save()
..clipPath(path)
..drawPaint(_highlightPaint);
highlightPainter
..layout()
..paint(canvas, Alignment.center.inscribe(highlightPainter.size, rect).topLeft);
canvas.restore();
}
movingRect = movingRect.shift(Offset(-rect.width, 0));
}
}
}
@override
bool shouldRepaint(RenderEditablePainter? oldDelegate) => false;
void update() => notifyListeners();
final _colors = [Colors.indigo.shade900, Colors.teal.shade900, Colors.green.shade900];
_color(int length) {
return _colors[length % _colors.length];
}
}
List<Rect> _getRects(TextRange range, RenderEditable renderEditable) {
final selection = TextSelection(baseOffset: range.start, extentOffset: range.end);
return renderEditable.getBoxesForSelection(selection)
.map((box) => box.toRect())
.toList();
}
typedef ScriptRecord = ({int timestamp, String text, TextSelection range});
class Script {
Script(List<(int timestamp, String text)> input) : lines = _initLines(input);
late List<ScriptRecord> lines;
static List<ScriptRecord> _initLines(List<(int timestamp, String text)> input) {
final list = <ScriptRecord>[];
int index = 0;
for (int i = 0; i < input.length; i++) {
final line = input[i];
final range = TextSelection(baseOffset: index, extentOffset: index + line.$2.length + 1);
index += line.$2.length + 1;
list.add((timestamp: input[i].$1, text: input[i].$2, range: range));
}
return list;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment