rewrite subtitle rendering engine

This commit is contained in:
2025-05-06 22:47:41 +02:00
parent 0d752a9038
commit 35bdb0d366
5 changed files with 315 additions and 134 deletions

View File

@@ -3,7 +3,7 @@ import {
} from "./pythagoras_client"; } from "./pythagoras_client";
import { ScrollingTextBox } from "./scrolling_textbox"; import { ScrollingTextBox } from "./scrolling_textbox";
import { dict, IDLE_LOGOS, QUESTION_LINK, QUESTION_QR } from "./settings"; import { dict, IDLE_LOGOS, QUESTION_LINK, QUESTION_QR } from "./settings";
import { el, sleep, wait_for_dom_refresh } from "./tools"; import { AsyncRunner, el, sleep, wait_for_dom_refresh } from "./tools";
interface PresentationScreen { interface PresentationScreen {
prepare(): Promise<void>; prepare(): Promise<void>;
@@ -67,23 +67,25 @@ export class PresentationManager {
class MainScreen implements PresentationScreen { class MainScreen implements PresentationScreen {
private dom_root: HTMLDivElement; private dom_root: HTMLDivElement;
private subs_english: ScrollingTextBox; private subs_english: AsyncRunner<ScrollingTextBox>;
private subs_czech: ScrollingTextBox; private subs_czech: AsyncRunner<ScrollingTextBox>;
private dom_question: HTMLDivElement; private dom_question: HTMLDivElement;
private dom_question_link: HTMLDivElement; private dom_question_link: HTMLDivElement;
private question_insert: HTMLParagraphElement; private question_insert: HTMLParagraphElement;
public constructor() { public constructor() {
this.subs_english = new ScrollingTextBox(); const subs_en = new ScrollingTextBox();
this.subs_czech = new ScrollingTextBox(); const subs_cz = new ScrollingTextBox();
this.subs_english = new AsyncRunner(subs_en);
this.subs_czech = new AsyncRunner(subs_cz);
this.question_insert = el.p(""); this.question_insert = el.p("");
this.dom_question_link = el.div([el.h1(QUESTION_LINK)], ["link"]); this.dom_question_link = el.div([el.h1(QUESTION_LINK)], ["link"]);
this.dom_question = el.div([this.dom_question_link, this.question_insert], ["question"]); this.dom_question = el.div([this.dom_question_link, this.question_insert], ["question"]);
this.dom_root = el.div([ this.dom_root = el.div([
this.dom_question, this.dom_question,
el.div([ el.div([
el.div([this.subs_english.dom], ["lang"]), el.div([subs_en.dom], ["lang"]),
el.div([this.subs_czech.dom], ["lang"]), el.div([subs_cz.dom], ["lang"]),
], ["subtitles"]), ], ["subtitles"]),
], ["main"]); ], ["main"]);
} }
@@ -117,13 +119,19 @@ class MainScreen implements PresentationScreen {
public async serve(trigger: PythagorasIncomingMessage): Promise<void> { public async serve(trigger: PythagorasIncomingMessage): Promise<void> {
switch (trigger.type) { switch (trigger.type) {
case PythagorasIncomingMessageType.SubEnUpdateCur: case PythagorasIncomingMessageType.SubEnUpdateCur:
await this.subs_english.update_current(trigger.text); this.subs_english.run((target: ScrollingTextBox) => {
return target.update_current(trigger.text)
});
break; break;
case PythagorasIncomingMessageType.SubEnSubmit: case PythagorasIncomingMessageType.SubEnSubmit:
await this.subs_english.finish_line(trigger.text); this.subs_english.run((target: ScrollingTextBox) => {
return target.finish_current(trigger.text);
});
break; break;
case PythagorasIncomingMessageType.SubCzSubmit: case PythagorasIncomingMessageType.SubCzSubmit:
await this.subs_czech.add_line(trigger.text); this.subs_czech.run((target: ScrollingTextBox) => {
return target.add_line(trigger.text);
});
break; break;
case PythagorasIncomingMessageType.SelectedMessage: case PythagorasIncomingMessageType.SelectedMessage:
if (trigger.message === null) { this.hide_question(); } if (trigger.message === null) { this.hide_question(); }

View File

@@ -119,7 +119,6 @@ export class PythagorasClient {
const payload = await this.recv_inner(); const payload = await this.recv_inner();
const text = payload.bytes === undefined ? const text = payload.bytes === undefined ?
(await payload.text()).slice(4) : (utf8_decode((await payload.bytes()).slice(4))); (await payload.text()).slice(4) : (utf8_decode((await payload.bytes()).slice(4)));
console.log("payload:", text);
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
return parsed; return parsed;
} }

View File

@@ -1,138 +1,281 @@
import { DELAY_WORDS } from "./settings"; import { el, sleep, wait_for_dom_refresh } from "./tools";
import { el, sleep } from "./tools";
export class ScrollingTextBox { class Word {
private dom_root: HTMLDivElement; private dom_root: HTMLParagraphElement;
private prev_line_words: HTMLDivElement[]; private inner: string;
private cur_line_words: HTMLDivElement[]; private is_visible: boolean;
private prev_line: HTMLParagraphElement; private static ANIMATION_DURATION: number = 20;
private current_line: HTMLParagraphElement;
public constructor() { public constructor(inner: string, initially_visible: boolean = false) {
this.dom_root = el.div([], ["scrolling-textbox"]); this.inner = inner;
this.cur_line_words = []; this.is_visible = initially_visible;
this.prev_line_words = []; this.dom_root = el.p(this.inner, ["word"]);
this.current_line = el.div(this.cur_line_words, ["paragraph", "active"]); if (!this.is_visible) {
this.prev_line = el.div(this.prev_line_words, ["paragraph", "previous"]); this.dom_root.style.opacity = "0";
this.dom_root.style.transform = "scale(0)";
this.dom_root.appendChild(this.prev_line);
this.dom_root.appendChild(this.current_line);
}
public async update_current(text: string): Promise<void> {
this.update_words(this.current_line, this.cur_line_words, text);
await sleep(10);
this.show_new_words(this.cur_line_words);
}
public async finish_line(text: string): Promise<void> {
this.update_words(this.current_line, this.cur_line_words, text, true);
const current_height = this.current_line.getBoundingClientRect().height;
this.prev_line.style.transform = `translateY(calc(-100% - ${current_height}px))`;
this.current_line.style.transform = "translateY(-100%)";
this.current_line.style.color = "grey";
await sleep(500);
this.dom_root.removeChild(this.prev_line);
this.prev_line = this.current_line;
this.prev_line_words = this.cur_line_words;
this.prev_line.className = "paragraph previous";
this.current_line = el.div([], ["paragraph", "active"]);
this.cur_line_words = [];
this.dom_root.appendChild(this.current_line);
await sleep(10);
}
public async add_line(text: string): Promise<void> {
const current_height = this.current_line.getBoundingClientRect().height;
const next_words = this.make_words(text);
const next_line = el.div(next_words, ["paragraph", "previous"]);
next_line.style.transform = `translateY(${current_height}px)`;
next_line.style.opacity = "0";
this.dom_root.appendChild(next_line);
await sleep(50);
next_line.style.transform = "translateY(0px)";
next_line.style.opacity = "1";
this.current_line.style.transform = "translateY(-100%)";
this.current_line.style.color = "grey";
this.prev_line.style.transform = `translateY(calc(-100% - ${current_height}px))`;
await sleep(500);
this.dom_root.removeChild(this.prev_line);
this.prev_line = this.current_line;
this.prev_line_words = this.cur_line_words;
this.prev_line.className = "paragraph previous";
this.current_line = next_line;
this.current_line.className = "paragraph active";
this.cur_line_words = next_words;
}
private async show_new_words(words: HTMLParagraphElement[]): Promise<void> {
for (const word of words) {
if (word.style.opacity != "0") { continue; }
word.style.opacity = "1";
word.style.transform = "scale(100%)";
await sleep(50);
} }
} }
private update_words( public set text(new_value: string) {
parent: HTMLDivElement, this.inner = new_value;
words: HTMLParagraphElement[], this.dom_root.innerText = this.inner;
new_text: string,
override: boolean = false,
): void {
const new_words = new_text.split(" ");
if (!override) {
for (let i = 0; i < DELAY_WORDS; ++i) { new_words.pop(); }
}
if (words.length > new_words.length) {
this.trim_words(parent, words, new_words.length);
for (let i = 0; i < new_words.length; ++i) {
if (words[i].innerText == new_words[i]) { continue; }
words[i].innerText = new_words[i];
}
return;
}
const to_push: HTMLParagraphElement[] = [];
for (let i = 0; i < new_words.length; ++i) {
if (i < words.length) {
if (new_words[i] == words[i].innerText) { continue; }
words[i].innerText = new_words[i];
continue;
}
const word = el.p(new_words[i], ["word"]);
if (!override) {
word.style.opacity = "0";
word.style.transform = "scale(0)";
}
words.push(word);
to_push.push(word);
}
parent.append(...to_push);
} }
private trim_words( public get text(): string {
parent: HTMLDivElement, return this.inner;
words: HTMLParagraphElement[],
target_length: number
): void {
if (target_length >= words.length || words.length == 0) { return; }
for (let i = words.length - 1; i >= target_length; --i) {
const word = words.pop();
if (word === undefined) { continue; }
parent.removeChild(word);
}
} }
private make_words(text: string): HTMLDivElement[] { public async show(): Promise<void> {
const words = text.split(" "); if (this.is_visible) { return; }
let res = []; this.dom_root.style.opacity = "1";
for (const word of words) { this.dom_root.style.transform = "scale(100%)";
res.push(el.p(word, ["word"])); this.is_visible = true;
} await sleep(Word.ANIMATION_DURATION);
return res; }
public get visible(): boolean {
return this.is_visible;
}
public get dom(): HTMLDivElement {
return this.dom_root;
}
}
class Paragraph {
private dom_root: HTMLDivElement;
private words: Word[];
private height: number;
private height_current: boolean;
private visible: boolean;
private vertical_position: number;
private static SUBTLE_COLOR: string = "gray";
private static ACTIVE_COLOR: string = "white";
private static WORD_ANIM_POST_WAIT: number = 180;
public constructor(initial_text: string | null) {
this.dom_root = el.div([], ["paragraph"]);
this.set_color(Paragraph.ACTIVE_COLOR);
this.words = [];
this.visible = true;
this.height = 0;
this.height_current = true;
this.vertical_position = 0;
this.set_initial_text(initial_text);
}
private set_initial_text(text: string | null): void {
if (text === null) { return; }
this.visible = false;
this.height_current = false;
this.dom_root.style.opacity = "0";
const initial_words = text.split(" ");
for (const word of initial_words) {
const cur_word = new Word(word, true);
this.words.push(cur_word);
this.dom_root.appendChild(cur_word.dom);
}
}
private set_color(new_color: string): void {
this.dom_root.style.color = new_color;
}
private trim_words(target_length: number): void {
if (this.words.length == 0 || target_length < 0) { return; }
if (this.words.length != target_length) { this.invalidate_height(); }
for (let i = this.words.length - 1; i >= target_length; --i) {
const current_pop = this.words.pop();
if (current_pop === undefined) { continue; }
this.dom_root.removeChild(current_pop.dom);
}
}
private invalidate_height(): void {
this.height_current = false;
}
public set_text(new_text: string): void {
const new_words = new_text.split(" ");
if (new_words.length < this.words.length) { this.trim_words(new_words.length); }
for (let i = 0; i < new_words.length; ++i) {
const new_word = new_words[i];
if (this.words.length <= i) {
const cur_word = new Word(new_word);
this.words.push(cur_word);
this.dom_root.appendChild(cur_word.dom);
this.invalidate_height();
continue;
}
if (this.words[i].text != new_word) {
this.words[i].text = new_word;
this.invalidate_height();
}
}
}
public async animate(): Promise<void> {
for (const word of this.words) {
await word.show();
}
await sleep(Paragraph.WORD_ANIM_POST_WAIT);
}
private calculate_height(): void {
this.height = this.dom_root.getBoundingClientRect().height;
}
public get real_height(): number {
if (!this.height_current) {
this.calculate_height();
this.height_current = true;
}
return this.height;
}
public subtleize(): Promise<void> {
this.set_color(Paragraph.SUBTLE_COLOR);
return sleep(500);
}
public async show(): Promise<void> {
if (this.visible) { return; }
this.dom_root.style.opacity = "1";
return sleep(500);
}
public set translate_y(value: number) {
this.vertical_position = value;
this.dom_root.style.transform = `translateY(${value}px)`;
}
public get translate_y(): number {
return this.vertical_position;
}
public get dom(): HTMLDivElement {
return this.dom_root;
}
}
export class ScrollingTextBox {
private dom_root: HTMLDivElement;
private paragraphs: Paragraph[];
private static MAX_HEIGHT: number = 1000;
private static CENTER_HEIGHT: number = 600;
private static VERTICAL_MARGIN: number = 10;
public constructor() {
this.dom_root = el.div([], ["scrolling-textbox"]);
this.paragraphs = [];
}
private add_paragraph(initial_text: string | null): void {
const new_paragraph = new Paragraph(initial_text);
new_paragraph.translate_y = ScrollingTextBox.CENTER_HEIGHT;
this.paragraphs.push(new_paragraph);
this.dom_root.appendChild(new_paragraph.dom);
}
private add_paragraph_bottom(initial_text: string): void {
if (this.paragraphs.length == 0) {
this.add_paragraph(initial_text);
return;
}
const most_bottom = this.paragraphs[this.paragraphs.length - 1];
const offset = most_bottom.translate_y + most_bottom.real_height;
const new_paragraph = new Paragraph(initial_text);
new_paragraph.translate_y = offset;
this.paragraphs.push(new_paragraph);
this.dom_root.appendChild(new_paragraph.dom);
}
/**
* Remove all paragraphs up to (not including) a certain index.
*/
private trim_to(exclusive_end: number): void {
const spliced = this.paragraphs.splice(0, exclusive_end);
for (const item of spliced) {
this.dom_root.removeChild(item.dom);
}
}
/**
* Run scroll animation on the first element and then pop it off of the DOM.
*/
private async scroll_trim(offset: number): Promise<void> {
const to_trim = this.paragraphs.shift();
if (to_trim === undefined) { return; }
to_trim.translate_y = offset;
await sleep(500);
this.dom_root.removeChild(to_trim.dom);
}
/**
* Update elements DOM scrolls and wait for animations to complete.
*
* Note that this will remove any paragraphs that aren't visible anymore.
*/
private async update_scroll(): Promise<void> {
if (this.paragraphs.length == 0) { return; }
const last = this.paragraphs[this.paragraphs.length - 1];
let offset = Math.min(
ScrollingTextBox.MAX_HEIGHT - last.real_height, ScrollingTextBox.CENTER_HEIGHT
);
last.translate_y = offset;
if (this.paragraphs.length == 1) { return; }
for (let i = this.paragraphs.length - 2; i >= 0; --i) {
offset -= ScrollingTextBox.VERTICAL_MARGIN;
const cur = this.paragraphs[i];
if (offset <= 0) {
this.trim_to(i);
this.scroll_trim(offset - cur.real_height);
break;
}
offset -= cur.real_height;
cur.translate_y = offset;
}
await sleep(500);
}
public async update_current(text: string): Promise<void> {
if (this.paragraphs.length == 0) {
this.add_paragraph(null);
this.update_scroll();
}
const current = this.paragraphs[this.paragraphs.length - 1];
current.set_text(text);
await wait_for_dom_refresh();
await Promise.all([this.update_scroll(), current.animate()]);
}
public async finish_current(text: string): Promise<void> {
if (this.paragraphs.length == 0) {
this.add_paragraph(text);
}
const current = this.paragraphs[this.paragraphs.length - 1];
current.set_text(text);
this.add_paragraph(null);
await Promise.all([this.update_scroll(), current.subtleize(), current.animate()]);
}
public async add_line(text: string): Promise<void> {
if (this.paragraphs.length != 0) {
this.paragraphs[this.paragraphs.length - 1].subtleize();
}
this.add_paragraph_bottom(text);
await wait_for_dom_refresh();
await Promise.all([
this.update_scroll(),
this.paragraphs[this.paragraphs.length - 1].show(),
]);
} }
public get dom(): HTMLDivElement { public get dom(): HTMLDivElement {

View File

@@ -120,14 +120,18 @@ body {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
width: 100%; width: 100%;
height: 1000px;
.paragraph { .paragraph {
transition: .5s ease color, .5s ease transform, .5s ease opacity; transition: .5s ease color, .5s ease transform, .5s ease opacity;
position: absolute;
display: flex; display: flex;
font-size: $line-height; font-size: $line-height;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0; margin: 0;
top: 0;
transform: translateY(0); transform: translateY(0);
height: fit-content;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;

View File

@@ -41,6 +41,33 @@ export function wait_for_dom_refresh(): Promise<void> {
}); });
} }
/**
* Provides a locking abstraction around any ref-passed object to provide inner mutability that can
* automatically release on promise resolution. This provides "atomic transactions" on the inner
* type.
*
* This allows you to not await the transaction result, but offload the waiting to the next call
* to run. So other tasks can normally run when the transaction is executing. And if the inner
* type is needed again... the run call will wait for the (possibly) running transaction to
* finish first.
*/
export class AsyncRunner<T> {
private inner: T;
private lock: Promise<void>;
public constructor(inner: T) {
this.inner = inner;
this.lock = Promise.resolve();
}
public async run(transaction: (target: T) => Promise<void>): Promise<void> {
await this.lock;
this.lock = new Promise((resolver) => {
transaction(this.inner).then(() => { resolver() });
});
}
}
export namespace el { export namespace el {
function add_classes(target: HTMLElement, classes?: string[]): void { function add_classes(target: HTMLElement, classes?: string[]): void {
if (classes === undefined) { return; } if (classes === undefined) { return; }