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

View File

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

View File

@@ -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
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";
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> {
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();
}
}
}
}

View File

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

View File

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