Last active
February 6, 2024 15:59
-
-
Save pskink/d4133f951b84e77864eabba88338cdea to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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