amber-backend/pb_public/index.html
Claude fb59f504fe Czech account emails, and a reset that finishes on our own domain
The reset email works — confirmed by a delivered message, after every attempt on
record had failed at the lookup and the mail path had never once been exercised.
What the delivered message showed was a different problem: it was in English,
and its button pointed at `pb.petruzalekr.cz/_/#/auth/confirm-password-reset/…`,
which is PocketBase's admin console. Every other surface of this app is Czech on
purpose, because the people using it are the owner's parents and his children —
and the one moment they are most likely to be stuck is the one moment it started
speaking English and sent them to what reads like a developer tool.

Found by looking at a delivered email rather than at the settings. The templates
were simply PocketBase's defaults, and a default is invisible until somebody
receives one.

The reset link now lands on amber.petruzalekr.cz, which hosts the form: two
fields, in Czech, on the domain people already know. A reset link wins over a
stored session, because somebody arriving with one is trying to fix their
account and dropping them into a signed-in page hides the thing they came to do.
The token is stripped from the address bar once spent — a URL carrying a
credential is one that ends up in a bookmark or a screenshot.

A failed confirm names the likely cause. These links expire, and "something went
wrong" gives somebody who opened yesterday's email no way to know that.

Verification and email-change keep PocketBase's own confirm pages: nobody has
used those, they are not part of the report, and pointing them at a page that
does not exist would be worse than English.

As a migration rather than a click in the admin UI, so restoring the backend
from migrations does not quietly put English back.
2026-09-05 19:04:50 +02:00

1426 lines
68 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; }
.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>
<!-- ── 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, 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">Zvuk — hlavní</label>
<select id="prAudio" class="lang"></select></div>
<div><label for="prAudio2">Zvuk — záložní</label>
<select id="prAudio2" class="lang"></select></div>
</div>
<div class="row">
<div><label for="prSub">Titulky — hlavní</label>
<select id="prSub" class="lang sub"></select></div>
<div><label for="prSub2">Titulky — záložní</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">Anime — zvuk</label>
<select id="prAnimeAudio" class="lang"></select></div>
<div><label for="prAnimeSub">Anime — titulky</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 —
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 ({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"})[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);
};
// ── 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 (await restore()) await enter();
})();
</script>
</body>
</html>