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>
207 lines
6.6 KiB
TypeScript
207 lines
6.6 KiB
TypeScript
// 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;
|
||
}
|
||
}
|