Redesign Media Centre as a teletext broadcast information service
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.
Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).
- New templates for all nine pages (home/watch/library/discover/
requests/system/activity/login/settings) behind a shared _shell.html
carrying the direction contract and _macros.html for the row/bar/tile
vocabulary; deletes the old single-page dashboard.html and its
dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
type scale (Chivo Mono / DotGothic16), and the five disciplines the
system is held to (no in-between tone, no enclosure, one shared bar
ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
url_for('dashboard'), an endpoint removed earlier in the branch, which
would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
count below the 760px breakpoint, so a fill could land mid-cell on a
phone. Removed the mobile override; the ruler is now 20 cells at every
width, matching the macro unconditionally.
Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
This commit is contained in:
parent
9a277b9031
commit
4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions
|
|
@ -1,406 +0,0 @@
|
|||
/* Server-rendered config, read off <body data-*> (see dashboard.html).
|
||||
URL_BASE carries the reverse-proxy prefix so /api/* fetches still
|
||||
resolve when the app is mounted on a subpath via SCRIPT_NAME. */
|
||||
const URL_BASE = document.body.dataset.urlBase || "";
|
||||
const TIMEZONE = document.body.dataset.timezone || "UTC";
|
||||
|
||||
let sonarrSearchResult = null;
|
||||
let radarrSearchResult = null;
|
||||
|
||||
/* ── Clock ── */
|
||||
function updateClock() {
|
||||
const el = document.getElementById('header-clock');
|
||||
if (!el) return;
|
||||
try {
|
||||
el.textContent = new Date().toLocaleTimeString('en-US', {
|
||||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
el.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
/* ── Discover: show more / show less ── */
|
||||
function toggleMore(btn, listId) {
|
||||
const list = document.getElementById(listId);
|
||||
if (!list) return;
|
||||
const items = Array.from(list.children);
|
||||
const hidden = items.filter(el => el.classList.contains('is-hidden'));
|
||||
if (hidden.length) {
|
||||
hidden.forEach(el => el.classList.remove('is-hidden'));
|
||||
btn.textContent = 'Show less';
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
items.forEach((el, i) => {
|
||||
if (i >= 5) el.classList.add('is-hidden');
|
||||
});
|
||||
const n = items.length - 5;
|
||||
btn.textContent = 'Show ' + n + ' more';
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Add flow: first tap arms, second tap commits ──
|
||||
Adding queues a real download, so a single stray tap shouldn't do it. */
|
||||
let armedBtn = null;
|
||||
let armedTimer = null;
|
||||
|
||||
function disarm() {
|
||||
if (armedBtn) {
|
||||
armedBtn.classList.remove('armed');
|
||||
armedBtn.textContent = '+ Add';
|
||||
armedBtn = null;
|
||||
}
|
||||
clearTimeout(armedTimer);
|
||||
}
|
||||
|
||||
function armAdd(btn) {
|
||||
if (armedBtn === btn) { disarm(); return doAdd(btn); }
|
||||
disarm();
|
||||
armedBtn = btn;
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'Confirm?';
|
||||
armedTimer = setTimeout(disarm, 4000);
|
||||
}
|
||||
|
||||
function removeRtItem(btn) {
|
||||
const item = btn.closest('.rt-item');
|
||||
if (!item) return;
|
||||
item.classList.add('removing');
|
||||
setTimeout(() => {
|
||||
const list = item.parentElement;
|
||||
item.remove();
|
||||
if (list) {
|
||||
let n = 0;
|
||||
list.querySelectorAll('.rt-item').forEach(el => {
|
||||
el.querySelector('.rt-rank').textContent = ++n;
|
||||
});
|
||||
}
|
||||
}, 320);
|
||||
}
|
||||
|
||||
async function doAdd(btn) {
|
||||
const title = btn.dataset.title;
|
||||
const target = btn.dataset.target; // 'sonarr' | 'radarr'
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Adding…';
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/add-to-' + target, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if ((resp.ok && data.success) || resp.status === 409) {
|
||||
removeRtItem(btn);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '+ Add';
|
||||
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '+ Add';
|
||||
alert('Network error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// A tap anywhere else cancels a pending confirm.
|
||||
document.addEventListener('click', e => {
|
||||
if (armedBtn && !e.target.closest('.btn-add')) disarm();
|
||||
});
|
||||
|
||||
/* ── SABnzbd live polling (never cached server-side) ── */
|
||||
const sabSpeed = document.getElementById('sab-speed');
|
||||
const sabQueue = document.getElementById('sab-queue');
|
||||
const sabRemaining = document.getElementById('sab-remaining');
|
||||
const sabBadge = document.getElementById('sab-badge');
|
||||
const sabTimeleft = document.getElementById('sab-timeleft');
|
||||
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
|
||||
|
||||
async function pollSabnzbd() {
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
if (d.status !== 'ok') return;
|
||||
if (sabSpeed) sabSpeed.textContent = d.speed;
|
||||
if (sabQueue) sabQueue.textContent = d.queue_count;
|
||||
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
|
||||
if (sabBadge) {
|
||||
const s = (d.sab_status || '').toLowerCase();
|
||||
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
|
||||
sabBadge.textContent = d.sab_status;
|
||||
}
|
||||
const active = (d.sab_status || '').toLowerCase() === 'downloading'
|
||||
&& d.timeleft && d.timeleft !== '0:00:00';
|
||||
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
|
||||
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
|
||||
const pill = document.getElementById('svc-stat-sabnzbd');
|
||||
if (pill) pill.textContent = d.speed;
|
||||
} catch (e) { /* transient network error — try again next tick */ }
|
||||
}
|
||||
pollSabnzbd();
|
||||
setInterval(pollSabnzbd, 3000);
|
||||
|
||||
/* ── RT descriptions arrive asynchronously from IMDB ── */
|
||||
async function loadRtDescriptions() {
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
for (const [title, desc] of Object.entries(d.descriptions || {})) {
|
||||
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
|
||||
if (!item || !desc) continue;
|
||||
const el = item.querySelector('.rt-desc');
|
||||
if (!el || el.textContent.trim()) continue; // already filled
|
||||
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
|
||||
}
|
||||
if (d.pending) setTimeout(loadRtDescriptions, 4000);
|
||||
} catch (e) { /* silently skip */ }
|
||||
}
|
||||
setTimeout(loadRtDescriptions, 1500);
|
||||
|
||||
/* ── Prowlarr indexer polling ── */
|
||||
function updateServicePill(name, isUp) {
|
||||
const pill = document.getElementById('svc-' + name);
|
||||
const light = document.getElementById('svc-light-' + name);
|
||||
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
|
||||
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
|
||||
}
|
||||
|
||||
async function pollProwlarr() {
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
if (d.status !== 'ok') return;
|
||||
const upEl = document.getElementById('prowlarr-up-count');
|
||||
const totEl = document.getElementById('prowlarr-total');
|
||||
if (upEl) upEl.textContent = d.up_count;
|
||||
if (totEl) totEl.textContent = d.total;
|
||||
d.indexers.forEach(idx => {
|
||||
const row = document.getElementById('idx-' + idx.id);
|
||||
const light = document.getElementById('idx-light-' + idx.id);
|
||||
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
|
||||
if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); }
|
||||
const badge = document.getElementById('idx-badge-' + idx.id);
|
||||
if (badge) {
|
||||
badge.classList.toggle('online', idx.up);
|
||||
badge.classList.toggle('offline', !idx.up);
|
||||
badge.textContent = idx.up ? 'Online' : 'Offline';
|
||||
}
|
||||
});
|
||||
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
|
||||
const stat = document.getElementById('svc-stat-prowlarr');
|
||||
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
|
||||
} catch (e) { /* silently skip */ }
|
||||
}
|
||||
pollProwlarr();
|
||||
setInterval(pollProwlarr, 30000);
|
||||
|
||||
/* ── Header service lights ── */
|
||||
async function pollServices() {
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp);
|
||||
} catch (e) { /* silently skip */ }
|
||||
}
|
||||
pollServices();
|
||||
setInterval(pollServices, 60000);
|
||||
|
||||
/* ── Tautulli live streams ── */
|
||||
async function pollTautulli() {
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
if (d.status !== 'ok') return;
|
||||
|
||||
const count = d.stream_count;
|
||||
const sessions = d.sessions || [];
|
||||
|
||||
const stat = document.getElementById('svc-stat-tautulli');
|
||||
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
|
||||
|
||||
const countEl = document.getElementById('tautulli-count');
|
||||
if (countEl) countEl.textContent = count + ' active';
|
||||
|
||||
const body = document.getElementById('now-playing');
|
||||
if (!body) return;
|
||||
if (sessions.length) {
|
||||
body.innerHTML = sessions.map(s => {
|
||||
const icon = s.media_type === 'movie' ? '🎬' : '📺';
|
||||
const esc = t => String(t).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
return `<div class="stream">
|
||||
<span class="live-dot"></span>
|
||||
<span class="stream-icon" aria-hidden="true">${icon}</span>
|
||||
<span class="stream-info">
|
||||
<span class="stream-title">${esc(s.title)}</span>
|
||||
<span class="stream-user">${esc(s.user)}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
|
||||
}
|
||||
} catch (e) { /* silently skip */ }
|
||||
}
|
||||
pollTautulli();
|
||||
setInterval(pollTautulli, 15000);
|
||||
|
||||
/* ── Clear search results when the field is emptied ── */
|
||||
document.getElementById('sonarr-search-input').addEventListener('input', function () {
|
||||
if (!this.value.trim()) {
|
||||
document.getElementById('sonarr-search-result').classList.remove('visible');
|
||||
document.getElementById('sonarr-search-msg').style.display = 'none';
|
||||
sonarrSearchResult = null;
|
||||
}
|
||||
});
|
||||
document.getElementById('radarr-search-input').addEventListener('input', function () {
|
||||
if (!this.value.trim()) {
|
||||
document.getElementById('radarr-search-result').classList.remove('visible');
|
||||
document.getElementById('radarr-search-msg').style.display = 'none';
|
||||
radarrSearchResult = null;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Sonarr search ── */
|
||||
async function searchSonarr() {
|
||||
const input = document.getElementById('sonarr-search-input');
|
||||
const btn = document.getElementById('sonarr-search-btn');
|
||||
const resultEl = document.getElementById('sonarr-search-result');
|
||||
const msgEl = document.getElementById('sonarr-search-msg');
|
||||
const title = input.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
btn.disabled = true; btn.textContent = 'Searching…';
|
||||
resultEl.classList.remove('visible');
|
||||
msgEl.style.display = 'none';
|
||||
sonarrSearchResult = null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
|
||||
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
|
||||
msgEl.textContent = 'Session expired — please refresh the page.';
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Search'; return;
|
||||
}
|
||||
const d = await resp.json();
|
||||
if (!resp.ok || d.error) {
|
||||
msgEl.textContent = d.error || 'Search failed.';
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
} else if (!d.title) {
|
||||
msgEl.textContent = 'No results found.';
|
||||
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
|
||||
} else {
|
||||
sonarrSearchResult = d;
|
||||
document.getElementById('sonarr-result-title').textContent = d.title;
|
||||
document.getElementById('sonarr-result-meta').textContent =
|
||||
(d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
|
||||
const addBtn = document.getElementById('sonarr-add-btn');
|
||||
if (d.exists) {
|
||||
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
|
||||
} else {
|
||||
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
|
||||
}
|
||||
resultEl.classList.add('visible');
|
||||
}
|
||||
} catch (e) {
|
||||
msgEl.textContent = 'Search failed: ' + e.message;
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Search';
|
||||
}
|
||||
|
||||
async function confirmAddSonarr() {
|
||||
if (!sonarrSearchResult) return;
|
||||
const addBtn = document.getElementById('sonarr-add-btn');
|
||||
addBtn.disabled = true; addBtn.textContent = 'Adding…';
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
|
||||
});
|
||||
const d = await resp.json();
|
||||
if ((resp.ok && d.success) || resp.status === 409) {
|
||||
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
|
||||
} else {
|
||||
addBtn.disabled = false; addBtn.textContent = '+ Add';
|
||||
alert('Failed: ' + (d.error || 'Unknown error'));
|
||||
}
|
||||
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
|
||||
}
|
||||
|
||||
/* ── Radarr search ── */
|
||||
async function searchRadarr() {
|
||||
const input = document.getElementById('radarr-search-input');
|
||||
const btn = document.getElementById('radarr-search-btn');
|
||||
const resultEl = document.getElementById('radarr-search-result');
|
||||
const msgEl = document.getElementById('radarr-search-msg');
|
||||
const title = input.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
btn.disabled = true; btn.textContent = 'Searching…';
|
||||
resultEl.classList.remove('visible');
|
||||
msgEl.style.display = 'none';
|
||||
radarrSearchResult = null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
|
||||
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
|
||||
msgEl.textContent = 'Session expired — please refresh the page.';
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Search'; return;
|
||||
}
|
||||
const d = await resp.json();
|
||||
if (!resp.ok || d.error) {
|
||||
msgEl.textContent = d.error || 'Search failed.';
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
} else if (!d.title) {
|
||||
msgEl.textContent = 'No results found.';
|
||||
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
|
||||
} else {
|
||||
radarrSearchResult = d;
|
||||
document.getElementById('radarr-result-title').textContent = d.title;
|
||||
document.getElementById('radarr-result-meta').textContent =
|
||||
(d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : '');
|
||||
const addBtn = document.getElementById('radarr-add-btn');
|
||||
if (d.exists) {
|
||||
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
|
||||
} else {
|
||||
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
|
||||
}
|
||||
resultEl.classList.add('visible');
|
||||
}
|
||||
} catch (e) {
|
||||
msgEl.textContent = 'Search failed: ' + e.message;
|
||||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Search';
|
||||
}
|
||||
|
||||
async function confirmAddRadarr() {
|
||||
if (!radarrSearchResult) return;
|
||||
const addBtn = document.getElementById('radarr-add-btn');
|
||||
addBtn.disabled = true; addBtn.textContent = 'Adding…';
|
||||
try {
|
||||
const resp = await fetch(URL_BASE + '/add-to-radarr', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
|
||||
});
|
||||
const d = await resp.json();
|
||||
if ((resp.ok && d.success) || resp.status === 409) {
|
||||
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
|
||||
} else {
|
||||
addBtn.disabled = false; addBtn.textContent = '+ Add';
|
||||
alert('Failed: ' + (d.error || 'Unknown error'));
|
||||
}
|
||||
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
|
||||
}
|
||||
201
static/js/service.js
Normal file
201
static/js/service.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/* ═══════════════════════════════════════════════════════
|
||||
Page 100 — service behaviour.
|
||||
|
||||
Three things: the header clock, the page-number jump (the
|
||||
signature interaction), and the fastext colour keys. Plus
|
||||
guarded live polling for the two figures that must be
|
||||
current-second accurate.
|
||||
|
||||
Every element lookup is guarded. Nine pages share this file
|
||||
and none of them carry all of its targets.
|
||||
═══════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var body = document.body;
|
||||
var BASE = body.dataset.urlBase || '';
|
||||
var TZ = body.dataset.timezone || 'UTC';
|
||||
|
||||
/* ── Clock ────────────────────────────────────────────
|
||||
Teletext's header carried the transmission clock to the
|
||||
second. It is the one thing on the page that is always
|
||||
moving, and it is how you know the page is live. */
|
||||
var clockEl = document.getElementById('head-clock');
|
||||
function tick() {
|
||||
if (!clockEl) return;
|
||||
var now = new Date();
|
||||
var d = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
|
||||
}).format(now);
|
||||
var t = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TZ, hour: '2-digit', minute: '2-digit',
|
||||
second: '2-digit', hour12: false
|
||||
}).format(now);
|
||||
clockEl.textContent = d.toUpperCase() + ' ' + t;
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
|
||||
/* ── Page-number jump ─────────────────────────────────
|
||||
Type three digits anywhere and the service goes to that
|
||||
page, exactly as a teletext remote did. The digits fill in
|
||||
the header as you type them, so the interaction shows its
|
||||
own state instead of happening invisibly. */
|
||||
var pages = {};
|
||||
try {
|
||||
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
|
||||
pages[p[1]] = p[3]; // "100" -> "/"
|
||||
});
|
||||
} catch (e) { /* no nav data: the jump simply does nothing */ }
|
||||
|
||||
var entryEl = document.getElementById('page-entry');
|
||||
var buf = '';
|
||||
var clearTimer = null;
|
||||
|
||||
function paintEntry() {
|
||||
if (!entryEl) return;
|
||||
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
|
||||
entryEl.hidden = false;
|
||||
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
|
||||
}
|
||||
function resetEntry() {
|
||||
buf = '';
|
||||
paintEntry();
|
||||
}
|
||||
function typing(e) {
|
||||
var t = e.target;
|
||||
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' || t.isContentEditable);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
if (e.key === 'Escape') { resetEntry(); return; }
|
||||
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
buf += e.key;
|
||||
paintEntry();
|
||||
clearTimeout(clearTimer);
|
||||
if (buf.length === 3) {
|
||||
var dest = pages[buf];
|
||||
if (dest) { window.location.href = BASE + dest; }
|
||||
else {
|
||||
// No such page. Teletext just sat on the number and waited.
|
||||
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
|
||||
clearTimer = setTimeout(resetEntry, 1200);
|
||||
}
|
||||
buf = '';
|
||||
return;
|
||||
}
|
||||
// A half-typed number times out rather than lingering.
|
||||
clearTimer = setTimeout(resetEntry, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Fastext keys: r / g / y / c, the coloured buttons. */
|
||||
var k = e.key.toLowerCase();
|
||||
if ('rgyc'.indexOf(k) !== -1) {
|
||||
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
|
||||
if (link) { window.location.href = link.href; }
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Two-tap add ──────────────────────────────────────
|
||||
Adding to the library is destructive enough to want a
|
||||
confirming second press. The button states its own stage
|
||||
rather than opening a dialog. */
|
||||
var armed = null;
|
||||
function disarm() {
|
||||
if (!armed) return;
|
||||
armed.classList.remove('armed');
|
||||
armed.textContent = armed.dataset.label || '+ ADD';
|
||||
armed = null;
|
||||
}
|
||||
window.armAdd = function (btn) {
|
||||
if (btn.disabled) return;
|
||||
if (armed && armed !== btn) disarm();
|
||||
|
||||
if (armed === btn) {
|
||||
var title = btn.dataset.title;
|
||||
var target = btn.dataset.target;
|
||||
btn.disabled = true;
|
||||
btn.classList.remove('armed');
|
||||
btn.textContent = 'SENDING';
|
||||
fetch(BASE + '/add-to-' + target, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) {
|
||||
btn.classList.add('done');
|
||||
btn.textContent = 'ADDED';
|
||||
} else {
|
||||
btn.classList.add('armed');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'FAILED';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'FAILED';
|
||||
});
|
||||
armed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.label = btn.textContent.trim();
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'CONFIRM?';
|
||||
armed = btn;
|
||||
};
|
||||
document.addEventListener('click', function (e) {
|
||||
if (armed && !armed.contains(e.target)) disarm();
|
||||
});
|
||||
|
||||
/* ── Live figures ─────────────────────────────────────
|
||||
The queue and the active streams are the only two numbers
|
||||
that must be accurate to the second, so they are the only
|
||||
two that poll. Everything else is cached server-side and
|
||||
arrives with the page. */
|
||||
function setText(id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function pollSab() {
|
||||
if (!document.getElementById('sab-queue')) return;
|
||||
fetch(BASE + '/api/sabnzbd')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d || d.status !== 'ok') return;
|
||||
setText('sab-queue', d.queue_count);
|
||||
setText('sab-speed', d.speed || '0');
|
||||
setText('sab-remaining', d.sizeleft || '—');
|
||||
})
|
||||
.catch(function () { /* offline is the dev baseline, not an error */ });
|
||||
}
|
||||
|
||||
function pollTautulli() {
|
||||
if (!document.getElementById('tt-streams')) return;
|
||||
fetch(BASE + '/api/tautulli')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d || d.status !== 'ok') return;
|
||||
setText('tt-streams', d.active_streams);
|
||||
})
|
||||
.catch(function () { /* as above */ });
|
||||
}
|
||||
|
||||
if (document.getElementById('sab-queue')) {
|
||||
pollSab();
|
||||
setInterval(pollSab, 5000);
|
||||
}
|
||||
if (document.getElementById('tt-streams')) {
|
||||
pollTautulli();
|
||||
setInterval(pollTautulli, 15000);
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue