diff --git a/assets/fm_logo.svg b/assets/fm_logo.svg index c1cc3b1..c083ecf 100644 --- a/assets/fm_logo.svg +++ b/assets/fm_logo.svg @@ -1 +1,20 @@ - + + + + + diff --git a/assets/tul_logo.svg b/assets/tul_logo.svg new file mode 100644 index 0000000..2dc3a9e --- /dev/null +++ b/assets/tul_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/zmp_logo.svg b/assets/zmp_logo.svg new file mode 100644 index 0000000..297e394 --- /dev/null +++ b/assets/zmp_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/make.mk b/client/make.mk index 6f5213b..c4156a4 100644 --- a/client/make.mk +++ b/client/make.mk @@ -1,7 +1,7 @@ CLIENT_PAGES := index.html CLIENT_STYLES := style.css CLIENT_SCRIPTS := script.js -CLIENT_ASSETS := fm_logo.svg qr_ask.svg +CLIENT_ASSETS := fm_logo.svg zmp_logo.svg tul_logo.svg qr_ask.svg CLIENT_TARGETS := $(CLIENT_PAGES:%=static/%) \ $(CLIENT_STYLES:%=static/%) \ $(CLIENT_SCRIPTS:%=static/%) \ diff --git a/client/presentationmgr.ts b/client/presentationmgr.ts index 1d05704..7e37008 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 } from "./tools"; +import { AsyncRunner, el, sleep, wait_for_dom_refresh } from "./tools"; interface PresentationScreen { prepare(): Promise; @@ -27,7 +27,7 @@ export class PresentationManager { await this.screen.prepare(); this.dom_root.innerHTML = ""; this.dom_root.appendChild(this.screen.dom); - await sleep(10); + await wait_for_dom_refresh(); } public async serve(ws_client: PythagorasClient): Promise { @@ -67,24 +67,29 @@ 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"]), + el.div([ + el.p("Jedná se o automaticky generovaný přepis. Omluvte, prosíme, případné chyby.", ["disclaimer"]), + ]), ], ["main"]); } @@ -93,7 +98,7 @@ class MainScreen implements PresentationScreen { } public async start(): Promise { - await sleep(10); + await wait_for_dom_refresh(); this.dom_root.style.opacity = "1"; await sleep(500); } @@ -117,13 +122,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(); } @@ -198,7 +209,7 @@ class VideoScreen implements PresentationScreen { this.dom_video.currentTime = seconds_from_start; this.dom_root.innerHTML = ""; this.dom_root.appendChild(this.dom_video); - await sleep(10); + await wait_for_dom_refresh(); await this.start(); } @@ -215,7 +226,7 @@ class IdleScreen implements PresentationScreen { private dom_root: HTMLDivElement; private dom_title: HTMLHeadingElement; private dom_subtitle: HTMLHeadingElement; - private dom_logos: HTMLImageElement[]; + private dom_logos: HTMLDivElement[]; public constructor() { this.dom_title = el.h1(dict.IDLE_TITLE) @@ -226,14 +237,14 @@ class IdleScreen implements PresentationScreen { public async prepare(): Promise { for (const logo of IDLE_LOGOS) { - this.dom_logos.push(await el.img(logo, ["logo"])); + this.dom_logos.push(el.div([await el.img(logo)], ["logo"])); } const logos = el.div([...this.dom_logos], ["logos"]); this.dom_root.appendChild(logos); } public async start(): Promise { - await sleep(10); + await wait_for_dom_refresh(); this.dom_title.style.transform = "translateY(0)"; this.dom_title.style.opacity = "1"; await sleep(250); 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/settings.ts b/client/settings.ts index e68f08f..bc34109 100644 --- a/client/settings.ts +++ b/client/settings.ts @@ -4,7 +4,9 @@ export namespace dict { } export const IDLE_LOGOS: string[] = [ + "files/tul_logo.svg", "files/fm_logo.svg", + "files/zmp_logo.svg", ]; export const QUESTION_QR: string = "files/qr_ask.svg"; diff --git a/client/style.scss b/client/style.scss index 1fc8fc1..a540cc8 100644 --- a/client/style.scss +++ b/client/style.scss @@ -1,5 +1,12 @@ @use "sass:color"; +#run { + width: 100%; + height: 100vh; + background-color: transparent; + border: none; +} + body { font-family: Arial; background-color: black; @@ -19,14 +26,14 @@ body { h1 { transition: .5s ease transform, .5s ease opacity; + font-size: 3em; opacity: 0; transform: translateY(-100%); } h2 { transition: .5s ease opacity; - color: grey; - font-size: 1em; + color: color.scale(white, $lightness: -25%); margin: 0; opacity: 0; font-weight: normal; @@ -34,16 +41,23 @@ body { .logos { display: flex; - margin-top: 10em; - width: 40rem; + margin-top: 15em; justify-content: center; + align-items: center; .logo { + display: flex; + align-items: center; transition: .5s ease-out transform, .5s ease-out opacity; - width: 15em; + height: 7em; transform: translateY(100%); opacity: 0; - margin: 0 1em; + margin: 0 2em; + + img { + max-height: 100%; + max-width: 25em; + } } } } @@ -63,6 +77,15 @@ body { .main { transition: .5s ease opacity; opacity: 0; + + .disclaimer { + color: color.adjust(white, $lightness: -40%); + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + font-size: 1.5em; + } } .question { @@ -120,14 +143,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 a4cf0e1..14a4ff1 100644 --- a/client/tools.ts +++ b/client/tools.ts @@ -31,6 +31,43 @@ export async function sleep(millis: number): Promise { }); } +export function wait_for_dom_refresh(): Promise { + return new Promise((resolver) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolver(); + }); + }); + }); +} + +/** + * 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; }