diff --git a/assets/fm_logo.svg b/assets/fm_logo.svg new file mode 100644 index 0000000..c1cc3b1 --- /dev/null +++ b/assets/fm_logo.svg @@ -0,0 +1 @@ + diff --git a/assets/qr_ask.svg b/assets/qr_ask.svg new file mode 100644 index 0000000..e52f93d --- /dev/null +++ b/assets/qr_ask.svg @@ -0,0 +1 @@ + diff --git a/client/index.html b/client/index.html index 1609fe9..5961013 100644 --- a/client/index.html +++ b/client/index.html @@ -4,9 +4,10 @@ - + + diff --git a/client/make.mk b/client/make.mk index 04dd216..6f5213b 100644 --- a/client/make.mk +++ b/client/make.mk @@ -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) diff --git a/client/presentationmgr.ts b/client/presentationmgr.ts new file mode 100644 index 0000000..b3bf740 --- /dev/null +++ b/client/presentationmgr.ts @@ -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; + start(): Promise; + end(): Promise; + serve(trigger: PythagorasIncomingMessage): Promise; + 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 { + 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 { + 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 { + this.dom_question_link.append(await el.img(QUESTION_QR, ["qr"])); + } + + public async start(): Promise { + await sleep(10); + this.dom_root.style.opacity = "1"; + await sleep(500); + } + + public async end(): Promise { + } + + private async show_question(text: string): Promise { + this.question_insert.innerText = text; + this.question_insert.style.opacity = "1"; + await sleep(500); + } + + private async hide_question(): Promise { + this.question_insert.style.opacity = "0"; + await sleep(500); + } + + public async serve(trigger: PythagorasIncomingMessage): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 {} + + 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; + } +} diff --git a/client/pythagoras_client.ts b/client/pythagoras_client.ts index e565ca7..ba0ef42 100644 --- a/client/pythagoras_client.ts +++ b/client/pythagoras_client.ts @@ -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 { 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 { + 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 { 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 { - const advertised_length = uint_bytes_to_num(await this.recv_length(4)); - const payload = utf8_decode(await this.recv_length(advertised_length)); - return JSON.parse(payload); + 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)); + const parsed = JSON.parse(payload); + console.log(parsed); + return parsed; + } catch { + this.buf = new Uint8Array(0); + await this.reconnect(); + } + } } } diff --git a/client/script.ts b/client/script.ts index 40a835d..2cd622d 100644 --- a/client/script.ts +++ b/client/script.ts @@ -1,8 +1,14 @@ +import { PresentationManager } from "./presentationmgr"; import { PythagorasClient } from "./pythagoras_client"; async function main(): Promise { - 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); } -main(); +(document.querySelector("#run")).onclick = () => { + main(); +} diff --git a/client/scrolling_textbox.ts b/client/scrolling_textbox.ts new file mode 100644 index 0000000..9c23467 --- /dev/null +++ b/client/scrolling_textbox.ts @@ -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 { + 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); + } + } + + 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; + } +} diff --git a/client/settings.ts b/client/settings.ts new file mode 100644 index 0000000..e68f08f --- /dev/null +++ b/client/settings.ts @@ -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; diff --git a/client/style.scss b/client/style.scss index e69de29..1fc8fc1 100644 --- a/client/style.scss +++ b/client/style.scss @@ -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; +} diff --git a/client/tools.test.ts b/client/tools.test.ts index dd004e2..dc45d3a 100644 --- a/client/tools.test.ts +++ b/client/tools.test.ts @@ -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); +}); diff --git a/client/tools.ts b/client/tools.ts index 9c40ea6..7cf4bb6 100644 --- a/client/tools.ts +++ b/client/tools.ts @@ -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 { + await new Promise((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 { + const el = new Image(); + el.src = src; + el.draggable = false; + add_classes(el, classes); + await new Promise((resolver) => { + el.addEventListener("load", () => { resolver(); }); + }); + return el; + } + + export async function video( + src: string, + subtitles_url: string | null, + classes?: string[], + ): Promise { + 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; + } +} diff --git a/main.py b/main.py index 4bd981e..6c19e4c 100644 --- a/main.py +++ b/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):