Forty five of them across the account page and the status dashboard, in prose the owner and his family read. They are a tell: the writing reads as machine written, which is the opposite of what a family app should feel like. Most become a full stop or a comma. Headings that were a name plus an explainer take a colon instead. Two subtitles that were dash-led asides become parentheses, and the language selects lose the construction entirely: "Zvuk, hlavní" was never how anyone says it, so they are now "Hlavní zvuk" and "Záložní zvuk", "Hlavní titulky" and "Záložní titulky", "Zvuk u anime" and "Titulky u anime". Six are deliberately left: the em dash that stands in for an empty value in a table cell or an unset field. That is a typographic convention rather than prose, and replacing it with a word puts noise in every empty cell. Code comments are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1490 lines
71 KiB
HTML
1490 lines
71 KiB
HTML
<!doctype html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="robots" content="noindex">
|
|
<title>Amber: můj účet</title>
|
|
<style>
|
|
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
|
|
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
|
|
--line:#2a2d36; --sunk:#141620; }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--fg);
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
|
min-height:100dvh; padding:20px 16px 48px; }
|
|
.wrap { width:100%; max-width:560px; margin:0 auto; }
|
|
.card { background:var(--card); border-radius:16px; padding:22px 20px;
|
|
border:1px solid var(--line); box-shadow:0 10px 40px rgba(0,0,0,.25);
|
|
margin-bottom:16px; }
|
|
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
|
|
h2 { font-size:16px; margin:0 0 12px; color:var(--fg); }
|
|
p.sub { margin:0 0 16px; color:var(--muted); font-size:14px; line-height:1.5; }
|
|
label { display:block; font-size:13px; color:var(--muted); margin:14px 0 5px; }
|
|
input[type=email],input[type=password],input[type=text],input[type=number],select {
|
|
width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
|
|
background:var(--bg); color:var(--fg); font-size:15px; }
|
|
input:focus, select:focus { outline:2px solid var(--amber); border-color:transparent; }
|
|
/* Every other interactive thing on this page is amber. A browser-default blue
|
|
underlined link in the middle of it reads as something pasted in from
|
|
another site, which is not what you want somebody to think while they are
|
|
already worried they have lost their account. */
|
|
a { color:var(--amber); text-decoration:none; }
|
|
a:hover, a:focus-visible { text-decoration:underline; }
|
|
.row { display:flex; gap:10px; } .row > * { flex:1; min-width:0; }
|
|
.hint { font-size:12px; color:var(--muted); margin:5px 0 0; line-height:1.45; }
|
|
.check { display:flex; gap:10px; align-items:flex-start; margin-top:14px;
|
|
font-size:14px; line-height:1.45; }
|
|
.check input { margin-top:3px; width:auto; }
|
|
button.btn { width:100%; margin-top:18px; padding:13px; border:none;
|
|
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
|
|
.primary { background:var(--amber); color:#1b1206; }
|
|
.ghost { background:transparent; color:var(--fg); border:1px solid var(--line); }
|
|
.danger { background:transparent; color:var(--err); border:1px solid var(--err); }
|
|
button:disabled { opacity:.5; cursor:default; }
|
|
button.link { background:none; border:none; color:var(--muted); font-size:13px;
|
|
cursor:pointer; text-decoration:underline; padding:0; margin-top:12px; }
|
|
.msg { margin-top:12px; font-size:14px; min-height:18px; line-height:1.45; }
|
|
.msg.err { color:var(--err); } .msg.ok { color:var(--ok); }
|
|
.hidden { display:none !important; }
|
|
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
|
|
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
|
|
vertical-align:-2px; margin-right:7px; }
|
|
@keyframes s { to { transform:rotate(360deg); } }
|
|
nav { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px; }
|
|
nav button { padding:9px 13px; border-radius:999px; border:1px solid var(--line);
|
|
background:var(--card); color:var(--muted); font-size:13.5px; cursor:pointer; }
|
|
nav button[aria-current="true"] { background:var(--amber); color:#1b1206;
|
|
border-color:transparent; font-weight:700; }
|
|
.who { display:flex; justify-content:space-between; align-items:center;
|
|
font-size:13px; color:var(--muted); margin-bottom:14px; gap:12px; }
|
|
.who b { color:var(--fg); }
|
|
.sep { height:1px; background:var(--line); margin:18px 0 2px; border:0; }
|
|
.list { list-style:none; padding:0; margin:0; }
|
|
.list li { padding:12px; border:1px solid var(--line); border-radius:10px;
|
|
margin-bottom:8px; background:var(--sunk); font-size:14px; }
|
|
.list .meta { color:var(--muted); font-size:12.5px; margin-top:3px; }
|
|
.rowbtn { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
|
.rowbtn button { margin:0; width:auto; padding:8px 12px; font-size:13px; }
|
|
code.url { background:var(--sunk); padding:3px 7px; border-radius:6px;
|
|
font-size:13px; word-break:break-all; }
|
|
.warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0;
|
|
font-size:13px; color:var(--muted); line-height:1.5; }
|
|
ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; }
|
|
/* setup flow */
|
|
.step { border:1px solid var(--line); border-radius:12px; padding:14px;
|
|
margin-bottom:10px; background:var(--sunk); }
|
|
.step.done { opacity:.6; }
|
|
/* A finished step is dimmed, but a button inside one is still live. Dimming it
|
|
with the rest made "Změnit výběr" read as disabled, which is the one thing
|
|
this app has repeatedly got wrong. */
|
|
.step.done .btn, .step.done button.link { opacity:1; }
|
|
.step.now { border-color:var(--amber); }
|
|
.step h3 { font-size:14.5px; margin:0; display:flex; gap:9px;
|
|
align-items:center; font-weight:700; }
|
|
.step .num { display:inline-flex; align-items:center; justify-content:center;
|
|
width:22px; height:22px; flex:0 0 22px; border-radius:50%; font-size:12px;
|
|
background:var(--line); color:var(--muted); }
|
|
.step.done .num { background:var(--ok); color:#08210f; }
|
|
.step.now .num { background:var(--amber); color:#1b1206; }
|
|
.step .body { margin-top:10px; }
|
|
.step .body p { margin:0 0 8px; font-size:13.5px; color:var(--muted);
|
|
line-height:1.55; }
|
|
.step .btn { margin-top:12px; }
|
|
.prov { border:1px solid var(--line); border-radius:10px; padding:12px;
|
|
margin-bottom:8px; background:var(--card); }
|
|
.prov.on { border-color:var(--amber); }
|
|
.prov .top { display:flex; justify-content:space-between; gap:10px;
|
|
align-items:baseline; }
|
|
.prov .price { font-weight:700; white-space:nowrap; }
|
|
.prov ul { margin:8px 0 0; padding-left:17px; font-size:12.5px;
|
|
color:var(--muted); line-height:1.55; }
|
|
.prov ul.cons { margin-top:10px; }
|
|
.prov ul.cons li::marker { color:var(--err); }
|
|
.badge { display:inline-block; font-size:11px; font-weight:700; padding:2px 7px;
|
|
border-radius:999px; background:var(--amber); color:#1b1206; margin-left:6px;
|
|
vertical-align:1px; }
|
|
.badge.soft { background:var(--line); color:var(--fg); }
|
|
.guide { margin:0; padding-left:19px; font-size:13px; line-height:1.6; }
|
|
.guide li { margin-bottom:5px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<!-- ── setting a new password from an emailed link ─────────────────────── -->
|
|
<!-- Hosted here rather than letting the email point at PocketBase's own
|
|
confirm page: that page lives under /_/ with the admin console, is in
|
|
English, and looks to anybody's parents like a developer tool they have
|
|
opened by mistake. -->
|
|
<div id="reset" hidden>
|
|
<div class="card">
|
|
<h1>Nové heslo</h1>
|
|
<p class="sub">Zadej si nové heslo k účtu. Odkaz z e-mailu platí omezenou
|
|
dobu. Když nezabere, požádej o nový.</p>
|
|
<label for="rNew">Nové heslo</label>
|
|
<input id="rNew" type="password" autocomplete="new-password">
|
|
<label for="rNew2">Nové heslo znovu</label>
|
|
<input id="rNew2" type="password" autocomplete="new-password">
|
|
<button id="rSave" class="btn primary">Nastavit heslo</button>
|
|
<div id="rMsg" class="msg"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── confirming an email address from an emailed link ─────────────────── -->
|
|
<!-- Same reasoning as the reset card above: the verification email used to
|
|
land on pb.petruzalekr.cz/_/#/auth/confirm-verification/…, which is the
|
|
admin console. Nothing is gated on `verified` (the users collection has an
|
|
empty authRule), so this page exists to be reassuring rather than to
|
|
unlock anything.
|
|
|
|
It confirms on load rather than behind a button. A mail provider that
|
|
prefetches links only issues a GET; the POST happens from this script, and
|
|
scanners do not run it. Asking a parent to click a second button on a page
|
|
they reached by clicking a button is friction with nothing behind it. -->
|
|
<div id="verify" hidden>
|
|
<div class="card">
|
|
<h1>Ověření e-mailu</h1>
|
|
<p class="sub" id="vSub">Moment, ověřuji…</p>
|
|
<div id="vMsg" class="msg"></div>
|
|
<button id="vDone" class="btn primary" hidden>Pokračovat</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── signed out ───────────────────────────────────────────────────────── -->
|
|
<div id="anon">
|
|
<div class="card">
|
|
<h1>Amber</h1>
|
|
<p class="sub">Přihlas se a spravuj svůj účet, profily a nastavení.
|
|
Přihlášení zůstane uložené, takže příště to bude rovnou tady.</p>
|
|
<label for="email">E-mail</label>
|
|
<input id="email" type="email" autocomplete="username" inputmode="email">
|
|
<label for="pass">Heslo</label>
|
|
<input id="pass" type="password" autocomplete="current-password">
|
|
<button id="loginBtn" class="btn primary">Přihlásit se</button>
|
|
<div id="anonMsg" class="msg"></div>
|
|
<p class="hint"><a href="#" id="forgotLink">Zapomenuté heslo?</a></p>
|
|
|
|
<!-- Hidden until asked for: the overwhelmingly common visit is somebody
|
|
who remembers their password, and a reset field on the way in invites
|
|
people to reach for it first. -->
|
|
<div id="forgot" hidden>
|
|
<label for="fEmail">E-mail účtu</label>
|
|
<input id="fEmail" type="email" autocomplete="username" inputmode="email">
|
|
<button id="forgotBtn" class="btn">Poslat odkaz na nové heslo</button>
|
|
<div id="forgotMsg" class="msg"></div>
|
|
</div>
|
|
|
|
<p class="hint">Účty zakládá Richard, registrace tu není. Když se nemůžeš
|
|
dostat dovnitř, napiš mu.</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Nainstalovat na televizi</h2>
|
|
<ol class="steps">
|
|
<li>Na televizi otevři <b>prohlížeč</b> a zadej
|
|
<code class="url" id="tvUrl">amber.petruzalekr.cz</code>.</li>
|
|
<li>Zmáčkni tlačítko níž, stáhne se instalace a pak ji spusť.</li>
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR kód
|
|
telefonem. Na televizi tak nemusíš nic psát.</li>
|
|
</ol>
|
|
<a class="btn primary" id="tvDl" href="/tv"
|
|
style="display:block;text-align:center;text-decoration:none">Stáhnout na televizi</a>
|
|
<p class="hint">Stahuje se aplikace pro Android TV. Bez účtu se do ní
|
|
nepřihlásíš, takže samotný soubor nikomu nic nedá.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── signed in ────────────────────────────────────────────────────────── -->
|
|
<div id="app" class="hidden">
|
|
<div class="who">
|
|
<span>Účet: <b id="whoEmail"></b></span>
|
|
<button id="logoutBtn" class="link" style="margin:0">Odhlásit</button>
|
|
</div>
|
|
|
|
<!-- Resume path for anyone who chose "nastavím to později". Without this,
|
|
skipping is indistinguishable from finishing and the flow is gone for
|
|
good. -->
|
|
<div id="stBanner" class="warn hidden">Nastavení ještě není dokončené.
|
|
Amber zatím nemá kde hledat.
|
|
<button id="stResume" class="link">Pokračovat v nastavení</button></div>
|
|
|
|
<nav id="nav"></nav>
|
|
|
|
<!-- setup flow: the BYOC path, for someone who brings their own accounts -->
|
|
<section id="tab-start" class="card tab hidden">
|
|
<h2>Začínáme</h2>
|
|
<p class="sub">Amber sám nic nehostuje, potřebuje účet aspoň u jedné
|
|
služby. Projdi to po krocích. Můžeš kdykoli odejít a vrátit se, tvůj
|
|
postup se ukládá k účtu, takže na něj dosáhneš i z jiného zařízení.</p>
|
|
<div id="stList"></div>
|
|
<div id="stMsg" class="msg"></div>
|
|
<button id="stLater" class="link">Nastavím to později</button>
|
|
</section>
|
|
|
|
<!-- profiles -->
|
|
<section id="tab-profiles" class="card tab hidden">
|
|
<h2>Profily</h2>
|
|
<p class="sub">Každý profil má vlastní seznamy, rozkoukané a nastavení
|
|
přehrávání.</p>
|
|
<ul class="list" id="profileList"></ul>
|
|
<hr class="sep">
|
|
<label for="pfName">Upravit profil</label>
|
|
<select id="pfSelect"></select>
|
|
<label for="pfName2">Jméno</label>
|
|
<input id="pfName2" type="text" maxlength="40">
|
|
<label for="pfRating">Věkový strop</label>
|
|
<select id="pfRating">
|
|
<option value="">Podle účtu</option>
|
|
<option value="kids">Děti (do 7)</option>
|
|
<option value="twelve">12+</option>
|
|
<option value="fifteen">15+</option>
|
|
<option value="unrestricted">Bez omezení</option>
|
|
</select>
|
|
<div class="check">
|
|
<input id="pfChild" type="checkbox">
|
|
<!-- Text is set in enter(): an account without 18+ enabled must not be told
|
|
that 18+ exists. See there. -->
|
|
<label for="pfChild" id="pfChildLabel" style="margin:0">Dětský profil</label>
|
|
</div>
|
|
<label for="pfPin">Nový PIN (4 číslice, nech prázdné = beze změny)</label>
|
|
<input id="pfPin" type="text" inputmode="numeric" maxlength="4" autocomplete="off">
|
|
<button id="pfSave" class="btn primary">Uložit profil</button>
|
|
<div id="pfMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- playback prefs -->
|
|
<section id="tab-playback" class="card tab hidden">
|
|
<h2>Přehrávání</h2>
|
|
<p class="sub">Nastavení jazyků platí pro vybraný profil a projeví se na
|
|
všech zařízeních.</p>
|
|
<label for="prProfile">Profil</label>
|
|
<select id="prProfile"></select>
|
|
<hr class="sep">
|
|
<div class="row">
|
|
<div><label for="prAudio">Hlavní zvuk</label>
|
|
<select id="prAudio" class="lang"></select></div>
|
|
<div><label for="prAudio2">Záložní zvuk</label>
|
|
<select id="prAudio2" class="lang"></select></div>
|
|
</div>
|
|
<div class="row">
|
|
<div><label for="prSub">Hlavní titulky</label>
|
|
<select id="prSub" class="lang sub"></select></div>
|
|
<div><label for="prSub2">Záložní titulky</label>
|
|
<select id="prSub2" class="lang sub"></select></div>
|
|
</div>
|
|
<p class="hint">Čeština jako hlavní zvuk znamená, že Amber vždycky hledá
|
|
ověřený český dabing. Záložní jazyk se použije jen tehdy, když žádný
|
|
český zdroj neexistuje.</p>
|
|
<hr class="sep">
|
|
<div class="row">
|
|
<div><label for="prAnimeAudio">Zvuk u anime</label>
|
|
<select id="prAnimeAudio" class="lang"></select></div>
|
|
<div><label for="prAnimeSub">Titulky u anime</label>
|
|
<select id="prAnimeSub" class="lang sub"></select></div>
|
|
</div>
|
|
<hr class="sep">
|
|
<label for="prRes">Preferované rozlišení</label>
|
|
<select id="prRes">
|
|
<option value="">Bez preference</option>
|
|
<option value="2160">4K (2160p)</option>
|
|
<option value="1080">1080p</option>
|
|
<option value="720">720p</option>
|
|
</select>
|
|
<div class="check">
|
|
<input id="prHighest" type="checkbox">
|
|
<label for="prHighest" style="margin:0">Řadit zdroje s vyšším rozlišením výš</label>
|
|
</div>
|
|
<div class="check">
|
|
<input id="prSigns" type="checkbox">
|
|
<label for="prSigns" style="margin:0">Spojit titulky pro nápisy a dialogy</label>
|
|
</div>
|
|
<label for="prFeedback">Ptát se, jak se přehrávalo</label>
|
|
<select id="prFeedback">
|
|
<option value="off">Nikdy</option>
|
|
<option value="sometimes">Občas</option>
|
|
<option value="always">Po každém přehrání</option>
|
|
</select>
|
|
<button id="prSave" class="btn primary">Uložit přehrávání</button>
|
|
<div id="prMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- addons -->
|
|
<section id="tab-addons" class="card tab hidden">
|
|
<h2>Zdroje</h2>
|
|
<p class="sub">Odkud Amber bere filmy a seriály. Uloženo zašifrovaně.
|
|
Klíč se počítá z tvého hesla a nikdy neopustí prohlížeč.</p>
|
|
|
|
<div id="adNeedPass">
|
|
<label for="adPass">Heslo k účtu</label>
|
|
<input id="adPass" type="password" autocomplete="current-password">
|
|
<p class="hint">Bez hesla nelze zdroje rozšifrovat ani uložit.</p>
|
|
<button id="adUnlock" class="btn primary">Odemknout zdroje</button>
|
|
</div>
|
|
|
|
<div id="adBody" class="hidden">
|
|
<div id="adSetup">
|
|
<p class="sub">Ještě tu nic není. Vyber, jak to nastavit:</p>
|
|
<button id="adFamily" class="btn ghost">Mám kód od Richarda</button>
|
|
<button id="adByoc" class="btn ghost">Mám vlastní účty u služeb</button>
|
|
</div>
|
|
|
|
<!-- family code path -->
|
|
<div id="adFamilyBox" class="hidden">
|
|
<label for="adCode">Kód</label>
|
|
<input id="adCode" type="text" autocomplete="off" spellcheck="false">
|
|
<p class="hint">Kód dostaneš od Richarda a nastaví ti zdroje, které s tebou
|
|
sdílí. Rodina a přátelé mají každý svůj vlastní kód, takže dostaneš
|
|
ty správné.</p>
|
|
<button id="adCodeGo" class="btn primary">Použít kód</button>
|
|
<button class="link adBack">Zpět</button>
|
|
</div>
|
|
|
|
<!-- BYOC path -->
|
|
<div id="adByocBox" class="hidden">
|
|
<p class="sub">Amber si z tvých přihlašovacích údajů poskládá adresy
|
|
zdrojů sám. Údaje jdou přímo do doplňku, přes náš server neprojdou.</p>
|
|
|
|
<h2 style="margin-top:18px">prehraj.to <span style="color:var(--muted);font-weight:400">(nebo webshare níž)</span></h2>
|
|
<div class="row">
|
|
<div><label for="byPtUser">Jméno</label>
|
|
<input id="byPtUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
<div><label for="byPtPass">Heslo</label>
|
|
<input id="byPtPass" type="password" autocomplete="off"></div>
|
|
</div>
|
|
<p class="hint">Vyplň prehraj.to, webshare, nebo obojí. Stačí jeden
|
|
z nich, obojí najde nejvíc. Bez českého hosta nebudou české dabingy.
|
|
Když chceš jen anime nebo originální znění, vyplň dole samotný
|
|
TorBox.</p>
|
|
|
|
<h2 style="margin-top:18px">webshare.cz</h2>
|
|
<div class="row">
|
|
<div><label for="byWsUser">Jméno</label>
|
|
<input id="byWsUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
<div><label for="byWsPass">Heslo</label>
|
|
<input id="byWsPass" type="password" autocomplete="off"></div>
|
|
</div>
|
|
<p class="hint">Druhý český host, funguje i sám o sobě. U každého
|
|
hosta ale buď obojí, nebo nic. Půlka údajů selže při každém hledání.</p>
|
|
|
|
<h2 style="margin-top:18px">TorBox <span style="color:var(--muted);font-weight:400">(volitelné, ale doporučené)</span></h2>
|
|
<label for="byTbKey">API klíč</label>
|
|
<input id="byTbKey" type="text" autocomplete="off" spellcheck="false"
|
|
placeholder="z torbox.app → Settings → API">
|
|
<p class="hint"><b>Tohle je zdroj s největším výběrem.</b> Pro cokoli,
|
|
co není česky dabované (původní znění, novinky, seriály, 4K), je
|
|
TorBox zdaleka nejlepší a najde toho nejvíc. Bez něj zůstaneš jen
|
|
u českých zdrojů.</p>
|
|
|
|
<label for="byTmdb">TMDB klíč</label>
|
|
<input id="byTmdb" type="text" autocomplete="off" spellcheck="false">
|
|
<p class="hint">Bez něj se nenačtou plakáty ani popisy. Zdarma na
|
|
themoviedb.org.</p>
|
|
|
|
<button id="byGo" class="btn primary">Nastavit zdroje</button>
|
|
<button class="link adBack">Zpět</button>
|
|
</div>
|
|
|
|
<!-- manual edit of the current config -->
|
|
<div id="adEdit" class="hidden">
|
|
<label for="adTorbox">TorBox</label>
|
|
<input id="adTorbox" type="text" spellcheck="false" placeholder="https://…">
|
|
<label for="adCzech">Český zdroj</label>
|
|
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
|
|
<label for="adTmdb">TMDB klíč</label>
|
|
<input id="adTmdb" type="text" spellcheck="false">
|
|
<div id="adAdultBox" class="hidden">
|
|
<hr class="sep">
|
|
<label for="adAdult">Zdroj 18+</label>
|
|
<input id="adAdult" type="text" spellcheck="false" placeholder="https://…">
|
|
</div>
|
|
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
|
|
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
|
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
|
</div>
|
|
</div>
|
|
<div id="adMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- devices -->
|
|
<section id="tab-devices" class="card tab hidden">
|
|
<h2>Zařízení</h2>
|
|
<p class="sub">Přihlášené televize a počítače. Odebrání zabrání dalšímu
|
|
přihlašování z toho zařízení.</p>
|
|
<ul class="list" id="deviceList"></ul>
|
|
<div id="dvMsg" class="msg"></div>
|
|
<p class="hint">Už přihlášené zařízení může dobíhat, dokud mu nevyprší
|
|
token. Odebrání zastaví nová přihlášení, ne běžící relaci.</p>
|
|
</section>
|
|
|
|
<!-- downloads -->
|
|
<section id="tab-download" class="card tab hidden">
|
|
<h2>Stáhnout Amber</h2>
|
|
<p class="sub">Verze podle tvého účtu.</p>
|
|
<div id="dlList"></div>
|
|
<hr class="sep">
|
|
<h2 style="margin-top:16px">Televize</h2>
|
|
<ol class="steps">
|
|
<li>Na televizi otevři prohlížeč, zadej
|
|
<code class="url">amber.petruzalekr.cz</code> a zmáčkni
|
|
<b>Stáhnout na televizi</b>.</li>
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR telefonem.</li>
|
|
</ol>
|
|
<p class="hint">Kratší cesta, když se ti chce psát: rovnou
|
|
<code class="url">amber.petruzalekr.cz/tv</code> stáhne soubor bez klikání.</p>
|
|
<div id="dlMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- account -->
|
|
<section id="tab-account" class="card tab hidden">
|
|
<h2>Účet</h2>
|
|
<label for="acEmail">E-mail</label>
|
|
<input id="acEmail" type="email" disabled>
|
|
<p class="hint">E-mail mění správce.</p>
|
|
|
|
<hr class="sep">
|
|
<label for="acRating">Výchozí věkový strop účtu</label>
|
|
<select id="acRating">
|
|
<option value="unrestricted">Bez omezení</option>
|
|
<option value="fifteen">15+</option>
|
|
<option value="twelve">12+</option>
|
|
<option value="kids">Děti (do 7)</option>
|
|
</select>
|
|
<p class="hint">Použije se pro profily, které nemají vlastní strop.</p>
|
|
<button id="acSaveRating" class="btn primary">Uložit</button>
|
|
|
|
<hr class="sep">
|
|
<h2 style="margin-top:18px">Změna hesla</h2>
|
|
<div class="warn">Heslo šifruje tvoje zdroje. Změna je proto přešifruje, takže
|
|
musíš mít v této relaci odemčené <b>Zdroje</b>, jinak o ně přijdeš.</div>
|
|
<label for="acOld">Současné heslo</label>
|
|
<input id="acOld" type="password" autocomplete="current-password">
|
|
<label for="acNew">Nové heslo</label>
|
|
<input id="acNew" type="password" autocomplete="new-password">
|
|
<label for="acNew2">Nové heslo znovu</label>
|
|
<input id="acNew2" type="password" autocomplete="new-password">
|
|
<button id="acChange" class="btn danger" disabled>Změnit heslo</button>
|
|
<div id="acMsg" class="msg"></div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── crypto: MUST match the app byte-for-byte ─────────────────────────────────
|
|
// addon_config_crypto.dart: key = PBKDF2-HMAC-SHA256(password, salt, 210000,
|
|
// 256 bit); blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] );
|
|
// kdf id "pbkdf2-sha256-210000". Lifted unchanged from settings.html, which is
|
|
// pinned against the Dart implementation by addon_config_crypto_interop_test.
|
|
var subtle = crypto.subtle;
|
|
var PBKDF2_ITERS = 210000;
|
|
var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS;
|
|
|
|
function b64e(u8){var s="";for(var i=0;i<u8.length;i++)s+=String.fromCharCode(u8[i]);return btoa(s);}
|
|
function b64d(s){var bin=atob(s);var u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
|
|
function b64url(str){return btoa(unescape(encodeURIComponent(str)))
|
|
.replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");}
|
|
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
|
|
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
|
|
|
|
async function deriveKey(password, salt){
|
|
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
|
|
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
|
|
return subtle.importKey("raw",bits,"AES-GCM",false,["encrypt","decrypt"]);
|
|
}
|
|
async function decryptBlob(blobB64, password, saltB64){
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
var packed=b64d(blobB64);
|
|
var clear=await subtle.decrypt(
|
|
{name:"AES-GCM",iv:packed.slice(0,12),tagLength:128}, key, packed.slice(12));
|
|
return new TextDecoder().decode(clear);
|
|
}
|
|
// Reuse the record's EXISTING salt so the key stays identical to the one the
|
|
// user's devices already cached — an already-signed-in TV then decrypts the
|
|
// edited blob without re-entering a password.
|
|
async function encryptWithSalt(plaintext, password, saltB64){
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
var nonce=crypto.getRandomValues(new Uint8Array(12));
|
|
var ct=new Uint8Array(await subtle.encrypt(
|
|
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
|
|
return b64e(concat(nonce, ct));
|
|
}
|
|
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
|
|
|
|
// ── addon hosts. Public, credential-free bases; the token segment carries the
|
|
// credentials and is minted per user. ───────────────────────────────────────
|
|
var PREHRAJTO_BASE = "https://prehrajto.petruzalekr.cz";
|
|
var TORBOX_BASE = "https://torbox.petruzalekr.cz";
|
|
|
|
// ── state ────────────────────────────────────────────────────────────────────
|
|
var TOKEN=null, USER=null, PASSWORD=null;
|
|
var PROFILES=[], CONFIGS=[], PREFS={};
|
|
var cfgRec=null, cfg=null; // active addon_config row + plaintext
|
|
var LS="amber.session";
|
|
|
|
function $(id){ return document.getElementById(id); }
|
|
function setMsg(el,t,cls){ el.className="msg "+(cls||""); el.textContent=t||""; }
|
|
function busy(btn,on,label){ if(!btn.dataset.label) btn.dataset.label=btn.textContent;
|
|
btn.disabled=on; btn.innerHTML = on ? '<span class="spin"></span>'+label : btn.dataset.label; }
|
|
|
|
async function api(method, path, body, opts){
|
|
var o={ method:method, headers:{} };
|
|
if (body!==undefined && body!==null){ o.headers["Content-Type"]="application/json";
|
|
o.body=JSON.stringify(body); }
|
|
if (TOKEN && !(opts&&opts.anon)) o.headers["Authorization"]=TOKEN;
|
|
var r=await fetch(path,o); var d=null; try{ d=await r.json(); }catch(_){}
|
|
if (r.status===401 && TOKEN){ signOut(); }
|
|
return { ok:r.ok, status:r.status, data:d };
|
|
}
|
|
|
|
// ── session ──────────────────────────────────────────────────────────────────
|
|
// The token persists; the password never does. Anything needing the password
|
|
// (the addon vault) asks for it in the moment — that is the honest split
|
|
// between "stay signed in" and "hold the key to your credentials in a browser".
|
|
function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} }
|
|
function signOut(){
|
|
TOKEN=null; USER=null; PASSWORD=null; cfg=null; cfgRec=null;
|
|
// CONFIGS and PROVIDERS decide what the setup flow claims is finished, so
|
|
// leaving them behind would show the next person to sign in on this browser a
|
|
// checklist describing somebody else's account.
|
|
CONFIGS=[]; PROVIDERS=[];
|
|
try{ localStorage.removeItem(LS); }catch(_){}
|
|
$("stBanner").className="warn hidden";
|
|
$("app").className="hidden"; $("anon").className="";
|
|
}
|
|
async function restore(){
|
|
var t=null; try{ t=localStorage.getItem(LS); }catch(_){}
|
|
if (!t) return false;
|
|
TOKEN=t;
|
|
var r=await api("POST","/api/collections/users/auth-refresh",null);
|
|
if (!r.ok || !r.data || !r.data.token){ TOKEN=null; return false; }
|
|
TOKEN=r.data.token; USER=r.data.record; saveSession();
|
|
return true;
|
|
}
|
|
|
|
$("loginBtn").onclick=async function(){
|
|
// Lower-cased, not just trimmed. PocketBase looks accounts up
|
|
// case-sensitively, so an address typed with a capital — which phones do by
|
|
// themselves — matches nothing and reads as a wrong password. It cost a
|
|
// family member two days and four failed password resets.
|
|
var email=$("email").value.trim().toLowerCase(), pass=$("pass").value;
|
|
if(!email||!pass){ setMsg($("anonMsg"),"Vyplň e-mail i heslo.","err"); return; }
|
|
setMsg($("anonMsg"),""); busy($("loginBtn"),true,"Přihlašuji…");
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:email, password:pass }, {anon:true});
|
|
busy($("loginBtn"),false);
|
|
if(!r.ok){ setMsg($("anonMsg"), r.status===400
|
|
? "Špatný e-mail nebo heslo." : "Přihlášení selhalo.","err"); return; }
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=pass; saveSession();
|
|
await enter();
|
|
};
|
|
$("forgotLink").onclick=function(e){
|
|
e.preventDefault();
|
|
var box=$("forgot");
|
|
box.hidden=!box.hidden;
|
|
if(!box.hidden){ $("fEmail").value=$("email").value; $("fEmail").focus(); }
|
|
};
|
|
|
|
$("forgotBtn").onclick=async function(){
|
|
var email=$("fEmail").value.trim().toLowerCase();
|
|
if(!email){ setMsg($("forgotMsg"),"Vyplň e-mail.","err"); return; }
|
|
setMsg($("forgotMsg"),""); busy($("forgotBtn"),true,"Odesílám…");
|
|
var r=await api("POST","/api/collections/users/request-password-reset",
|
|
{ email:email }, {anon:true});
|
|
busy($("forgotBtn"),false);
|
|
// The server answers the same whether or not the account exists — on purpose,
|
|
// so nobody can use this to find out which addresses are registered. So the
|
|
// message says what was DONE, never whether it matched: promising "sent" for
|
|
// an address with no account is the lie that made the last outage so hard to
|
|
// read from the outside.
|
|
if(!r.ok){ setMsg($("forgotMsg"),"Nepovedlo se odeslat. Zkus to za chvíli.","err"); return; }
|
|
setMsg($("forgotMsg"),"Pokud k té adrese účet existuje, přišel na ni odkaz. "+
|
|
"Mrkni i do spamu. Když nic nedorazí, napiš Richardovi, heslo ti nastaví přímo.","ok");
|
|
};
|
|
|
|
$("logoutBtn").onclick=signOut;
|
|
|
|
// ── shell ────────────────────────────────────────────────────────────────────
|
|
var TABS=[["start","Začínáme"],["profiles","Profily"],["playback","Přehrávání"],
|
|
["addons","Zdroje"],["devices","Zařízení"],["download","Stáhnout"],
|
|
["account","Účet"]];
|
|
function showTab(id){
|
|
TABS.forEach(function(t){
|
|
$("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden");
|
|
var b=$("nav-"+t[0]); if(b) b.setAttribute("aria-current", t[0]===id?"true":"false");
|
|
});
|
|
try{ location.hash=id; }catch(_){}
|
|
}
|
|
function buildNav(){
|
|
var n=$("nav"); n.innerHTML="";
|
|
TABS.forEach(function(t){
|
|
var b=document.createElement("button");
|
|
b.id="nav-"+t[0]; b.textContent=t[1];
|
|
b.onclick=function(){ showTab(t[0]); };
|
|
n.appendChild(b);
|
|
});
|
|
}
|
|
|
|
async function enter(){
|
|
$("anon").className="hidden"; $("app").className="";
|
|
$("whoEmail").textContent=(USER&&USER.email)||"";
|
|
buildNav();
|
|
await Promise.all([loadProfiles(), loadDevices(), loadDownloads(),
|
|
loadProviders(), loadConfigList()]);
|
|
$("acEmail").value=(USER&&USER.email)||"";
|
|
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
|
// **Do not mention 18+ to an account that does not have it.** This label used to
|
|
// read "skryje anime a 18+" for everyone, which told most of the household that a
|
|
// section they have never seen and cannot reach exists at all. Three of the four
|
|
// accounts are `nsfwEnabled = false`, and for them the sentence was also simply
|
|
// untrue: there is no 18+ content to hide. The adult source field on the Zdroje
|
|
// tab has always been gated on the same flag; this label was the one place that
|
|
// was not.
|
|
$("pfChildLabel").textContent = (USER && USER.nsfwEnabled)
|
|
? "Dětský profil (skryje anime a 18+, na odchod chce PIN)"
|
|
: "Dětský profil (skryje anime, na odchod chce PIN)";
|
|
renderAddonGate();
|
|
renderStart();
|
|
// An explicit #hash always wins — a bookmark or a link must land where it says.
|
|
// Otherwise: the setup flow for someone who has no sources yet and has not
|
|
// said "later", and profiles for everyone else. Which means the whole family,
|
|
// who all have sources already, never sees it.
|
|
var want=(location.hash||"").replace("#","");
|
|
if (TABS.some(function(t){return t[0]===want;})) showTab(want);
|
|
else showTab(setupDone() || onb().skipped ? "profiles" : "start");
|
|
}
|
|
|
|
// ── setup flow ───────────────────────────────────────────────────────────────
|
|
// Six steps in front of the tabs that already do the work. This screen owns no
|
|
// credentials and no crypto: steps 1, 5 and 6 hand off to Účet, Zdroje and
|
|
// Stáhnout. Re-implementing the vault here would mean two encryptors for one
|
|
// blob, which is how a vault gets corrupted.
|
|
//
|
|
// **Why the password is step 1 and not step 6.** Changing it re-keys the vault
|
|
// with a fresh salt, so every device already signed in holds a stale key until it
|
|
// authenticates again. Done first, while the vault is still empty, there is
|
|
// nothing to re-encrypt and no paired television to strand — and the password is
|
|
// already in memory from the sign-in that just happened, so it costs nothing.
|
|
//
|
|
// **What is stored and what is derived.** Only `skipped`, `chosen` and
|
|
// `pwChangedAt` live on the user record, because nothing else has to: whether the
|
|
// sources exist is `CONFIGS.length`, and whether TMDB is set is a field in the
|
|
// decrypted config. A second copy of a derivable fact eventually disagrees with
|
|
// the first, and then the flow either nags someone who has finished or
|
|
// congratulates someone who has not.
|
|
var PROVIDERS=[];
|
|
|
|
function onb(){
|
|
var o = USER && USER.onboarding;
|
|
return (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
|
|
}
|
|
async function saveOnb(patch){
|
|
var next = Object.assign({}, onb(), patch);
|
|
var r = await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
{ onboarding: next });
|
|
// Keep the screen truthful even if the write failed — the alternative is a
|
|
// checkbox that springs back with no explanation.
|
|
if (r.ok) USER = r.data; else USER.onboarding = next;
|
|
return r.ok;
|
|
}
|
|
|
|
// Sources exist at all. Deliberately not "is the config complete": a row
|
|
// existing is knowable without the password, so this still answers on a
|
|
// returning visit where the vault is locked.
|
|
function setupDone(){ return CONFIGS.length > 0; }
|
|
|
|
// The list only, no decryption — see above. `loadAddons` re-fetches and decrypts
|
|
// once the vault is unlocked.
|
|
async function loadConfigList(){
|
|
var r = await api("GET","/api/collections/addon_config/records?perPage=200");
|
|
if (r.ok) CONFIGS = r.data.items || [];
|
|
}
|
|
|
|
async function loadProviders(){
|
|
var r = await api("GET","/api/collections/providers/records?perPage=50&sort=sort"
|
|
+ "&filter=" + encodeURIComponent("enabled=true"));
|
|
PROVIDERS = r.ok ? (r.data.items||[]) : [];
|
|
}
|
|
|
|
// Jump to the code box in Zdroje. Locked vault: land on the section and let the
|
|
// viewer unlock, rather than reaching for a form that is not built yet.
|
|
function gotoCode(){
|
|
showTab("addons");
|
|
var setup=$("adSetup");
|
|
if (PASSWORD && setup && setup.className.indexOf("hidden") < 0) $("adFamily").click();
|
|
}
|
|
|
|
function stP(parent, text, cls){
|
|
var p=document.createElement("p"); p.textContent=text;
|
|
if (cls) p.className=cls; parent.appendChild(p); return p;
|
|
}
|
|
function stBtn(parent, label, cls, fn){
|
|
var b=document.createElement("button"); b.className="btn "+(cls||"ghost");
|
|
b.textContent=label; b.onclick=fn; parent.appendChild(b); return b;
|
|
}
|
|
function stLink(parent, label, href){
|
|
var a=document.createElement("a"); a.href=href; a.target="_blank";
|
|
a.rel="noopener noreferrer"; a.textContent=label;
|
|
a.style.cssText="display:inline-block;margin-top:10px;color:var(--amber);font-size:14px;font-weight:700";
|
|
parent.appendChild(a); return a;
|
|
}
|
|
function stList(parent, items, cls){
|
|
if (!Array.isArray(items) || !items.length) return null;
|
|
var ul=document.createElement("ul"); if (cls) ul.className=cls;
|
|
items.forEach(function(t){
|
|
var li=document.createElement("li"); li.textContent=String(t); ul.appendChild(li);
|
|
});
|
|
parent.appendChild(ul); return ul;
|
|
}
|
|
// A signup guide is an ORDERED list, and an `<ul>` here is not a styling
|
|
// preference — someone following it with the provider's form open in the next tab
|
|
// needs to know which step they are on, and bullets cannot tell them.
|
|
function stOl(parent, items){
|
|
if (!Array.isArray(items) || !items.length) return null;
|
|
var ol=document.createElement("ol"); ol.className="guide";
|
|
items.forEach(function(t){
|
|
var li=document.createElement("li"); li.textContent=String(t); ol.appendChild(li);
|
|
});
|
|
parent.appendChild(ol); return ol;
|
|
}
|
|
|
|
// One service, with the numbers and the honest downsides. Everything here is
|
|
// content from the `providers` collection, so a price is corrected in the admin
|
|
// UI and not in this file.
|
|
function provCard(p, chosen, onToggle){
|
|
var d=document.createElement("div");
|
|
d.className="prov"+(chosen?" on":"");
|
|
var top=document.createElement("div"); top.className="top";
|
|
var left=document.createElement("div");
|
|
var nm=document.createElement("b"); nm.textContent=p.name||p.slug;
|
|
left.appendChild(nm);
|
|
if (p.recommended){
|
|
var bd=document.createElement("span"); bd.className="badge";
|
|
bd.textContent="doporučeno"; left.appendChild(bd);
|
|
}
|
|
// prehraj.to's free month is the most persuasive fact in the whole flow —
|
|
// someone can reach a working Amber with Czech dubs for the kids without
|
|
// spending anything. It earns a badge rather than a line of small print.
|
|
if (/30 dní/i.test(p.priceNote||"")){
|
|
var tr=document.createElement("span"); tr.className="badge soft";
|
|
tr.textContent="30 dní zdarma"; left.appendChild(tr);
|
|
}
|
|
top.appendChild(left);
|
|
var pr=document.createElement("span"); pr.className="price";
|
|
pr.textContent=p.price||""; top.appendChild(pr);
|
|
d.appendChild(top);
|
|
if (p.tagline) stP(d, p.tagline, "hint");
|
|
stList(d, p.pros);
|
|
stList(d, p.cons, "cons");
|
|
if (p.priceNote) stP(d, p.priceNote, "hint");
|
|
if (p.priceCheckedAt){
|
|
stP(d, "Cena ověřena " + String(p.priceCheckedAt).slice(0,10) + ".", "hint");
|
|
}
|
|
var row=document.createElement("div"); row.className="check";
|
|
var cb=document.createElement("input"); cb.type="checkbox"; cb.checked=chosen;
|
|
cb.id="prov-"+p.slug;
|
|
var lb=document.createElement("label"); lb.style.margin="0";
|
|
lb.setAttribute("for", cb.id);
|
|
lb.textContent="Chci používat " + (p.name||p.slug);
|
|
cb.onchange=function(){ onToggle(p.slug, cb.checked); };
|
|
row.appendChild(cb); row.appendChild(lb); d.appendChild(row);
|
|
return d;
|
|
}
|
|
|
|
function stStep(n, title, state, buildBody){
|
|
var d=document.createElement("div");
|
|
d.className="step" + (state==="done" ? " done" : state==="now" ? " now" : "");
|
|
var h=document.createElement("h3");
|
|
var num=document.createElement("span"); num.className="num";
|
|
num.textContent = state==="done" ? "✓" : String(n);
|
|
h.appendChild(num); h.appendChild(document.createTextNode(title));
|
|
d.appendChild(h);
|
|
if (buildBody){
|
|
var b=document.createElement("div"); b.className="body";
|
|
buildBody(b, state);
|
|
if (b.childNodes.length) d.appendChild(b);
|
|
}
|
|
return d;
|
|
}
|
|
|
|
function renderStart(){
|
|
var wrap=$("stList"); if(!wrap) return;
|
|
wrap.innerHTML="";
|
|
var o=onb();
|
|
var chosen = Array.isArray(o.chosen) ? o.chosen.slice() : [];
|
|
var sources = PROVIDERS.filter(function(p){ return p.kind==="source"; });
|
|
var tmdb = PROVIDERS.filter(function(p){ return p.kind==="metadata"; })[0] || null;
|
|
|
|
var pwDone = !!o.pwChangedAt;
|
|
var pickDone = chosen.length > 0;
|
|
var srcDone = setupDone();
|
|
// Only answerable with the vault open. Locked, it reads as not-done, which
|
|
// shows a step that is already finished rather than hiding one that is not —
|
|
// the safe direction to be wrong in.
|
|
var tmdbDone = !!(cfg && cfg.tmdbKey);
|
|
|
|
var flags=[pwDone, pickDone, srcDone, tmdbDone, srcDone];
|
|
var current=flags.indexOf(false);
|
|
if (current<0) current=5; // everything done → the download step is live
|
|
function st(i, done){ return done ? "done" : (i===current ? "now" : "todo"); }
|
|
|
|
// 1 ── the password Richard generated
|
|
wrap.appendChild(stStep(1, "Změň si heslo", st(0, pwDone), function(b, state){
|
|
if (state==="done"){
|
|
stP(b, "Hotovo, heslo sis změnil " + String(o.pwChangedAt).slice(0,10) + ".", "hint");
|
|
return;
|
|
}
|
|
stP(b, "Přihlásil jsi se heslem, které ti vygeneroval Richard, takže ho zná. "
|
|
+ "Změň si ho teď, dokud u sebe nemáš nastavené zdroje: heslem se šifrují, "
|
|
+ "takže pozdější změna je nutí přešifrovat a odhlášená zařízení si na ně "
|
|
+ "musí říct znovu.");
|
|
stBtn(b, "Otevřít Účet", "primary", function(){ showTab("account"); });
|
|
}));
|
|
|
|
// 2 ── which services, at what price
|
|
wrap.appendChild(stStep(2, "Vyber si služby", st(1, pickDone), function(b, state){
|
|
if (state==="done"){
|
|
var names=chosen.map(function(s){
|
|
var p=PROVIDERS.filter(function(x){return x.slug===s;})[0];
|
|
return p ? (p.name||s) : s;
|
|
});
|
|
stP(b, "Vybral jsi: " + names.join(", ") + ".", "hint");
|
|
stBtn(b, "Změnit výběr", "ghost", async function(){
|
|
await saveOnb({ chosen: [] }); renderStart();
|
|
});
|
|
return;
|
|
}
|
|
if (!sources.length){
|
|
stP(b, "Seznam služeb se nepodařilo načíst. Zkus stránku obnovit. Zdroje "
|
|
+ "můžeš i tak nastavit ručně v sekci Zdroje.", "hint");
|
|
stBtn(b, "Otevřít Zdroje", "ghost", function(){ showTab("addons"); });
|
|
return;
|
|
}
|
|
stP(b, "Aspoň jedna stačí. Český host dělá české dabingy, TorBox dělá anime "
|
|
+ "a originální znění. Kdo chce obojí, vezme si obojí.");
|
|
// Someone Richard shares his accounts with has nothing to buy, and being walked
|
|
// through three price lists first would be actively misleading.
|
|
stP(b, "Máš od Richarda kód? Pak nekupuj nic, kód ti zdroje nastaví sám.",
|
|
"hint");
|
|
stBtn(b, "Mám kód", "ghost", gotoCode);
|
|
var picked=chosen.slice();
|
|
sources.forEach(function(p){
|
|
b.appendChild(provCard(p, picked.indexOf(p.slug)>=0, function(slug, on){
|
|
var i=picked.indexOf(slug);
|
|
if (on && i<0) picked.push(slug);
|
|
if (!on && i>=0) picked.splice(i,1);
|
|
}));
|
|
});
|
|
stBtn(b, "Pokračovat", "primary", async function(){
|
|
if (!picked.length){
|
|
setMsg($("stMsg"), "Vyber aspoň jednu službu.", "err"); return;
|
|
}
|
|
setMsg($("stMsg"), "");
|
|
await saveOnb({ chosen: picked });
|
|
renderStart();
|
|
});
|
|
}));
|
|
|
|
// 3 ── the part only they can do
|
|
wrap.appendChild(stStep(3, "Založ si účty a zaplať", st(2, srcDone), function(b, state){
|
|
if (state==="done"){
|
|
stP(b, "Hotovo, tvoje údaje fungují, takže účty máš.", "hint");
|
|
return;
|
|
}
|
|
var picks=sources.filter(function(p){ return chosen.indexOf(p.slug)>=0; });
|
|
if (!picks.length){ stP(b, "Nejdřív si vyber služby v kroku 2.", "hint"); return; }
|
|
stP(b, "U každé vybrané služby si založ účet a aktivuj předplatné. Odkazy a "
|
|
+ "postup máš níž. Otevřou se v novém okně, takže tuhle stránku "
|
|
+ "neztratíš.");
|
|
picks.forEach(function(p){
|
|
var h=document.createElement("h3"); h.style.marginTop="14px";
|
|
h.textContent=p.name||p.slug; b.appendChild(h);
|
|
if (p.price) stP(b, p.price + (p.priceNote ? ". " + p.priceNote : ""), "hint");
|
|
stOl(b, p.steps);
|
|
if (p.url) stLink(b, "Otevřít " + (p.name||p.slug), p.url);
|
|
});
|
|
}));
|
|
|
|
// 4 ── the free key nobody expects to need
|
|
wrap.appendChild(stStep(4, "Vezmi si klíč z TMDB", st(3, tmdbDone), function(b, state){
|
|
if (state==="done"){ stP(b, "Hotovo, klíč je uložený.", "hint"); return; }
|
|
if (!tmdb){
|
|
stP(b, "Postup se nepodařilo načíst. Klíč seženeš zdarma na "
|
|
+ "themoviedb.org a vloží se v sekci Zdroje.", "hint");
|
|
return;
|
|
}
|
|
if (tmdb.tagline) stP(b, tmdb.tagline);
|
|
stOl(b, tmdb.steps);
|
|
if (tmdb.url) stLink(b, "Otevřít TMDB", tmdb.url);
|
|
}));
|
|
|
|
// 5 ── hand off to the box that already does this
|
|
wrap.appendChild(stStep(5, "Vlož údaje do Amberu", st(4, srcDone), function(b, state){
|
|
if (state==="done"){
|
|
stP(b, "Hotovo, zdroje máš nastavené. Aplikace si je stáhne sama.", "hint");
|
|
stBtn(b, "Zkontrolovat Zdroje", "ghost", function(){ showTab("addons"); });
|
|
return;
|
|
}
|
|
stP(b, "V sekci Zdroje vyber „Mám vlastní účty u služeb“ a vyplň jméno, heslo "
|
|
+ "a klíče. Amber si z nich adresy zdrojů poskládá sám. Údaje jdou přímo "
|
|
+ "do doplňku a přes náš server neprojdou.");
|
|
stBtn(b, "Otevřít Zdroje", "primary", function(){ showTab("addons"); });
|
|
}));
|
|
|
|
// 6 ── no completion state: you can always want the app again
|
|
wrap.appendChild(stStep(6, "Stáhni Amber", srcDone ? "now" : "todo", function(b){
|
|
stP(b, "Nainstaluj Amber na televizi, telefon nebo počítač a přihlas se. "
|
|
+ "Televizi můžeš spárovat v sekci Zařízení, ať nemusíš heslo psát "
|
|
+ "ovladačem.");
|
|
stBtn(b, "Otevřít Stáhnout", srcDone ? "primary" : "ghost", function(){
|
|
showTab("download");
|
|
});
|
|
}));
|
|
|
|
// The banner is the way back for anyone who chose "později". Without it,
|
|
// skipping and finishing look identical and the flow is gone for good.
|
|
$("stBanner").className = (!srcDone && o.skipped) ? "warn" : "warn hidden";
|
|
}
|
|
|
|
$("stLater").onclick=async function(){
|
|
await saveOnb({ skipped:true });
|
|
renderStart();
|
|
showTab("profiles");
|
|
};
|
|
$("stResume").onclick=async function(){
|
|
await saveOnb({ skipped:false });
|
|
renderStart();
|
|
showTab("start");
|
|
};
|
|
|
|
// ── profiles ─────────────────────────────────────────────────────────────────
|
|
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
|
|
fifteen:"15+", unrestricted:"Bez omezení" };
|
|
async function loadProfiles(){
|
|
var r=await api("GET","/api/collections/profiles/records?perPage=200&sort=created");
|
|
PROFILES = r.ok ? (r.data.items||[]) : [];
|
|
var ul=$("profileList"); ul.innerHTML="";
|
|
PROFILES.forEach(function(p){
|
|
var li=document.createElement("li");
|
|
li.innerHTML="<b>"+esc(p.name||"Profil")+"</b>"+
|
|
'<div class="meta">'+(p.isChild?"dětský · ":"")+
|
|
(TIERS[p.maxRating||""]||p.maxRating)+"</div>";
|
|
ul.appendChild(li);
|
|
});
|
|
var sels=[$("pfSelect"), $("prProfile")];
|
|
sels.forEach(function(sel){
|
|
var keep=sel.value; sel.innerHTML="";
|
|
PROFILES.forEach(function(p){
|
|
var o=document.createElement("option"); o.value=p.id;
|
|
o.textContent=p.name||"Profil"; sel.appendChild(o);
|
|
});
|
|
if (keep) sel.value=keep;
|
|
});
|
|
if (PROFILES.length){ fillProfileForm(); await loadPrefs(); }
|
|
}
|
|
function esc(s){ return String(s).replace(/[<>&"]/g,function(c){
|
|
return ({"<":"<",">":">","&":"&",'"':"""})[c]; }); }
|
|
function currentProfile(){ return PROFILES.filter(function(p){
|
|
return p.id===$("pfSelect").value; })[0] || PROFILES[0]; }
|
|
function fillProfileForm(){
|
|
var p=currentProfile(); if(!p) return;
|
|
$("pfName2").value=p.name||""; $("pfRating").value=p.maxRating||"";
|
|
$("pfChild").checked=!!p.isChild; $("pfPin").value="";
|
|
}
|
|
$("pfSelect").onchange=fillProfileForm;
|
|
|
|
$("pfSave").onclick=async function(){
|
|
var p=currentProfile(); if(!p) return;
|
|
var pin=$("pfPin").value.trim();
|
|
if (pin && !/^\d{4}$/.test(pin)){ setMsg($("pfMsg"),"PIN musí být 4 číslice.","err"); return; }
|
|
busy($("pfSave"),true,"Ukládám…"); setMsg($("pfMsg"),"");
|
|
var r=await api("PATCH","/api/collections/profiles/records/"+p.id, {
|
|
name: $("pfName2").value.trim() || "Profil",
|
|
isChild: $("pfChild").checked,
|
|
maxRating: $("pfRating").value,
|
|
});
|
|
if (r.ok && pin){
|
|
// pinHash is a hidden field — PocketBase silently drops a client PATCH to
|
|
// it, so the PIN must go through the server route that hashes it.
|
|
var pr=await api("POST","/api/amber/set-pin",{ profileId:p.id, pin:pin });
|
|
if (!pr.ok) r={ok:false};
|
|
}
|
|
busy($("pfSave"),false);
|
|
setMsg($("pfMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadProfiles();
|
|
};
|
|
|
|
// ── playback prefs ───────────────────────────────────────────────────────────
|
|
var LANGS=[["","Beze změny"],["cze","Čeština"],["slo","Slovenština"],
|
|
["eng","Angličtina"],["jpn","Japonština"],["ger","Němčina"],["pol","Polština"]];
|
|
function fillLangs(){
|
|
document.querySelectorAll("select.lang").forEach(function(sel){
|
|
sel.innerHTML="";
|
|
LANGS.forEach(function(l){
|
|
if (l[0]==="" ) return;
|
|
var o=document.createElement("option"); o.value=l[0]; o.textContent=l[1];
|
|
sel.appendChild(o);
|
|
});
|
|
var none=document.createElement("option");
|
|
none.value=""; none.textContent = sel.classList.contains("sub") ? "Žádné" : "Bez preference";
|
|
sel.insertBefore(none, sel.firstChild);
|
|
if (sel.classList.contains("sub")){
|
|
var off=document.createElement("option");
|
|
off.value="off"; off.textContent="Vypnuté"; sel.appendChild(off);
|
|
}
|
|
});
|
|
}
|
|
async function loadPrefs(){
|
|
var pid=$("prProfile").value || (PROFILES[0]&&PROFILES[0].id);
|
|
if(!pid) return;
|
|
var r=await api("GET","/api/collections/prefs/records?perPage=1&filter="+
|
|
encodeURIComponent('profile="'+pid+'"'));
|
|
var rec=(r.ok && r.data.items && r.data.items[0]) || null;
|
|
PREFS[pid]=rec;
|
|
var d=(rec && rec.data) || {};
|
|
$("prAudio").value=d.audioLanguage||"cze";
|
|
$("prAudio2").value=d.audioLanguage2||"eng";
|
|
$("prSub").value=d.subtitleLanguage||"cze";
|
|
$("prSub2").value=d.subtitleLanguage2||"eng";
|
|
$("prAnimeAudio").value=d.animeAudioLanguage||"jpn";
|
|
$("prAnimeSub").value=d.animeSubtitleLanguage||"eng";
|
|
$("prRes").value=d.preferredResolution ? String(d.preferredResolution) : "";
|
|
$("prHighest").checked=d.preferHighestQuality!==false;
|
|
$("prSigns").checked=!!d.combineSignsAndDialogue;
|
|
$("prFeedback").value=d.feedbackPrompt||"sometimes";
|
|
}
|
|
$("prProfile").onchange=loadPrefs;
|
|
|
|
$("prSave").onclick=async function(){
|
|
var pid=$("prProfile").value; if(!pid) return;
|
|
busy($("prSave"),true,"Ukládám…"); setMsg($("prMsg"),"");
|
|
var rec=PREFS[pid];
|
|
var base=(rec && rec.data) || {};
|
|
// Merge, never replace: the app writes fields this page does not show yet, and
|
|
// a whole-object PUT would silently drop them.
|
|
var data=Object.assign({}, base, {
|
|
appLanguage: base.appLanguage || "cs",
|
|
audioLanguage: $("prAudio").value || null,
|
|
audioLanguage2: $("prAudio2").value || null,
|
|
subtitleLanguage: $("prSub").value || null,
|
|
subtitleLanguage2: $("prSub2").value || null,
|
|
animeAudioLanguage: $("prAnimeAudio").value || null,
|
|
animeSubtitleLanguage: $("prAnimeSub").value || null,
|
|
animeAudioLanguage2: base.animeAudioLanguage2 || "eng",
|
|
animeSubtitleLanguage2: base.animeSubtitleLanguage2 || "cze",
|
|
preferredResolution: $("prRes").value ? parseInt($("prRes").value,10) : null,
|
|
preferHighestQuality: $("prHighest").checked,
|
|
combineSignsAndDialogue: $("prSigns").checked,
|
|
feedbackPrompt: $("prFeedback").value,
|
|
});
|
|
// updatedAt is the LWW clock the app compares on; the server's `updated` is
|
|
// only the pull cursor. Omitting this would make the edit lose to a stale
|
|
// device, or clobber a newer one unpredictably.
|
|
var body={ profile:pid, data:data, updatedAt:new Date().toISOString() };
|
|
var r = rec
|
|
? await api("PATCH","/api/collections/prefs/records/"+rec.id, body)
|
|
: await api("POST","/api/collections/prefs/records", body);
|
|
busy($("prSave"),false);
|
|
setMsg($("prMsg"), r.ok?"Uloženo. Zařízení si to stáhnou při dalším spuštění."
|
|
:"Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadPrefs();
|
|
};
|
|
|
|
// ── addons ───────────────────────────────────────────────────────────────────
|
|
function renderAddonGate(){
|
|
var have = !!PASSWORD;
|
|
$("adNeedPass").className = have ? "hidden" : "";
|
|
$("adBody").className = have ? "" : "hidden";
|
|
if (have) loadAddons();
|
|
}
|
|
$("adUnlock").onclick=async function(){
|
|
var p=$("adPass").value; if(!p){ return; }
|
|
busy($("adUnlock"),true,"Ověřuji…");
|
|
// Verify the password by authenticating, rather than by trying to decrypt —
|
|
// a wrong password and an absent config look identical to the decrypt path.
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:USER.email, password:p }, {anon:true});
|
|
busy($("adUnlock"),false);
|
|
if(!r.ok){ setMsg($("adMsg"),"Špatné heslo.","err"); return; }
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=p; saveSession();
|
|
setMsg($("adMsg"),""); $("adPass").value="";
|
|
renderAddonGate();
|
|
$("acChange").disabled=false;
|
|
};
|
|
|
|
async function loadAddons(){
|
|
var r=await api("GET","/api/collections/addon_config/records?perPage=200");
|
|
CONFIGS = r.ok ? (r.data.items||[]) : [];
|
|
cfgRec = CONFIGS[0] || null;
|
|
cfg=null;
|
|
if (cfgRec){
|
|
try { cfg=JSON.parse(await decryptBlob(cfgRec.blob, PASSWORD, cfgRec.salt)); }
|
|
catch(_){ cfg=null; }
|
|
}
|
|
var configured = cfg && (cfg.addonUrl || cfg.czechAddonUrl);
|
|
$("adSetup").className = configured ? "hidden" : "";
|
|
$("adEdit").className = configured ? "" : "hidden";
|
|
$("adFamilyBox").className="hidden"; $("adByocBox").className="hidden";
|
|
if (configured){
|
|
$("adTorbox").value=cfg.addonUrl||"";
|
|
$("adCzech").value=cfg.czechAddonUrl||"";
|
|
$("adTmdb").value=cfg.tmdbKey||"";
|
|
if (USER && USER.nsfwEnabled){
|
|
$("adAdultBox").className="";
|
|
$("adAdult").value=cfg.adultAddonUrl||"";
|
|
}
|
|
}
|
|
$("acChange").disabled = !PASSWORD;
|
|
// Unlocking or saving here is what finishes steps 4 and 5, so the flow has to
|
|
// be told. Cheap: it only re-renders one hidden section.
|
|
renderStart();
|
|
}
|
|
$("adFamily").onclick=function(){ $("adSetup").className="hidden"; $("adFamilyBox").className=""; };
|
|
$("adByoc").onclick=function(){ $("adSetup").className="hidden"; $("adByocBox").className=""; };
|
|
document.querySelectorAll(".adBack").forEach(function(b){
|
|
b.onclick=function(){ $("adFamilyBox").className="hidden";
|
|
$("adByocBox").className="hidden"; $("adSetup").className=""; setMsg($("adMsg"),""); };
|
|
});
|
|
$("adRedo").onclick=function(){ $("adEdit").className="hidden"; $("adSetup").className=""; };
|
|
|
|
// family code → the shared template
|
|
$("adCodeGo").onclick=async function(){
|
|
var code=$("adCode").value.trim();
|
|
if(!code){ setMsg($("adMsg"),"Zadej kód.","err"); return; }
|
|
busy($("adCodeGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
var r=await api("GET","/api/amber/onboarding-template?code="+encodeURIComponent(code));
|
|
if(!r.ok){
|
|
busy($("adCodeGo"),false);
|
|
setMsg($("adMsg"), r.status===403 ? "Neplatný kód."
|
|
: r.status===429 ? "Moc pokusů, zkus to za chvíli."
|
|
: "Zdroje se nepodařilo načíst.","err");
|
|
return;
|
|
}
|
|
await saveConfig({ addonUrl:r.data.addonUrl||"", czechAddonUrl:r.data.czechAddonUrl||"",
|
|
tmdbKey:r.data.tmdbKey||"" }, $("adCodeGo"));
|
|
};
|
|
|
|
// BYOC → mint the URLs against the addons themselves
|
|
$("byGo").onclick=async function(){
|
|
var ptU=$("byPtUser").value.trim(), ptP=$("byPtPass").value;
|
|
var wsU=$("byWsUser").value.trim(), wsP=$("byWsPass").value;
|
|
var tb=$("byTbKey").value.trim(), tmdb=$("byTmdb").value.trim();
|
|
// Each host is all-or-nothing, and at least one must be complete — the same
|
|
// rule the addon's own /encode enforces, checked here so the message is Czech.
|
|
if ((ptU && !ptP) || (!ptU && ptP)){
|
|
setMsg($("adMsg"),"U prehraj.to vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
|
return;
|
|
}
|
|
if ((wsU && !wsP) || (!wsU && wsP)){
|
|
setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
|
return;
|
|
}
|
|
// **One source of any kind is enough — a Czech host or TorBox.** This used to
|
|
// demand a Czech host, which made TorBox-only impossible to save even though
|
|
// nothing downstream requires a Czech addon: `CompositeStreamProvider` takes
|
|
// whatever is configured, and the Czech-specific paths key off a source's
|
|
// `providerId`, so they simply never fire. Someone who only watches anime or
|
|
// original-audio releases was being told to buy a service they had no use for.
|
|
var hasCzech = (ptU && ptP) || (wsU && wsP);
|
|
if (!hasCzech && !tb){
|
|
setMsg($("adMsg"),
|
|
"Vyplň aspoň jeden zdroj: českého hosta, nebo TorBox.","err");
|
|
return;
|
|
}
|
|
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
var out={ tmdbKey:tmdb };
|
|
// Only ask the Czech addon for a token when there are credentials for it.
|
|
// Calling /encode with nothing is a guaranteed 400, which is what made the
|
|
// TorBox-only case fail with "Český doplněk odmítl údaje" — an error about a
|
|
// service the person had deliberately left blank.
|
|
if (hasCzech){
|
|
try {
|
|
// Straight to the addon. Both send permissive CORS, so the credentials go
|
|
// from this browser to the addon and never through our server.
|
|
var enc=await fetch(PREHRAJTO_BASE+"/encode",{
|
|
method:"POST", headers:{"Content-Type":"application/json"},
|
|
body:JSON.stringify({ username:ptU||"", password:ptP||"",
|
|
wsUsername:wsU||"", wsPassword:wsP||"" })});
|
|
var ed=await enc.json();
|
|
if(!enc.ok || !ed.token) throw new Error(ed.error||"encode failed");
|
|
out.czechAddonUrl = PREHRAJTO_BASE+"/"+ed.token+"/manifest.json";
|
|
} catch(err){
|
|
busy($("byGo"),false);
|
|
setMsg($("adMsg"),"Český doplněk odmítl údaje: "+err.message,"err");
|
|
return;
|
|
}
|
|
}
|
|
if (tb){
|
|
// The TorBox addon has no /encode — its config token is base64url of the
|
|
// same JSON its own configure page builds.
|
|
out.addonUrl = TORBOX_BASE+"/"+b64url(JSON.stringify({
|
|
torboxApiKey: tb, language: "cs-CZ" }));
|
|
}
|
|
await saveConfig(out, $("byGo"));
|
|
};
|
|
|
|
async function saveConfig(next, btn){
|
|
var merged=Object.assign({}, cfg||{}, next);
|
|
var salt=(cfgRec && cfgRec.salt) || newSaltB64();
|
|
var blob=await encryptWithSalt(JSON.stringify(merged), PASSWORD, salt);
|
|
var body={ blob:blob, salt:salt, kdf:KDF_ID, updatedAt:new Date().toISOString() };
|
|
var r;
|
|
if (cfgRec){
|
|
r=await api("PATCH","/api/collections/addon_config/records/"+cfgRec.id, body);
|
|
} else {
|
|
if (!PROFILES.length){ setMsg($("adMsg"),"Účet nemá profil.","err");
|
|
if(btn) busy(btn,false); return; }
|
|
body.profile=PROFILES[0].id;
|
|
r=await api("POST","/api/collections/addon_config/records", body);
|
|
}
|
|
if(btn) busy(btn,false);
|
|
setMsg($("adMsg"), r.ok ? "Zdroje uloženy. Aplikace si je stáhne sama."
|
|
: "Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadAddons();
|
|
}
|
|
|
|
$("adSave").onclick=async function(){
|
|
busy($("adSave"),true,"Ukládám…");
|
|
var next={ addonUrl:$("adTorbox").value.trim(),
|
|
czechAddonUrl:$("adCzech").value.trim(),
|
|
tmdbKey:$("adTmdb").value.trim() };
|
|
if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim();
|
|
await saveConfig(next, $("adSave"));
|
|
};
|
|
|
|
// ── devices ──────────────────────────────────────────────────────────────────
|
|
async function loadDevices(){
|
|
var r=await api("GET","/api/collections/device_auth/records?perPage=200&sort=-created");
|
|
var ul=$("deviceList"); ul.innerHTML="";
|
|
var items=(r.ok && r.data.items) || [];
|
|
if(!items.length){ ul.innerHTML='<li class="meta">Zatím žádná zařízení.</li>'; return; }
|
|
items.forEach(function(d){
|
|
var li=document.createElement("li");
|
|
var wrap=document.createElement("div"); wrap.className="rowbtn";
|
|
var info=document.createElement("div");
|
|
info.innerHTML="<b>"+esc(d.deviceName||"Zařízení")+"</b>"+
|
|
'<div class="meta">'+esc(d.status||"")+" · "+String(d.created||"").slice(0,10)+"</div>";
|
|
var btn=document.createElement("button"); btn.className="danger"; btn.textContent="Odebrat";
|
|
btn.onclick=async function(){
|
|
busy(btn,true,"…");
|
|
var dr=await api("DELETE","/api/collections/device_auth/records/"+d.id);
|
|
setMsg($("dvMsg"), dr.ok?"Zařízení odebráno.":"Odebrání selhalo.", dr.ok?"ok":"err");
|
|
await loadDevices();
|
|
};
|
|
wrap.appendChild(info); wrap.appendChild(btn); li.appendChild(wrap); ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
// ── downloads ────────────────────────────────────────────────────────────────
|
|
async function loadDownloads(){
|
|
var box=$("dlList"); box.innerHTML="";
|
|
var plats=[["android","Android TV / telefon"],["windows","Windows"],["linux","Linux"]];
|
|
var any=false;
|
|
for (var i=0;i<plats.length;i++){
|
|
var p=plats[i];
|
|
var m=await api("GET","/api/update/manifest?platform="+p[0]);
|
|
var li=document.createElement("li");
|
|
li.style.cssText="padding:12px;border:1px solid var(--line);border-radius:10px;"+
|
|
"margin-bottom:8px;background:var(--sunk);font-size:14px;list-style:none";
|
|
if (m.ok && m.data && m.data.available){
|
|
// **The token is minted when the button is pressed, not when this list is
|
|
// built.** `releases.file` is protected, so the path needs a file token, and a
|
|
// file token lives 180 seconds — measured, not assumed. Baking one into the
|
|
// href at render time meant every download button died three minutes after
|
|
// the page loaded, permanently, until someone thought to reload. PocketBase
|
|
// answers an expired token with 404 "The requested resource wasn't found.",
|
|
// which reads like the file is gone rather than like "press it again".
|
|
//
|
|
// That is not a hypothetical: a friend hit it on the Windows build. The setup
|
|
// flow makes it near-certain, because it walks somebody through buying
|
|
// subscriptions and fetching a TMDB key before it points them here, which is
|
|
// many minutes, and the download tab may have been rendered at sign-in.
|
|
li.innerHTML='<div class="rowbtn"><div><b>'+esc(p[1])+'</b><div class="meta">'+
|
|
esc(m.data.version||"")+" · "+esc(m.data.variant||"")+'</div></div>'+
|
|
'<button class="btn primary" style="width:auto;padding:9px 14px;margin:0" '+
|
|
'data-dl="'+esc(m.data.downloadPath)+'">Stáhnout</button></div>';
|
|
any=true;
|
|
} else {
|
|
li.innerHTML="<b>"+esc(p[1])+'</b><div class="meta">zatím nic ke stažení</div>';
|
|
}
|
|
box.appendChild(li);
|
|
}
|
|
// Wired after the rows exist, so each press mints its own token. `download=1`
|
|
// makes PocketBase send the file as an attachment instead of leaving the browser
|
|
// to decide what to do with a .zip or an .exe.
|
|
Array.prototype.forEach.call(box.querySelectorAll("button[data-dl]"), function(b){
|
|
b.onclick=async function(){
|
|
busy(b,true,"Připravuji…");
|
|
var t=await api("POST","/api/files/token",null);
|
|
busy(b,false);
|
|
if(!(t.ok && t.data && t.data.token)){
|
|
setMsg($("dlMsg"),"Odkaz se nepodařilo vytvořit. Zkus to prosím znovu.","err");
|
|
return;
|
|
}
|
|
setMsg($("dlMsg"),"");
|
|
window.location.href = b.getAttribute("data-dl") +
|
|
"?token=" + encodeURIComponent(t.data.token) + "&download=1";
|
|
};
|
|
});
|
|
if (!any) setMsg($("dlMsg"),"Zatím není publikovaná žádná verze.","");
|
|
}
|
|
|
|
// ── account ──────────────────────────────────────────────────────────────────
|
|
$("acSaveRating").onclick=async function(){
|
|
busy($("acSaveRating"),true,"Ukládám…");
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
{ ratingDefault: $("acRating").value });
|
|
busy($("acSaveRating"),false);
|
|
if (r.ok) USER=r.data;
|
|
setMsg($("acMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
};
|
|
|
|
$("acChange").onclick=async function(){
|
|
var oldP=$("acOld").value, n1=$("acNew").value, n2=$("acNew2").value;
|
|
if (!PASSWORD){ setMsg($("acMsg"),"Nejdřív odemkni Zdroje, jinak přijdeš o nastavení zdrojů.","err"); return; }
|
|
if (n1.length<8){ setMsg($("acMsg"),"Nové heslo musí mít aspoň 8 znaků.","err"); return; }
|
|
if (n1!==n2){ setMsg($("acMsg"),"Nová hesla se neshodují.","err"); return; }
|
|
busy($("acChange"),true,"Měním…"); setMsg($("acMsg"),"");
|
|
|
|
// Re-key BEFORE changing the password: if the PATCH succeeded and the
|
|
// re-encrypt then failed, the vault would be unreadable by every device and
|
|
// the family would lose their sources. Doing it in this order means a failure
|
|
// here leaves everything exactly as it was.
|
|
var reEncrypted=[];
|
|
try {
|
|
for (var i=0;i<CONFIGS.length;i++){
|
|
var rec=CONFIGS[i];
|
|
var clear=await decryptBlob(rec.blob, oldP, rec.salt);
|
|
var salt=newSaltB64(); // new password ⇒ new key ⇒ new salt
|
|
reEncrypted.push({ id:rec.id,
|
|
blob: await encryptWithSalt(clear, n1, salt), salt: salt });
|
|
}
|
|
} catch(_){
|
|
busy($("acChange"),false);
|
|
setMsg($("acMsg"),"Současné heslo nesedí ke tvým zdrojům. Heslo jsem nezměnil.","err");
|
|
return;
|
|
}
|
|
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
{ oldPassword:oldP, password:n1, passwordConfirm:n2 });
|
|
if(!r.ok){
|
|
busy($("acChange"),false);
|
|
setMsg($("acMsg"), r.status===400 ? "Současné heslo nesedí."
|
|
: "Změna hesla selhala.","err");
|
|
return;
|
|
}
|
|
// The password change invalidates the token; re-authenticate before writing.
|
|
var a=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:USER.email, password:n1 }, {anon:true});
|
|
if (a.ok){
|
|
TOKEN=a.data.token; USER=a.data.record; PASSWORD=n1; saveSession();
|
|
// The setup flow's first step is "replace the password Richard generated",
|
|
// and PocketBase keeps no password-changed timestamp — `updated` moves for any
|
|
// edit at all. So this write is the only thing that can ever answer it, and it
|
|
// has to happen here, after the re-auth, or the PATCH goes out on a token the
|
|
// password change already invalidated.
|
|
await saveOnb({ pwChangedAt: new Date().toISOString() });
|
|
}
|
|
var failed=0;
|
|
for (var j=0;j<reEncrypted.length;j++){
|
|
var e=reEncrypted[j];
|
|
var ur=await api("PATCH","/api/collections/addon_config/records/"+e.id,
|
|
{ blob:e.blob, salt:e.salt, kdf:KDF_ID, updatedAt:new Date().toISOString() });
|
|
if(!ur.ok) failed++;
|
|
}
|
|
busy($("acChange"),false);
|
|
$("acOld").value=$("acNew").value=$("acNew2").value="";
|
|
setMsg($("acMsg"), failed
|
|
? "Heslo změněno, ale "+failed+" nastavení zdrojů se nepodařilo přešifrovat. Otevři Zdroje a ulož je znovu."
|
|
: "Heslo změněno a zdroje přešifrovány.", failed?"err":"ok");
|
|
await loadAddons();
|
|
};
|
|
|
|
// ── setting a new password from an emailed link ──────────────────────────────
|
|
|
|
/** The token the reset email put in the address bar, or null. */
|
|
function resetToken(){
|
|
try { return new URLSearchParams(location.search).get("reset"); }
|
|
catch(_){ return null; }
|
|
}
|
|
|
|
function showReset(){
|
|
$("anon").hidden = true;
|
|
$("reset").hidden = false;
|
|
$("rNew").focus();
|
|
}
|
|
|
|
$("rSave").onclick=async function(){
|
|
var tok=resetToken();
|
|
var a=$("rNew").value, b=$("rNew2").value;
|
|
if(a.length<8){ setMsg($("rMsg"),"Heslo musí mít aspoň 8 znaků.","err"); return; }
|
|
if(a!==b){ setMsg($("rMsg"),"Hesla se neshodují.","err"); return; }
|
|
setMsg($("rMsg"),""); busy($("rSave"),true,"Nastavuji…");
|
|
var r=await api("POST","/api/collections/users/confirm-password-reset",
|
|
{ token:tok, password:a, passwordConfirm:b }, {anon:true});
|
|
busy($("rSave"),false);
|
|
if(!r.ok){
|
|
// The overwhelmingly likely cause, and the one worth naming: these links
|
|
// expire, and a person who tried yesterday's email has no way to know that
|
|
// from "something went wrong".
|
|
setMsg($("rMsg"),"Nepovedlo se. Odkaz nejspíš vypršel, požádej o nový.","err");
|
|
return;
|
|
}
|
|
$("rNew").value=$("rNew2").value="";
|
|
setMsg($("rMsg"),"Hotovo. Přihlas se novým heslem.","ok");
|
|
// Drop the token from the address bar: it is spent, and a URL with a
|
|
// credential in it is one that ends up in a bookmark or a shared screenshot.
|
|
try { history.replaceState(null,"",location.pathname); } catch(_){}
|
|
setTimeout(function(){ $("reset").hidden=true; $("anon").hidden=false; }, 2500);
|
|
};
|
|
|
|
// ── confirming an email address from an emailed link ─────────────────────────
|
|
|
|
/** The token the verification email put in the address bar, or null. */
|
|
function verifyToken(){
|
|
try { return new URLSearchParams(location.search).get("verify"); }
|
|
catch(_){ return null; }
|
|
}
|
|
|
|
async function showVerify(){
|
|
$("anon").hidden = true;
|
|
$("verify").hidden = false;
|
|
var r = await api("POST","/api/collections/users/confirm-verification",
|
|
{ token: verifyToken() }, {anon:true});
|
|
// Spend the token out of the address bar either way: it is single use, and a
|
|
// URL carrying one is a URL that ends up in a bookmark or a screenshot.
|
|
try { history.replaceState(null,"",location.pathname); } catch(_){}
|
|
if(!r.ok){
|
|
// Worth saying plainly that nothing is broken: verification gates nothing,
|
|
// so a burnt or expired link costs the person exactly nothing.
|
|
$("vSub").textContent="Odkaz nezabral.";
|
|
// Neutral, not red: the sentence says nothing is wrong, and a red one
|
|
// saying "never mind" reads to a parent as a problem they have to solve.
|
|
setMsg($("vMsg"),"Nejspíš už vypršel. Nevadí, k přihlášení ověření "+
|
|
"potřeba není.");
|
|
} else {
|
|
$("vSub").textContent="Hotovo, e-mail je ověřený.";
|
|
setMsg($("vMsg"),"Můžeš se přihlásit.","ok");
|
|
}
|
|
$("vDone").hidden=false;
|
|
}
|
|
|
|
$("vDone").onclick=async function(){
|
|
$("verify").hidden=true;
|
|
if (await restore()) { await enter(); return; }
|
|
$("anon").hidden=false;
|
|
};
|
|
|
|
// ── boot ─────────────────────────────────────────────────────────────────────
|
|
(async function(){
|
|
fillLangs();
|
|
$("tvUrl").textContent=location.host;
|
|
// A reset link wins over a stored session: somebody arriving with one is
|
|
// trying to fix their account, and dropping them into a signed-in page they
|
|
// did not ask for hides the very thing they came to do.
|
|
if (resetToken()) { showReset(); return; }
|
|
if (verifyToken()) { await showVerify(); return; }
|
|
if (await restore()) await enter();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|