webOS: a TV app with the views of arr-tui

A TypeScript web app for LG webOS TVs (webOS 5 / Chromium 68 and newer),
driven by the remote: Movies and Series search with the Trakt lists,
Library with status, title and file-property filters and download marks,
seasons and episodes, release search and grab, Downloads, Calendar,
Settings and Log. It talks to Radarr, Sonarr, SABnzbd and Trakt directly
(they answer cross-origin requests); the arr-tui config is baked in at
build time and can be changed on the TV.

dev/screenshots/server.py answers cross-origin requests and accepts writes
for it; webos/dev/drive.mjs drives the app in Chromium for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
// Dialogs, action menus, option pickers, buttons and toasts.
import { append, h, replace } from "./dom.ts";
import { focus, focusFirst, Keys, popLayer, pushLayer, type KeyHandling } from "./focus.ts";
export function button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
return h(`button.btn.f${cls ? "." + cls : ""}` as "button", { onclick: onClick, tabindex: -1 }, label);
}
// Dialog: a centred box over everything; Back closes it.
export class Dialog {
el: HTMLElement;
box: HTMLElement;
private onClose?: () => void;
private closed = false;
constructor(title: string, cls = "", onClose?: () => void) {
this.onClose = onClose;
this.box = h("div.dialog-box", h("h2", title));
if (cls) this.box.classList.add(cls);
this.el = h("div.dialog", this.box);
}
add(...children: (Node | string | null)[]): this {
append(this.box, ...children);
return this;
}
// open shows the dialog; called again (content changed), it only moves
// the focus.
open(focusEl?: HTMLElement): this {
if (this.closed) return this;
if (!this.el.isConnected) {
document.getElementById("overlays")!.appendChild(this.el);
pushLayer(this.el, () => this.close());
}
if (focusEl) focus(focusEl);
else focusFirst(this.el);
return this;
}
close(): void {
if (this.closed) return;
this.closed = true;
popLayer(this.el);
this.el.remove();
this.onClose?.();
}
}
export interface Action {
label: string;
run: () => void;
danger?: boolean;
hint?: string;
}
// actionSheet lists what can be done with an item; OK runs one.
export function actionSheet(title: string, actions: Action[]): void {
const d = new Dialog(title, "sheet");
for (const a of actions) {
const b = button(a.label, () => {
d.close();
a.run();
}, a.danger ? "danger" : "");
if (a.hint) b.appendChild(h("span.btn-hint", a.hint));
d.add(b);
}
d.add(button("Cancel", () => d.close(), "ghost"));
d.open();
}
export function confirmDialog(message: string, yes: string, run: () => void, danger = false): void {
const d = new Dialog("Are you sure?");
d.add(h("p.dialog-text", message));
const no = button("Cancel", () => d.close(), "ghost");
d.add(h("div.row-buttons", button(yes, () => {
d.close();
run();
}, danger ? "danger" : ""), no));
d.open(no);
}
// Choice: a value picked from options with left / right (or OK for the
// next one), the TV-friendly dropdown.
export class Choice<V> {
el: HTMLElement & KeyHandling;
private valueEl: HTMLElement;
options: { label: string; value: V }[] = [];
index = 0;
onChange?: (v: V) => void;
constructor(label: string, options: { label: string; value: V }[], value?: V, onChange?: (v: V) => void) {
this.valueEl = h("span.choice-value");
this.el = h("div.choice.f", { tabindex: -1 }, h("span.choice-label", label), h("span.choice-arrows", "‹", this.valueEl, "›")) as HTMLElement & KeyHandling;
this.onChange = onChange;
this.setOptions(options, value);
this.el.onKey = (ev) => {
if (ev.keyCode !== Keys.left && ev.keyCode !== Keys.right) return false;
this.step(ev.keyCode === Keys.left ? -1 : 1);
return true;
};
this.el.addEventListener("click", (ev) => {
const r = this.el.getBoundingClientRect();
const x = (ev as MouseEvent).clientX;
// A pointer click on the left third goes back; OK and other clicks forward.
this.step(x > 0 && x < r.left + r.width / 3 ? -1 : 1);
});
}
get value(): V {
return this.options[this.index]?.value;
}
setOptions(options: { label: string; value: V }[], value?: V): void {
this.options = options;
const i = value === undefined ? -1 : options.findIndex((o) => o.value === value);
this.index = i >= 0 ? i : Math.min(this.index, Math.max(0, options.length - 1));
this.show();
}
private step(d: number): void {
if (this.options.length < 2) return;
this.index = (this.index + d + this.options.length) % this.options.length;
this.show();
this.onChange?.(this.value);
}
private show(): void {
replace(this.valueEl, this.options[this.index]?.label ?? "–");
}
}
// Toggle: a checkbox; OK flips it.
export class Toggle {
el: HTMLElement;
value: boolean;
onChange?: (v: boolean) => void;
constructor(label: string, value: boolean, onChange?: (v: boolean) => void) {
this.value = value;
this.onChange = onChange;
this.el = h("div.toggle.f", { tabindex: -1, onclick: () => this.set(!this.value, true) }, h("span.toggle-box"), h("span", label));
this.set(value, false);
}
set(v: boolean, notify: boolean): void {
this.value = v;
this.el.classList.toggle("on", v);
if (notify) this.onChange?.(v);
}
}
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" });
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
return { el: h("label.field", h("span.field-label", label), input), input };
}
// --- Toasts and the log ---
export type Level = "info" | "ok" | "warn" | "error";
export interface LogEntry {
at: Date;
level: Level;
text: string;
}
export const log: LogEntry[] = [];
const logListeners: (() => void)[] = [];
export function onLog(f: () => void): void {
logListeners.push(f);
}
// toast shows a message for a few seconds and keeps it in the log.
export function toast(text: string, level: Level = "info"): void {
log.push({ at: new Date(), level, text });
if (log.length > 500) log.splice(0, log.length - 500);
for (const f of logListeners) f();
const box = document.getElementById("toasts");
if (!box) return;
const el = h(`div.toast.toast-${level}` as "div", text);
box.appendChild(el);
while (box.children.length > 4) box.firstElementChild!.remove();
setTimeout(() => el.classList.add("gone"), level === "error" ? 7000 : 4000);
setTimeout(() => el.remove(), level === "error" ? 7600 : 4600);
}
export function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// run executes an action with a "…" toast and a result toast.
export async function run(doing: string, done: string, f: () => Promise<unknown>): Promise<boolean> {
toast(doing + "…");
try {
await f();
toast(done, "ok");
return true;
} catch (e) {
toast(`${doing} failed: ${errorText(e)}`, "error");
return false;
}
}