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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user