Files
arr-tui/webos/src/main.ts
T
nilsandClaude Opus 5.5 7f6d4d07b9 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>
2026-10-25 16:51:48 +01:00

136 lines
5.0 KiB
TypeScript

// arr-tui for webOS: the shell with the views, the status of the services,
// the remote's hints and its buttons.
import { app } from "./app.ts";
import { applyTheme } from "./theme.ts";
import { h, replace } from "./ui/dom.ts";
import { current, focus, focusFirst, initFocus, Keys } from "./ui/focus.ts";
import { button, confirmDialog, toast } from "./ui/widgets.ts";
import { CalendarView } from "./views/calendar.ts";
import { DownloadsView, watchDownloads } from "./views/downloads.ts";
import { LibraryView } from "./views/library.ts";
import { LogView } from "./views/log.ts";
import { SearchView } from "./views/search.ts";
import { SettingsView } from "./views/settings.ts";
import type { Hint, View } from "./views/view.ts";
interface Entry {
name: string;
view: View;
tab: HTMLButtonElement;
}
function start(): void {
applyTheme(app.cfg.ui.theme);
const content = h("main.content");
const hints = h("div.footer-hints");
const dots = h("div.status-dots");
const tabs = h("nav.tabs.fgroup");
const entries: Entry[] = [];
let cur: Entry | null = null;
let switchTimer = 0;
const show = (e: Entry, focusView: boolean) => {
if (cur === e) {
if (focusView) focusFirst(e.view.el);
return;
}
cur?.view.hide();
cur?.tab.classList.remove("on");
cur = e;
e.tab.classList.add("on");
replace(content, e.view.el);
renderHints(e.view.hints);
e.view.show();
if (!focusView && current() !== e.tab) focus(e.tab);
};
const views: [string, View][] = [
["Movies", new SearchView("movie")],
["Series", new SearchView("series")],
["Library", new LibraryView()],
["Downloads", new DownloadsView()],
["Calendar", new CalendarView()],
["Settings", new SettingsView()],
["Log", new LogView()],
];
views.forEach(([name, view], i) => {
const tab = button(`${i + 1} ${name}`, () => show(entry, true), "tab");
const entry: Entry = { name, view, tab };
// Moving along the tabs switches views once the focus rests there.
tab.addEventListener("focus", () => {
clearTimeout(switchTimer);
switchTimer = window.setTimeout(() => {
if (document.activeElement === tab) show(entry, false);
}, 250);
});
entries.push(entry);
tabs.appendChild(tab);
});
function renderHints(list: Hint[]): void {
replace(hints, ...list.map(([b, text]) => h("span.hint", h(`span.key.${b}` as "span", b === "ok" ? "OK" : b === "back" ? "Back" : ""), text)),
h("span.hint", h("span.key.back", "Back"), "back"));
}
function renderDots(): void {
replace(dots, ...(["Radarr", "Sonarr", "SABnzbd", "Trakt"] as const).map((name) => {
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured()
: name === "SABnzbd" ? app.sab.configured() : app.trakt.configured();
const err = app.status[name];
return h(`span.status-dot.${!configured ? "off" : err ? "bad" : "ok"}` as "span", { title: err || name }, name);
}));
}
app.onStatus.push(renderDots);
app.onConfig.push(renderDots);
renderDots();
const root = h("div.app",
h("header.topbar", h("div.brand", "arr-tui"), tabs, dots),
content,
h("footer.footer", hints),
);
document.getElementById("root")!.appendChild(root);
// Back: from a view to its tab; on the tabs, ask before leaving the app.
const back = () => {
const el = current();
if (cur && el && !tabs.contains(el)) {
focus(cur.tab);
return;
}
confirmDialog("Leave arr-tui?", "Leave", () => window.close());
};
const keys = (ev: KeyboardEvent): boolean => {
// 1-7 open a view.
const n = ev.keyCode >= 49 && ev.keyCode <= 57 ? ev.keyCode - 49 : ev.keyCode >= 97 && ev.keyCode <= 105 ? ev.keyCode - 97 : -1;
if (n >= 0 && n < entries.length) {
show(entries[n], true);
return true;
}
// Desktop browsers: F1-F4 stand in for the colour buttons.
const code = ({ 112: Keys.red, 113: Keys.green, 114: Keys.yellow, 115: Keys.blue } as Record<number, number>)[ev.keyCode] ?? ev.keyCode;
return !!cur?.view.keys?.(code);
};
initFocus(root, back, keys);
const first = app.radarr.configured() || app.sonarr.configured() || app.sab.configured() ? 0 : 5; // nothing set up: Settings
show(entries[first], false);
watchDownloads();
if (first === 5) toast("Nothing is configured yet: enter the addresses and API keys of your services.", "warn");
// Check the services once at start, for the status dots.
for (const [name, check] of [
["Radarr", () => app.radarr.systemStatus()],
["Sonarr", () => app.sonarr.systemStatus()],
["SABnzbd", () => app.sab.version()],
] as const) {
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured() : app.sab.configured();
if (configured) check().then(() => app.setStatus(name, null), (e) => app.setStatus(name, e));
}
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();