amber-backend/pb_public/index.html
Claude 2b458fb9f6 Let people ask for a new password on the website, and stop capitals breaking it
Two halves of the same report. A family member could not get in, four password
resets produced nothing, and the site offered no way to ask for one anyway.

**Capitals.** The web sign-in trimmed the address but did not lower-case it, and
PocketBase looks accounts up case-sensitively — so `Vojta.Markup@seznam.cz` for
an account stored in lowercase matched nothing and read as a wrong password.
Phones capitalise the first letter of a text field by themselves, so this is the
mistake people will actually make. The same fix went into the app.

**The reset itself.** A link under the sign-in button, 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.

The message it shows says what was *done*, never whether the address matched.
The endpoint deliberately answers the same either way so that nobody can use it
to discover which addresses are registered — and pretending otherwise is exactly
what made this so hard to read from outside: four attempts, four "sent", nothing
arriving, no way to tell that the lookup had simply found nothing. It also
points at the one thing that always works when mail does not, which is asking
for it to be set directly.

Whether delivery itself works is still unverified: every reset attempt on record
failed at the lookup, so the mail path has never once been exercised. A reset
has been triggered against a real account to settle that.
2026-09-05 19:01:02 +02:00

1365 lines
65 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">
<!-- ── 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();
};
// ── boot ─────────────────────────────────────────────────────────────────────
(async function(){
fillLangs();
$("tvUrl").textContent=location.host;
if (await restore()) await enter();
})();
</script>
</body>
</html>