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>
136 lines
5.0 KiB
TypeScript
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();
|