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; }