From 35bdb0d3666d6d712844b0321e6a544a13b511ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mekina?= Date: Tue, 6 May 2025 22:47:41 +0200 Subject: [PATCH] rewrite subtitle rendering engine --- client/presentationmgr.ts | 28 ++- client/pythagoras_client.ts | 1 - client/scrolling_textbox.ts | 389 ++++++++++++++++++++++++------------ client/style.scss | 4 + client/tools.ts | 27 +++ 5 files changed, 315 insertions(+), 134 deletions(-) diff --git a/client/presentationmgr.ts b/client/presentationmgr.ts index d7f4ff9..c905832 100644 --- a/client/presentationmgr.ts +++ b/client/presentationmgr.ts @@ -3,7 +3,7 @@ import { } from "./pythagoras_client"; import { ScrollingTextBox } from "./scrolling_textbox"; 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 { prepare(): Promise; @@ -67,23 +67,25 @@ export class PresentationManager { class MainScreen implements PresentationScreen { private dom_root: HTMLDivElement; - private subs_english: ScrollingTextBox; - private subs_czech: ScrollingTextBox; + private subs_english: AsyncRunner; + private subs_czech: AsyncRunner; private dom_question: HTMLDivElement; private dom_question_link: HTMLDivElement; private question_insert: HTMLParagraphElement; public constructor() { - this.subs_english = new ScrollingTextBox(); - this.subs_czech = new ScrollingTextBox(); + const subs_en = 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.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_root = el.div([ this.dom_question, el.div([ - el.div([this.subs_english.dom], ["lang"]), - el.div([this.subs_czech.dom], ["lang"]), + el.div([subs_en.dom], ["lang"]), + el.div([subs_cz.dom], ["lang"]), ], ["subtitles"]), ], ["main"]); } @@ -117,13 +119,19 @@ class MainScreen implements PresentationScreen { public async serve(trigger: PythagorasIncomingMessage): Promise { switch (trigger.type) { case PythagorasIncomingMessageType.SubEnUpdateCur: - await this.subs_english.update_current(trigger.text); + this.subs_english.run((target: ScrollingTextBox) => { + return target.update_current(trigger.text) + }); break; case PythagorasIncomingMessageType.SubEnSubmit: - await this.subs_english.finish_line(trigger.text); + this.subs_english.run((target: ScrollingTextBox) => { + return target.finish_current(trigger.text); + }); break; case PythagorasIncomingMessageType.SubCzSubmit: - await this.subs_czech.add_line(trigger.text); + this.subs_czech.run((target: ScrollingTextBox) => { + return target.add_line(trigger.text); + }); break; case PythagorasIncomingMessageType.SelectedMessage: if (trigger.message === null) { this.hide_question(); } diff --git a/client/pythagoras_client.ts b/client/pythagoras_client.ts index 4a97340..44af694 100644 --- a/client/pythagoras_client.ts +++ b/client/pythagoras_client.ts @@ -119,7 +119,6 @@ export class PythagorasClient { const payload = await this.recv_inner(); const text = payload.bytes === undefined ? (await payload.text()).slice(4) : (utf8_decode((await payload.bytes()).slice(4))); - console.log("payload:", text); const parsed = JSON.parse(text); return parsed; } diff --git a/client/scrolling_textbox.ts b/client/scrolling_textbox.ts index 9c23467..d493025 100644 --- a/client/scrolling_textbox.ts +++ b/client/scrolling_textbox.ts @@ -1,138 +1,281 @@ -import { DELAY_WORDS } from "./settings"; -import { el, sleep } from "./tools"; +import { el, sleep, wait_for_dom_refresh } from "./tools"; -export class ScrollingTextBox { - private dom_root: HTMLDivElement; +class Word { + private dom_root: HTMLParagraphElement; - private prev_line_words: HTMLDivElement[]; - private cur_line_words: HTMLDivElement[]; + private inner: string; + private is_visible: boolean; - private prev_line: HTMLParagraphElement; - private current_line: HTMLParagraphElement; + private static ANIMATION_DURATION: number = 20; - public constructor() { - this.dom_root = el.div([], ["scrolling-textbox"]); - this.cur_line_words = []; - this.prev_line_words = []; + public constructor(inner: string, initially_visible: boolean = false) { + this.inner = inner; + this.is_visible = initially_visible; + this.dom_root = el.p(this.inner, ["word"]); - this.current_line = el.div(this.cur_line_words, ["paragraph", "active"]); - this.prev_line = el.div(this.prev_line_words, ["paragraph", "previous"]); - - this.dom_root.appendChild(this.prev_line); - this.dom_root.appendChild(this.current_line); - } - - public async update_current(text: string): Promise { - 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 { - 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 { - 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 { - for (const word of words) { - if (word.style.opacity != "0") { continue; } - word.style.opacity = "1"; - word.style.transform = "scale(100%)"; - await sleep(50); + if (!this.is_visible) { + this.dom_root.style.opacity = "0"; + this.dom_root.style.transform = "scale(0)"; } } - private update_words( - parent: HTMLDivElement, - words: HTMLParagraphElement[], - 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); + public set text(new_value: string) { + this.inner = new_value; + this.dom_root.innerText = this.inner; } - private trim_words( - parent: HTMLDivElement, - 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); - } + public get text(): string { + return this.inner; } - private make_words(text: string): HTMLDivElement[] { - const words = text.split(" "); - let res = []; - for (const word of words) { - res.push(el.p(word, ["word"])); - } - return res; + public async show(): Promise { + if (this.is_visible) { return; } + this.dom_root.style.opacity = "1"; + this.dom_root.style.transform = "scale(100%)"; + this.is_visible = true; + await sleep(Word.ANIMATION_DURATION); + } + + 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 { + 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 { + this.set_color(Paragraph.SUBTLE_COLOR); + return sleep(500); + } + + public async show(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/client/style.scss b/client/style.scss index 1fc8fc1..d7d7133 100644 --- a/client/style.scss +++ b/client/style.scss @@ -120,14 +120,18 @@ body { -ms-overflow-style: none; scrollbar-width: none; width: 100%; + height: 1000px; .paragraph { transition: .5s ease color, .5s ease transform, .5s ease opacity; + position: absolute; display: flex; font-size: $line-height; flex-wrap: wrap; margin: 0; + top: 0; transform: translateY(0); + height: fit-content; width: 100%; max-width: 100%; diff --git a/client/tools.ts b/client/tools.ts index bb34a51..14a4ff1 100644 --- a/client/tools.ts +++ b/client/tools.ts @@ -41,6 +41,33 @@ export function wait_for_dom_refresh(): Promise { }); } +/** + * 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 { + private inner: T; + private lock: Promise; + + public constructor(inner: T) { + this.inner = inner; + this.lock = Promise.resolve(); + } + + public async run(transaction: (target: T) => Promise): Promise { + await this.lock; + this.lock = new Promise((resolver) => { + transaction(this.inner).then(() => { resolver() }); + }); + } +} + export namespace el { function add_classes(target: HTMLElement, classes?: string[]): void { if (classes === undefined) { return; }