add half-working client

This commit is contained in:
2025-05-05 00:15:12 +02:00
parent 776ee8d637
commit bfa0ba8edf
13 changed files with 782 additions and 38 deletions

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -4,9 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title></title> <title></title>
<link href="css/style.css" rel="stylesheet"> <link href="style.css" rel="stylesheet">
</head> </head>
<body> <body>
<button id="run">Run</button>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,9 +1,11 @@
CLIENT_PAGES := index.html CLIENT_PAGES := index.html
CLIENT_STYLES := style.css CLIENT_STYLES := style.css
CLIENT_SCRIPTS := script.js CLIENT_SCRIPTS := script.js
CLIENT_ASSETS := fm_logo.svg qr_ask.svg
CLIENT_TARGETS := $(CLIENT_PAGES:%=static/%) \ CLIENT_TARGETS := $(CLIENT_PAGES:%=static/%) \
$(CLIENT_STYLES:%=static/%) \ $(CLIENT_STYLES:%=static/%) \
$(CLIENT_SCRIPTS:%=static/%) $(CLIENT_SCRIPTS:%=static/%) \
$(CLIENT_ASSETS:%=static/%)
.PHONY: client_clean .PHONY: client_clean
client_clean: client_clean:
@@ -23,6 +25,9 @@ static/script.js: \
client/ws.ts \ client/ws.ts \
client/pythagoras_client.ts \ client/pythagoras_client.ts \
client/tools.ts \ client/tools.ts \
client/presentationmgr.ts \
client/settings.ts \
client/scrolling_textbox.ts \
client/node_modules client/node_modules
@mkdir -p $(@D) @mkdir -p $(@D)
bun build $< --minify --outfile $@ bun build $< --minify --outfile $@
@@ -44,6 +49,11 @@ static/%.css: client/%.scss client/node_modules
@mkdir -p $(@D) @mkdir -p $(@D)
bun run --cwd client sass $(notdir $<) --style compressed > $@ bun run --cwd client sass $(notdir $<) --style compressed > $@
# generic svgs
static/%.svg: assets/%.svg
@mkdir -p $(@D)
ln -f $< $@
# generic scripts # generic scripts
static/%.js: client/%.ts client/node_modules static/%.js: client/%.ts client/node_modules
@mkdir -p $(@D) @mkdir -p $(@D)

282
client/presentationmgr.ts Normal file
View 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;
}
}

View File

@@ -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"; import { WSClient } from "./ws";
enum PythagorasIncomingMessageType { export enum PythagorasIncomingMessageType {
SubUpdateCur,
SubFinishCur, // subtitles
SelectedMessage, 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, text: string,
} | } |
{ {
type: PythagorasIncomingMessageType.SelectedMessage, 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 buf: Uint8Array;
private static max_recv_retry: number = 10;
public constructor(addr: string) { public constructor(addr: string) {
this.sock = null; this.sock = null;
this.addr = addr; this.addr = addr;
@@ -46,12 +77,22 @@ export class PythagorasClient {
*/ */
private async recv_inner(): Promise<Blob> { private async recv_inner(): Promise<Blob> {
if (this.sock === null) { await this.reconnect(); } 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(); const received = await this.sock!.receive();
if (received !== null) { return received; } if (received !== null) { return received; }
await sleep(500);
await this.reconnect(); 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> { private async recv_length(target: number): Promise<Uint8Array> {
if (target == 0) { return new Uint8Array(0); } if (target == 0) { return new Uint8Array(0); }
while (this.buf.length < target) { 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); const merged = new Uint8Array(this.buf.length + received.length);
merged.set(this.buf); merged.set(this.buf);
merged.set(received, this.buf.length); merged.set(received, this.buf.length);
@@ -73,8 +114,17 @@ export class PythagorasClient {
} }
public async recv(): Promise<PythagorasIncomingMessage> { public async recv(): Promise<PythagorasIncomingMessage> {
const advertised_length = uint_bytes_to_num(await this.recv_length(4)); while (true) {
const payload = utf8_decode(await this.recv_length(advertised_length)); const advertised_length = uint_bytes_to_num(await this.recv_length(4));
return JSON.parse(payload); try {
const payload = utf8_decode(await this.recv_length(advertised_length));
const parsed = JSON.parse(payload);
console.log(parsed);
return parsed;
} catch {
this.buf = new Uint8Array(0);
await this.reconnect();
}
}
} }
} }

View File

@@ -1,8 +1,14 @@
import { PresentationManager } from "./presentationmgr";
import { PythagorasClient } from "./pythagoras_client"; import { PythagorasClient } from "./pythagoras_client";
async function main(): Promise<void> { async function main(): Promise<void> {
const conn = new PythagorasClient("/ws"); const ws_client = new PythagorasClient("/ws");
console.log(await conn.recv()); document.body.innerHTML = "";
const presentation_manager = new PresentationManager();
document.body.appendChild(presentation_manager.dom);
await presentation_manager.serve(ws_client);
} }
main(); (<HTMLButtonElement>document.querySelector("#run")).onclick = () => {
main();
}

141
client/scrolling_textbox.ts Normal file
View 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
View 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;

View File

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

View File

@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"; 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", () => { test("parse_uint - 0xFF", () => {
expect(uint_bytes_to_num(new Uint8Array([0x0, 0x0, 0x0, 0xFF]))).toBe(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\"", () => { test("decode_utf8 - \"test\"", () => {
expect(utf8_decode(new Uint8Array([0x74, 0x65, 0x73, 0x74]))).toBe("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);
});

View File

@@ -7,7 +7,88 @@ export function uint_bytes_to_num(value: Uint8Array): number {
return data_view.getUint32(0, false); 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 { export function utf8_decode(value: Uint8Array): string {
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
return decoder.decode(value); 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
View File

@@ -1,5 +1,5 @@
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException from fastapi import FastAPI, File, Request, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
import logging import logging
import uvicorn import uvicorn
from typing import Dict, List, Any from typing import Dict, List, Any
@@ -55,7 +55,7 @@ class ConnectionManager:
def json_to_binary(self, json_str: str): def json_to_binary(self, json_str: str):
json_bytes = json_str.encode('utf-8') json_bytes = json_str.encode('utf-8')
json_length = len(json_bytes) json_length = len(json_bytes) & 0xFFFFFFFF
# 4-byte unsigned integer (uint32) # 4-byte unsigned integer (uint32)
length_bytes = struct.pack('!I', json_length) length_bytes = struct.pack('!I', json_length)
@@ -100,26 +100,24 @@ class ConnectionManager:
manager = 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/") @app.get("/presentation/")
async def presentation_index(_: Request): 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") @app.get("/presentation/script.js")
async def presentation_script(_: Request): async def presentation_script(_: Request):
return HTMLResponse( return FileResponse(
status_code=200, content=StaticFiles.script_js, media_type="text/javascript" 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 # Endpoints
@app.post("/control") @app.post("/control")
async def control_endpoint(request: Request): async def control_endpoint(request: Request):