add half-working client
This commit is contained in:
1
assets/fm_logo.svg
Normal file
1
assets/fm_logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 49 KiB |
1
assets/qr_ask.svg
Normal file
1
assets/qr_ask.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 11 KiB |
@@ -4,9 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title></title>
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
<link href="style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<button id="run">Run</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -1,9 +1,11 @@
|
||||
CLIENT_PAGES := index.html
|
||||
CLIENT_STYLES := style.css
|
||||
CLIENT_SCRIPTS := script.js
|
||||
CLIENT_ASSETS := fm_logo.svg qr_ask.svg
|
||||
CLIENT_TARGETS := $(CLIENT_PAGES:%=static/%) \
|
||||
$(CLIENT_STYLES:%=static/%) \
|
||||
$(CLIENT_SCRIPTS:%=static/%)
|
||||
$(CLIENT_SCRIPTS:%=static/%) \
|
||||
$(CLIENT_ASSETS:%=static/%)
|
||||
|
||||
.PHONY: client_clean
|
||||
client_clean:
|
||||
@@ -23,6 +25,9 @@ static/script.js: \
|
||||
client/ws.ts \
|
||||
client/pythagoras_client.ts \
|
||||
client/tools.ts \
|
||||
client/presentationmgr.ts \
|
||||
client/settings.ts \
|
||||
client/scrolling_textbox.ts \
|
||||
client/node_modules
|
||||
@mkdir -p $(@D)
|
||||
bun build $< --minify --outfile $@
|
||||
@@ -44,6 +49,11 @@ static/%.css: client/%.scss client/node_modules
|
||||
@mkdir -p $(@D)
|
||||
bun run --cwd client sass $(notdir $<) --style compressed > $@
|
||||
|
||||
# generic svgs
|
||||
static/%.svg: assets/%.svg
|
||||
@mkdir -p $(@D)
|
||||
ln -f $< $@
|
||||
|
||||
# generic scripts
|
||||
static/%.js: client/%.ts client/node_modules
|
||||
@mkdir -p $(@D)
|
||||
|
282
client/presentationmgr.ts
Normal file
282
client/presentationmgr.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
PythagorasIncomingMessageType, type PythagorasClient, type PythagorasIncomingMessage
|
||||
} from "./pythagoras_client";
|
||||
import { ScrollingTextBox } from "./scrolling_textbox";
|
||||
import { dict, IDLE_LOGOS, QUESTION_LINK, QUESTION_QR } from "./settings";
|
||||
import { el, sleep } from "./tools";
|
||||
|
||||
interface PresentationScreen {
|
||||
prepare(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
end(): Promise<void>;
|
||||
serve(trigger: PythagorasIncomingMessage): Promise<void>;
|
||||
dom: HTMLDivElement;
|
||||
}
|
||||
|
||||
export class PresentationManager {
|
||||
private screen: PresentationScreen;
|
||||
private dom_root: HTMLDivElement;
|
||||
|
||||
public constructor() {
|
||||
this.dom_root = el.div([]);
|
||||
|
||||
this.screen = new BlankScreen();
|
||||
}
|
||||
|
||||
private async update_dom_screen(): Promise<void> {
|
||||
await this.screen.prepare();
|
||||
this.dom_root.innerHTML = "";
|
||||
this.dom_root.appendChild(this.screen.dom);
|
||||
await sleep(10);
|
||||
}
|
||||
|
||||
public async serve(ws_client: PythagorasClient): Promise<void> {
|
||||
while (true) {
|
||||
const received = await ws_client.recv();
|
||||
if (received.type == PythagorasIncomingMessageType.SetScreen) {
|
||||
await this.screen.end();
|
||||
this.set_screen(received.screen);
|
||||
await this.update_dom_screen();
|
||||
await this.screen.start();
|
||||
continue;
|
||||
}
|
||||
await this.screen.serve(received);
|
||||
}
|
||||
}
|
||||
|
||||
private set_screen(screen: "idle" | "video" | "main"): void {
|
||||
switch (screen) {
|
||||
case "idle":
|
||||
this.screen = new IdleScreen();
|
||||
break;
|
||||
case "video":
|
||||
this.screen = new VideoScreen();
|
||||
break;
|
||||
case "main":
|
||||
this.screen = new MainScreen();
|
||||
break;
|
||||
default:
|
||||
throw new Error("unknown screen id");
|
||||
}
|
||||
}
|
||||
|
||||
public get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
||||
|
||||
class MainScreen implements PresentationScreen {
|
||||
private dom_root: HTMLDivElement;
|
||||
private subs_english: ScrollingTextBox;
|
||||
private subs_czech: ScrollingTextBox;
|
||||
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();
|
||||
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"]),
|
||||
], ["subtitles"]),
|
||||
], ["main"]);
|
||||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
this.dom_question_link.append(await el.img(QUESTION_QR, ["qr"]));
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await sleep(10);
|
||||
this.dom_root.style.opacity = "1";
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
public async end(): Promise<void> {
|
||||
}
|
||||
|
||||
private async show_question(text: string): Promise<void> {
|
||||
this.question_insert.innerText = text;
|
||||
this.question_insert.style.opacity = "1";
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
private async hide_question(): Promise<void> {
|
||||
this.question_insert.style.opacity = "0";
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
public async serve(trigger: PythagorasIncomingMessage): Promise<void> {
|
||||
switch (trigger.type) {
|
||||
case PythagorasIncomingMessageType.SubEnUpdateCur:
|
||||
await this.subs_english.update_current(trigger.text);
|
||||
break;
|
||||
case PythagorasIncomingMessageType.SubEnSubmit:
|
||||
await this.subs_english.finish_line(trigger.text);
|
||||
break;
|
||||
case PythagorasIncomingMessageType.SubCzSubmit:
|
||||
await this.subs_czech.add_line(trigger.text);
|
||||
break;
|
||||
case PythagorasIncomingMessageType.SelectedMessage:
|
||||
if (trigger.message === null) { this.hide_question(); }
|
||||
else { this.show_question(trigger.message); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
||||
|
||||
class VideoScreen implements PresentationScreen {
|
||||
private dom_root: HTMLDivElement;
|
||||
private dom_video: HTMLVideoElement;
|
||||
|
||||
private src: string | null;
|
||||
private subtitles: string | null;
|
||||
|
||||
public constructor() {
|
||||
this.src = null;
|
||||
this.subtitles = null;
|
||||
this.dom_video = document.createElement("video");
|
||||
this.dom_root = el.div([this.dom_video], ["video"]);
|
||||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
this.dom_root.innerHTML = "";
|
||||
if (this.src !== null) {
|
||||
this.dom_video = await el.video(
|
||||
this.src, this.subtitles === null ? "" : this.subtitles
|
||||
);
|
||||
this.dom_video.volume = 0;
|
||||
this.dom_root.appendChild(this.dom_video);
|
||||
}
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.dom_video.style.opacity = "1";
|
||||
for (let i = 0; i <= 100; ++i) {
|
||||
this.dom_video.volume = i / 100;
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
public async end(): Promise<void> {
|
||||
this.dom_video.style.opacity = "0";
|
||||
for (let i = 100; i >= 0; --i) {
|
||||
this.dom_video.volume = i / 100;
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
public async serve(trigger: PythagorasIncomingMessage): Promise<void> {
|
||||
switch (trigger.type) {
|
||||
case PythagorasIncomingMessageType.PlayVideo:
|
||||
await this.play_video(
|
||||
trigger.filename, trigger.subtitles, trigger.seconds_from_start
|
||||
);
|
||||
break;
|
||||
case PythagorasIncomingMessageType.SeekVideo:
|
||||
this.seek_video(trigger.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async play_video(
|
||||
video_url: string, subtitles_url: string | null, seconds_from_start: number
|
||||
): Promise<void> {
|
||||
this.dom_video = await el.video(video_url, subtitles_url);
|
||||
this.dom_video.currentTime = seconds_from_start;
|
||||
this.dom_root.innerHTML = "";
|
||||
this.dom_root.appendChild(this.dom_video);
|
||||
await sleep(10);
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private seek_video(seconds_from_start: number): void {
|
||||
this.dom_video.currentTime = seconds_from_start;
|
||||
}
|
||||
|
||||
public get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
||||
|
||||
class IdleScreen implements PresentationScreen {
|
||||
private dom_root: HTMLDivElement;
|
||||
private dom_title: HTMLHeadingElement;
|
||||
private dom_subtitle: HTMLHeadingElement;
|
||||
private dom_logos: HTMLImageElement[];
|
||||
|
||||
public constructor() {
|
||||
this.dom_title = el.h1(dict.IDLE_TITLE)
|
||||
this.dom_subtitle = el.h2(dict.IDLE_STARTING);
|
||||
this.dom_logos = [];
|
||||
this.dom_root = el.div([this.dom_title, this.dom_subtitle], ["idle"]);
|
||||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
for (const logo of IDLE_LOGOS) {
|
||||
this.dom_logos.push(await el.img(logo, ["logo"]));
|
||||
}
|
||||
const logos = el.div([...this.dom_logos], ["logos"]);
|
||||
this.dom_root.appendChild(logos);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.dom_title.style.transform = "translateY(0)";
|
||||
this.dom_title.style.opacity = "1";
|
||||
await sleep(250);
|
||||
this.dom_subtitle.style.opacity = "1";
|
||||
await sleep(250);
|
||||
for (const logo of this.dom_logos) {
|
||||
logo.style.transform = "translateY(0)";
|
||||
logo.style.opacity = "1";
|
||||
await sleep(250);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
public async end(): Promise<void> {
|
||||
for (const logo of this.dom_logos) {
|
||||
logo.style.transform = "translateY(100%)";
|
||||
logo.style.opacity = "0";
|
||||
await sleep(250);
|
||||
}
|
||||
this.dom_subtitle.style.opacity = "0";
|
||||
await sleep(250);
|
||||
this.dom_title.style.transform = "translateY(-100%)";
|
||||
this.dom_title.style.opacity = "0";
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
public async serve(trigger: PythagorasIncomingMessage): Promise<void> {}
|
||||
|
||||
public get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
||||
|
||||
class BlankScreen implements PresentationScreen {
|
||||
private dom_root: HTMLDivElement;
|
||||
|
||||
public constructor() {
|
||||
this.dom_root = el.div([]);
|
||||
}
|
||||
|
||||
public async prepare() {}
|
||||
public async start() {}
|
||||
public async end() {}
|
||||
public async serve(_: PythagorasIncomingMessage) {}
|
||||
|
||||
public get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
@@ -1,22 +1,55 @@
|
||||
import { uint_bytes_to_num, utf8_decode } from "./tools";
|
||||
import { sleep, uint_bytes_to_num, utf8_decode, utf8_encode } from "./tools";
|
||||
import { WSClient } from "./ws";
|
||||
|
||||
enum PythagorasIncomingMessageType {
|
||||
SubUpdateCur,
|
||||
SubFinishCur,
|
||||
SelectedMessage,
|
||||
export enum PythagorasIncomingMessageType {
|
||||
|
||||
// subtitles
|
||||
SubEnUpdateCur = "subtitle_en_update_current",
|
||||
SubEnSubmit = "subtitle_en_submit_sentence",
|
||||
SubCzSubmit = "subtitle_cs_submit_sentence",
|
||||
|
||||
// mode management
|
||||
SetScreen = "setscreen",
|
||||
|
||||
// video
|
||||
PlayVideo = "playvideo",
|
||||
SeekVideo = "seekvideo",
|
||||
|
||||
// message
|
||||
SelectedMessage = "selectedmessage",
|
||||
};
|
||||
|
||||
type PythagorasIncomingMessage = (
|
||||
export type PythagorasIncomingMessage = (
|
||||
|
||||
{
|
||||
type: PythagorasIncomingMessageType.SubFinishCur | PythagorasIncomingMessageType.SubUpdateCur,
|
||||
type: (
|
||||
PythagorasIncomingMessageType.SubEnUpdateCur |
|
||||
PythagorasIncomingMessageType.SubEnSubmit |
|
||||
PythagorasIncomingMessageType.SubCzSubmit
|
||||
),
|
||||
text: string,
|
||||
} |
|
||||
|
||||
{
|
||||
type: PythagorasIncomingMessageType.SelectedMessage,
|
||||
message: string,
|
||||
message: string | null,
|
||||
} |
|
||||
|
||||
{
|
||||
type: PythagorasIncomingMessageType.SetScreen,
|
||||
screen: "main" | "video" | "idle",
|
||||
} |
|
||||
|
||||
{
|
||||
type: PythagorasIncomingMessageType.PlayVideo,
|
||||
filename: string,
|
||||
subtitles: string,
|
||||
seconds_from_start: number,
|
||||
} |
|
||||
|
||||
{
|
||||
type: PythagorasIncomingMessageType.SeekVideo,
|
||||
timestamp: number,
|
||||
}
|
||||
|
||||
);
|
||||
@@ -27,8 +60,6 @@ export class PythagorasClient {
|
||||
|
||||
private buf: Uint8Array;
|
||||
|
||||
private static max_recv_retry: number = 10;
|
||||
|
||||
public constructor(addr: string) {
|
||||
this.sock = null;
|
||||
this.addr = addr;
|
||||
@@ -46,12 +77,22 @@ export class PythagorasClient {
|
||||
*/
|
||||
private async recv_inner(): Promise<Blob> {
|
||||
if (this.sock === null) { await this.reconnect(); }
|
||||
for (let i = 0; i < PythagorasClient.max_recv_retry; ++i) {
|
||||
while (true) {
|
||||
const received = await this.sock!.receive();
|
||||
if (received !== null) { return received; }
|
||||
await sleep(500);
|
||||
await this.reconnect();
|
||||
}
|
||||
throw new Error("max reconnection attempts reached");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force receive from the underlying `WSClient` and convert the result to bytes (if the result
|
||||
* is not bytes already).
|
||||
*/
|
||||
private async recv_inner_bytes(): Promise<Uint8Array> {
|
||||
const received = await this.recv_inner();
|
||||
if (received.bytes !== undefined) { return await received.bytes(); }
|
||||
return utf8_encode(await received.text());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +101,7 @@ export class PythagorasClient {
|
||||
private async recv_length(target: number): Promise<Uint8Array> {
|
||||
if (target == 0) { return new Uint8Array(0); }
|
||||
while (this.buf.length < target) {
|
||||
const received = await (await this.recv_inner()).bytes();
|
||||
const received = await this.recv_inner_bytes();
|
||||
const merged = new Uint8Array(this.buf.length + received.length);
|
||||
merged.set(this.buf);
|
||||
merged.set(received, this.buf.length);
|
||||
@@ -73,8 +114,17 @@ export class PythagorasClient {
|
||||
}
|
||||
|
||||
public async recv(): Promise<PythagorasIncomingMessage> {
|
||||
while (true) {
|
||||
const advertised_length = uint_bytes_to_num(await this.recv_length(4));
|
||||
try {
|
||||
const payload = utf8_decode(await this.recv_length(advertised_length));
|
||||
return JSON.parse(payload);
|
||||
const parsed = JSON.parse(payload);
|
||||
console.log(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
this.buf = new Uint8Array(0);
|
||||
await this.reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import { PresentationManager } from "./presentationmgr";
|
||||
import { PythagorasClient } from "./pythagoras_client";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const conn = new PythagorasClient("/ws");
|
||||
console.log(await conn.recv());
|
||||
const ws_client = new PythagorasClient("/ws");
|
||||
document.body.innerHTML = "";
|
||||
const presentation_manager = new PresentationManager();
|
||||
document.body.appendChild(presentation_manager.dom);
|
||||
await presentation_manager.serve(ws_client);
|
||||
}
|
||||
|
||||
(<HTMLButtonElement>document.querySelector("#run")).onclick = () => {
|
||||
main();
|
||||
}
|
||||
|
141
client/scrolling_textbox.ts
Normal file
141
client/scrolling_textbox.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { DELAY_WORDS } from "./settings";
|
||||
import { el, sleep } from "./tools";
|
||||
|
||||
export class ScrollingTextBox {
|
||||
private dom_root: HTMLDivElement;
|
||||
|
||||
private prev_line_words: HTMLDivElement[];
|
||||
private cur_line_words: HTMLDivElement[];
|
||||
|
||||
private prev_line: HTMLParagraphElement;
|
||||
private current_line: HTMLParagraphElement;
|
||||
|
||||
public constructor() {
|
||||
this.dom_root = el.div([], ["scrolling-textbox"]);
|
||||
this.cur_line_words = [];
|
||||
this.prev_line_words = [];
|
||||
|
||||
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<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(
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 get dom(): HTMLDivElement {
|
||||
return this.dom_root;
|
||||
}
|
||||
}
|
13
client/settings.ts
Normal file
13
client/settings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export namespace dict {
|
||||
export const IDLE_TITLE: string = "Richard Stallman na Technické univerzitě v Liberci";
|
||||
export const IDLE_STARTING: string = "Přednáška o soukromí a svobodě v digitální době";
|
||||
}
|
||||
|
||||
export const IDLE_LOGOS: string[] = [
|
||||
"files/fm_logo.svg",
|
||||
];
|
||||
|
||||
export const QUESTION_QR: string = "files/qr_ask.svg";
|
||||
export const QUESTION_LINK: string = "https://ask.libre-liberec.cz";
|
||||
|
||||
export const DELAY_WORDS: number = 1;
|
@@ -0,0 +1,151 @@
|
||||
@use "sass:color";
|
||||
|
||||
body {
|
||||
font-family: Arial;
|
||||
background-color: black;
|
||||
color: white;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.idle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
transition: .5s ease transform, .5s ease opacity;
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
h2 {
|
||||
transition: .5s ease opacity;
|
||||
color: grey;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.logos {
|
||||
display: flex;
|
||||
margin-top: 10em;
|
||||
width: 40rem;
|
||||
justify-content: center;
|
||||
|
||||
.logo {
|
||||
transition: .5s ease-out transform, .5s ease-out opacity;
|
||||
width: 15em;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
margin: 0 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
|
||||
video {
|
||||
transition: 1s linear opacity;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
transition: .5s ease opacity;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-bottom: 5em;
|
||||
|
||||
p {
|
||||
transition: .5s ease opacity;
|
||||
font-size: 2em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: fit-content;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 2em;
|
||||
|
||||
.qr {
|
||||
display: block;
|
||||
margin-left: 2em;
|
||||
width: 7em;
|
||||
height: 7em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subtitles {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
|
||||
.lang {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.scrolling-textbox {
|
||||
$line-height: 2rem;
|
||||
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
padding-top: $line-height;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
width: 100%;
|
||||
|
||||
.paragraph {
|
||||
transition: .5s ease color, .5s ease transform, .5s ease opacity;
|
||||
display: flex;
|
||||
font-size: $line-height;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
transform: translateY(0);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
.word {
|
||||
transition: .2s ease opacity, .2s ease transform;
|
||||
margin: 0;
|
||||
margin-right: .5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.previous {
|
||||
position: absolute;
|
||||
transition: .5s ease transform, .5s ease opacity;
|
||||
color: grey;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.scrolling-textbox::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { uint_bytes_to_num, utf8_decode } from "./tools";
|
||||
import { uint_bytes_to_num, utf8_decode, utf8_encode } from "./tools";
|
||||
|
||||
test("parse_uint - 0xFF", () => {
|
||||
expect(uint_bytes_to_num(new Uint8Array([0x0, 0x0, 0x0, 0xFF]))).toBe(0xFF);
|
||||
@@ -20,3 +20,12 @@ test("parse_uint - 0xFF000000", () => {
|
||||
test("decode_utf8 - \"test\"", () => {
|
||||
expect(utf8_decode(new Uint8Array([0x74, 0x65, 0x73, 0x74]))).toBe("test");
|
||||
});
|
||||
|
||||
test("encode_utf8 - \"test\"", () => {
|
||||
expect(utf8_encode("test")).toMatchObject(new Uint8Array([0x74, 0x65, 0x73, 0x74]));
|
||||
});
|
||||
|
||||
test("encode_decode_utf8 - \"Hello, world!\"", () => {
|
||||
const text = "Hello, world!";
|
||||
expect(utf8_decode(utf8_encode(text))).toBe(text);
|
||||
});
|
||||
|
@@ -7,7 +7,88 @@ export function uint_bytes_to_num(value: Uint8Array): number {
|
||||
return data_view.getUint32(0, false);
|
||||
}
|
||||
|
||||
export function utf8_encode(value: string): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(value);
|
||||
}
|
||||
|
||||
export function utf8_decode(value: Uint8Array): string {
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
return decoder.decode(value);
|
||||
}
|
||||
|
||||
export async function sleep(millis: number): Promise<void> {
|
||||
await new Promise<void>((resolver) => {
|
||||
setTimeout(resolver, millis);
|
||||
});
|
||||
}
|
||||
|
||||
export namespace el {
|
||||
function add_classes(target: HTMLElement, classes?: string[]): void {
|
||||
if (classes === undefined) { return; }
|
||||
for (const cls of classes) {
|
||||
target.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
export function div(children: HTMLElement[], classes?: string[]): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
add_classes(el, classes);
|
||||
el.append(...children);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function h1(text: string, classes?: string[]): HTMLHeadingElement {
|
||||
const el = document.createElement("h1");
|
||||
el.innerText = text;
|
||||
add_classes(el, classes);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function h2(text: string, classes?: string[]): HTMLHeadingElement {
|
||||
const el = document.createElement("h2");
|
||||
el.innerText = text;
|
||||
add_classes(el, classes);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function p(text: string, classes?: string[]): HTMLParagraphElement {
|
||||
const el = document.createElement("p");
|
||||
el.innerText = text;
|
||||
add_classes(el, classes);
|
||||
return el;
|
||||
}
|
||||
|
||||
export async function img(src: string, classes?: string[]): Promise<HTMLImageElement> {
|
||||
const el = new Image();
|
||||
el.src = src;
|
||||
el.draggable = false;
|
||||
add_classes(el, classes);
|
||||
await new Promise<void>((resolver) => {
|
||||
el.addEventListener("load", () => { resolver(); });
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
export async function video(
|
||||
src: string,
|
||||
subtitles_url: string | null,
|
||||
classes?: string[],
|
||||
): Promise<HTMLVideoElement> {
|
||||
const blob = await (await fetch(src)).blob();
|
||||
const el = document.createElement("video");
|
||||
el.autoplay = true;
|
||||
const source = document.createElement("source");
|
||||
source.src = URL.createObjectURL(blob);
|
||||
el.appendChild(source);
|
||||
if (subtitles_url !== null) {
|
||||
const subs = document.createElement("track");
|
||||
subs.src = subtitles_url;
|
||||
subs.kind = "subtitles";
|
||||
subs.default = true;
|
||||
el.appendChild(subs);
|
||||
}
|
||||
add_classes(el, classes);
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
30
main.py
30
main.py
@@ -1,5 +1,5 @@
|
||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
||||
from fastapi import FastAPI, File, Request, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
import logging
|
||||
import uvicorn
|
||||
from typing import Dict, List, Any
|
||||
@@ -55,7 +55,7 @@ class ConnectionManager:
|
||||
|
||||
def json_to_binary(self, json_str: str):
|
||||
json_bytes = json_str.encode('utf-8')
|
||||
json_length = len(json_bytes)
|
||||
json_length = len(json_bytes) & 0xFFFFFFFF
|
||||
|
||||
# 4-byte unsigned integer (uint32)
|
||||
length_bytes = struct.pack('!I', json_length)
|
||||
@@ -100,26 +100,24 @@ class ConnectionManager:
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Static files
|
||||
def read_file(filepath: str) -> str:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
@dataclass
|
||||
class StaticFiles:
|
||||
index_html: str = read_file("static/index.html")
|
||||
script_js: str = read_file("static/script.js")
|
||||
|
||||
@app.get("/presentation/")
|
||||
async def presentation_index(_: Request):
|
||||
return HTMLResponse(status_code=200, content=StaticFiles.index_html, media_type="text/html")
|
||||
return FileResponse(status_code=200, path="static/index.html", media_type="text/html")
|
||||
|
||||
@app.get("/presentation/script.js")
|
||||
async def presentation_script(_: Request):
|
||||
return HTMLResponse(
|
||||
status_code=200, content=StaticFiles.script_js, media_type="text/javascript"
|
||||
return FileResponse(
|
||||
status_code=200, path="static/script.js", media_type="text/javascript"
|
||||
)
|
||||
|
||||
@app.get("/presentation/style.css")
|
||||
async def presentation_style(_: Request):
|
||||
return FileResponse(status_code=200, path="static/style.css", media_type="text/css")
|
||||
|
||||
@app.get("/presentation/files/{file_path:path}")
|
||||
async def presentation_file(file_path: str):
|
||||
return FileResponse(status_code=200, path=f"static/{file_path}")
|
||||
|
||||
# Endpoints
|
||||
@app.post("/control")
|
||||
async def control_endpoint(request: Request):
|
||||
|
Reference in New Issue
Block a user