/* Mediamark SPA — keyboard-first library marking. No build step, no CDNs. */ (function () { "use strict"; var SECTIONS = { movies: { label: "Movies", key: "m" }, tvseries: { label: "TV Series", key: "t" } }; var state = { view: "home", section: null, titles: [], filtered: [], selected: 0, detail: null, homeTile: 0 }; var el = {}; ["view-home", "view-section", "view-detail", "search", "search-count", "section-title", "title-list", "list-empty", "detail-title", "detail-poster", "detail-noposter", "detail-facts", "detail-overview", "detail-size", "detail-files", "detail-marked", "detail-toggle", "detail-sync", "detail-back", "toast", "keyhint-text", "home-tiles" ].forEach(function (id) { el[id] = document.getElementById(id); }); /* ---------- fzf-ish subsequence matcher ---------- Scores a subsequence match: contiguous runs and word-boundary hits score higher, and an earlier first match wins ties. Returns null for no match. */ function fuzzy(needle, haystack) { if (!needle) return { score: 0, positions: [] }; var n = needle.toLowerCase(), h = haystack.toLowerCase(); var positions = [], score = 0, run = 0, hi = 0; for (var ni = 0; ni < n.length; ni++) { var ch = n.charAt(ni); if (ch === " ") { run = 0; continue; } var found = -1; for (var j = hi; j < h.length; j++) { if (h.charAt(j) === ch) { found = j; break; } } if (found < 0) return null; positions.push(found); score += 1; if (found === hi && ni > 0) { run++; score += 4 + run; } else { run = 0; } var prev = found > 0 ? h.charAt(found - 1) : ""; if (found === 0) score += 8; else if (prev === " " || prev === "-" || prev === "." || prev === "(" || prev === ":") score += 6; hi = found + 1; } score -= positions[0] * 0.1; score -= (h.length - n.length) * 0.01; return { score: score, positions: positions }; } function highlight(text, positions) { if (!positions || !positions.length) return document.createTextNode(text); var frag = document.createDocumentFragment(), at = 0; for (var i = 0; i < positions.length; i++) { var p = positions[i]; if (p < at) continue; if (p > at) frag.appendChild(document.createTextNode(text.slice(at, p))); var m = document.createElement("mark"); m.textContent = text.charAt(p); frag.appendChild(m); at = p + 1; } if (at < text.length) frag.appendChild(document.createTextNode(text.slice(at))); return frag; } /* ---------- helpers ---------- */ function humanSize(bytes) { if (!bytes) return "0 B"; var units = ["B", "KiB", "MiB", "GiB", "TiB"], i = 0, v = bytes; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + " " + units[i]; } var toastTimer = null; function toast(msg, isErr) { el.toast.textContent = msg; el.toast.className = "toast" + (isErr ? " err" : ""); clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.toast.className = "toast hidden"; }, 3200); } function api(method, path) { return fetch(path, { method: method, headers: { Accept: "application/json" } }) .then(function (res) { return res.json().catch(function () { return {}; }).then(function (body) { if (!res.ok) throw new Error(body.error || ("request failed (" + res.status + ")")); return body; }); }); } function displayName(t) { return (t.arr && t.arr.title) || t.name; } /* ---------- rendering ---------- */ function show(view) { state.view = view; el["view-home"].classList.toggle("hidden", view !== "home"); el["view-section"].classList.toggle("hidden", view !== "section"); el["view-detail"].classList.toggle("hidden", view !== "detail"); el["keyhint-text"].textContent = view === "home" ? "m movies · t tv series · ←/→ select · enter open" : view === "section" ? "type to filter · ↑/↓ select · enter details · m toggle cheeztv · esc back" : "m toggle cheeztv · esc back"; } function applyFilter() { var q = el.search.value.trim(); var scored = []; for (var i = 0; i < state.titles.length; i++) { var t = state.titles[i]; var r = fuzzy(q, displayName(t)); if (!r && q) r = fuzzy(q, t.name); if (r) scored.push({ t: t, score: r.score, positions: q ? r.positions : [] }); } if (q) scored.sort(function (a, b) { return b.score - a.score; }); state.filtered = scored; if (state.selected >= scored.length) state.selected = Math.max(0, scored.length - 1); renderList(); } function renderList() { el["title-list"].textContent = ""; el["list-empty"].classList.toggle("hidden", state.filtered.length > 0); el["search-count"].textContent = state.filtered.length + " / " + state.titles.length; state.filtered.forEach(function (row, idx) { var t = row.t; var a = document.createElement("a"); a.className = "title-row" + (idx === state.selected ? " selected" : "") + (t.marked ? " marked" : ""); a.href = "#/" + state.section + "/" + encodeURIComponent(t.name); a.setAttribute("role", "option"); a.dataset.index = String(idx); var art = document.createElement("div"); art.className = "title-art"; if (t.artUrl) { var img = document.createElement("img"); img.src = t.artUrl; img.alt = ""; img.loading = "lazy"; img.className = "poster"; art.appendChild(img); } else { art.appendChild(document.createTextNode("?")); art.classList.add("poster-placeholder"); } a.appendChild(art); var main = document.createElement("div"); main.className = "title-main"; var name = document.createElement("div"); name.className = "title-name"; name.appendChild(highlight(displayName(t), row.positions)); main.appendChild(name); var sub = document.createElement("div"); sub.className = "title-sub muted"; var bits = []; if (t.arr && t.arr.year) bits.push(t.arr.year); if (t.arr && t.arr.status) bits.push(t.arr.status); bits.push(humanSize(t.sizeBytes)); sub.textContent = bits.join(" · "); main.appendChild(sub); a.appendChild(main); var badges = document.createElement("div"); badges.className = "title-badges"; if (t.marked) badges.appendChild(badge("cheeztv", "badge-marked")); if (t.needsSync) badges.appendChild(badge("needs sync", "badge-sync")); a.appendChild(badges); el["title-list"].appendChild(a); }); } function badge(text, cls) { var s = document.createElement("span"); s.className = "badge " + cls; s.textContent = text; return s; } // Arrowing moves focus out of the search box and onto the list, so the // single-letter shortcuts (m) are unambiguous; typing any other character // hands focus straight back to the search box. function moveSelection(delta) { if (!state.filtered.length) return; state.selected = Math.min(state.filtered.length - 1, Math.max(0, state.selected + delta)); renderList(); el["title-list"].focus({ preventScroll: true }); var sel = el["title-list"].querySelector(".title-row.selected"); if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: "nearest" }); } function currentTitle() { if (state.view === "detail") return state.detail; var row = state.filtered[state.selected]; return row ? row.t : null; } /* ---------- data ---------- */ function loadSection(section) { state.section = section; el["section-title"].textContent = SECTIONS[section].label; document.title = "Mediamark — " + SECTIONS[section].label; el["title-list"].textContent = ""; return api("GET", "/api/library/" + section).then(function (body) { state.titles = body.titles || []; state.selected = 0; applyFilter(); }).catch(function (e) { toast(e.message, true); state.titles = []; applyFilter(); }); } function mergeTitle(updated) { for (var i = 0; i < state.titles.length; i++) { if (state.titles[i].name === updated.name) { ["marked", "needsSync", "sizeBytes", "fileCount", "unlinkedFiles"].forEach(function (k) { state.titles[i][k] = updated[k]; }); if (state.detail && state.detail.name === updated.name) state.detail = state.titles[i]; return state.titles[i]; } } return null; } function toggleMark(t) { if (!t) return; var wantMark = !t.marked; var prev = { marked: t.marked, needsSync: t.needsSync }; t.marked = wantMark; // optimistic t.needsSync = false; renderList(); if (state.view === "detail") renderDetail(); api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/" + (wantMark ? "mark" : "unmark")) .then(function (body) { mergeTitle(body.title); var s = body.sync || {}; toast(wantMark ? "Marked " + displayName(t) + " — " + (s.linked || 0) + " linked, " + (s.unchanged || 0) + " already there" : "Removed " + displayName(t) + " from cheeztv"); renderList(); if (state.view === "detail") renderDetail(); }) .catch(function (e) { t.marked = prev.marked; t.needsSync = prev.needsSync; renderList(); if (state.view === "detail") renderDetail(); toast(e.message, true); }); } function syncTitle(t) { api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/mark") .then(function (body) { mergeTitle(body.title); var s = body.sync || {}; toast("Synced " + displayName(t) + " — " + (s.linked || 0) + " new, " + (s.replaced || 0) + " replaced"); renderList(); renderDetail(); }) .catch(function (e) { toast(e.message, true); }); } /* ---------- detail ---------- */ function renderDetail() { var t = state.detail; if (!t) return; el["detail-title"].textContent = displayName(t); el["detail-back"].href = "#/" + state.section; var facts = []; if (t.arr && t.arr.year) facts.push(t.arr.year); if (t.arr && t.arr.status) facts.push(t.arr.status); facts.push(SECTIONS[state.section].label); el["detail-facts"].textContent = facts.join(" · "); el["detail-overview"].textContent = (t.arr && t.arr.overview) || "No overview available for this title."; el["detail-size"].textContent = humanSize(t.sizeBytes); el["detail-files"].textContent = String(t.fileCount); el["detail-marked"].textContent = t.marked ? (t.needsSync ? "marked (" + t.unlinkedFiles + " files unsynced)" : "marked") : "not marked"; if (t.artUrl) { el["detail-poster"].src = t.artUrl; el["detail-poster"].classList.remove("hidden"); el["detail-noposter"].classList.add("hidden"); } else { el["detail-poster"].classList.add("hidden"); el["detail-noposter"].classList.remove("hidden"); } el["detail-toggle"].textContent = t.marked ? "Remove from cheeztv" : "Add to cheeztv"; el["detail-toggle"].className = "btn " + (t.marked ? "btn-danger btn-lg" : "btn-primary btn-lg"); el["detail-sync"].classList.toggle("hidden", !t.needsSync); } function openDetail(section, name) { var go = state.section === section && state.titles.length ? Promise.resolve() : loadSection(section); return go.then(function () { var found = null; for (var i = 0; i < state.titles.length; i++) { if (state.titles[i].name === name) { found = state.titles[i]; break; } } if (!found) { toast("Title not found", true); location.hash = "#/" + section; return; } state.detail = found; show("detail"); renderDetail(); }); } /* ---------- routing ---------- */ function route() { var parts = (location.hash || "#/").replace(/^#\/?/, "").split("/"); var section = parts[0]; if (!SECTIONS[section]) { show("home"); document.title = "Mediamark"; focusTile(); return; } if (parts.length > 1 && parts[1]) { openDetail(section, decodeURIComponent(parts[1])); return; } show("section"); var reload = state.section === section && state.titles.length ? Promise.resolve(applyFilter()) : loadSection(section); reload.then(function () { el.search.focus(); el.search.select(); }); } function tiles() { return Array.prototype.slice.call(el["home-tiles"].querySelectorAll(".app-tile")); } function focusTile() { var ts = tiles(); ts.forEach(function (t, i) { t.classList.toggle("selected", i === state.homeTile); }); } /* ---------- keyboard ---------- */ document.addEventListener("keydown", function (ev) { if (ev.ctrlKey || ev.metaKey || ev.altKey) return; var typingInSearch = ev.target === el.search; if (state.view === "home") { var ts = tiles(); if (ev.key === "m") { location.hash = "#/movies"; ev.preventDefault(); return; } if (ev.key === "t") { location.hash = "#/tvseries"; ev.preventDefault(); return; } if (ev.key === "ArrowRight" || ev.key === "ArrowDown") { state.homeTile = Math.min(ts.length - 1, state.homeTile + 1); focusTile(); ev.preventDefault(); return; } if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") { state.homeTile = Math.max(0, state.homeTile - 1); focusTile(); ev.preventDefault(); return; } if (ev.key === "Enter") { ts[state.homeTile].click(); ev.preventDefault(); } return; } if (ev.key === "Escape") { if (state.view === "detail") { location.hash = "#/" + state.section; } else if (typingInSearch && el.search.value) { el.search.value = ""; applyFilter(); } else { location.hash = "#/"; } ev.preventDefault(); return; } if (state.view === "detail") { if (ev.key === "m") { toggleMark(state.detail); ev.preventDefault(); } return; } // Section view. if (ev.key === "ArrowDown") { moveSelection(1); ev.preventDefault(); return; } if (ev.key === "ArrowUp") { moveSelection(-1); ev.preventDefault(); return; } if (ev.key === "Enter") { var t = currentTitle(); if (t) location.hash = "#/" + state.section + "/" + encodeURIComponent(t.name); ev.preventDefault(); return; } if (ev.key === "m" && !typingInSearch) { toggleMark(currentTitle()); ev.preventDefault(); return; } if (!typingInSearch && ev.key.length === 1) { el.search.focus(); el.search.value += ev.key; state.selected = 0; applyFilter(); ev.preventDefault(); } }); el.search.addEventListener("input", function () { state.selected = 0; applyFilter(); }); el["title-list"].addEventListener("click", function (ev) { var row = ev.target.closest ? ev.target.closest(".title-row") : null; if (row) state.selected = Number(row.dataset.index); }); el["home-tiles"].addEventListener("mouseover", function (ev) { var tile = ev.target.closest ? ev.target.closest(".app-tile") : null; if (!tile) return; state.homeTile = tiles().indexOf(tile); focusTile(); }); el["detail-toggle"].addEventListener("click", function () { toggleMark(state.detail); }); el["detail-sync"].addEventListener("click", function () { syncTitle(state.detail); }); window.addEventListener("hashchange", route); route(); })();