880 lines
42 KiB
HTML
880 lines
42 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; }
|
||
|
|
</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">Úč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 aplikaci <b>Downloader</b>.</li>
|
||
|
|
<li>Zadej <code class="url" id="tvUrl">amber.petruzalekr.cz/tv</code> —
|
||
|
|
stáhne se instalace.</li>
|
||
|
|
<li>Spusť Amber → <b>Přihlásit z jiného zařízení</b> a načti QR kód
|
||
|
|
telefonem. Na televizi nic nepíšeš.</li>
|
||
|
|
</ol>
|
||
|
|
</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>
|
||
|
|
|
||
|
|
<nav id="nav"></nav>
|
||
|
|
|
||
|
|
<!-- 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">
|
||
|
|
<label for="pfChild" style="margin:0">Dětský profil (skryje anime a 18+,
|
||
|
|
na odchod chce PIN)</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 rodinný kód</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">Rodinný kód</label>
|
||
|
|
<input id="adCode" type="text" autocomplete="off" spellcheck="false">
|
||
|
|
<p class="hint">Kód dostaneš od Richarda. Nastaví zdroje rodiny za tebe.</p>
|
||
|
|
<button id="adCodeGo" class="btn primary">Použít rodinný 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</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">Povinné — český doplněk stojí na prehraj.to a bez něj
|
||
|
|
se nespustí. webshare se přidává k němu, samostatně zatím nefunguje.</p>
|
||
|
|
|
||
|
|
<h2 style="margin-top:18px">webshare.cz <span style="color:var(--muted);font-weight:400">— volitelné</span></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. 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 v aplikaci <b>Downloader</b> zadej
|
||
|
|
<code class="url">amber.petruzalekr.cz/tv</code>.</li>
|
||
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR telefonem.</li>
|
||
|
|
</ol>
|
||
|
|
<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;
|
||
|
|
try{ localStorage.removeItem(LS); }catch(_){}
|
||
|
|
$("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(){
|
||
|
|
var email=$("email").value.trim(), 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();
|
||
|
|
};
|
||
|
|
$("logoutBtn").onclick=signOut;
|
||
|
|
|
||
|
|
// ── shell ────────────────────────────────────────────────────────────────────
|
||
|
|
var TABS=[["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()]);
|
||
|
|
$("acEmail").value=(USER&&USER.email)||"";
|
||
|
|
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
||
|
|
renderAddonGate();
|
||
|
|
var want=(location.hash||"").replace("#","");
|
||
|
|
showTab(TABS.some(function(t){return t[0]===want;}) ? want : "profiles");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── profiles ─────────────────────────────────────────────────────────────────
|
||
|
|
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
|
||
|
|
fifteen:"15+", unrestricted:"Bez omezení" };
|
||
|
|
async function loadProfiles(){
|
||
|
|
var r=await api("GET","/api/collections/profiles/records?perPage=200&sort=created");
|
||
|
|
PROFILES = r.ok ? (r.data.items||[]) : [];
|
||
|
|
var ul=$("profileList"); ul.innerHTML="";
|
||
|
|
PROFILES.forEach(function(p){
|
||
|
|
var li=document.createElement("li");
|
||
|
|
li.innerHTML="<b>"+esc(p.name||"Profil")+"</b>"+
|
||
|
|
'<div class="meta">'+(p.isChild?"dětský · ":"")+
|
||
|
|
(TIERS[p.maxRating||""]||p.maxRating)+"</div>";
|
||
|
|
ul.appendChild(li);
|
||
|
|
});
|
||
|
|
var sels=[$("pfSelect"), $("prProfile")];
|
||
|
|
sels.forEach(function(sel){
|
||
|
|
var keep=sel.value; sel.innerHTML="";
|
||
|
|
PROFILES.forEach(function(p){
|
||
|
|
var o=document.createElement("option"); o.value=p.id;
|
||
|
|
o.textContent=p.name||"Profil"; sel.appendChild(o);
|
||
|
|
});
|
||
|
|
if (keep) sel.value=keep;
|
||
|
|
});
|
||
|
|
if (PROFILES.length){ fillProfileForm(); await loadPrefs(); }
|
||
|
|
}
|
||
|
|
function esc(s){ return String(s).replace(/[<>&"]/g,function(c){
|
||
|
|
return ({"<":"<",">":">","&":"&",'"':"""})[c]; }); }
|
||
|
|
function currentProfile(){ return PROFILES.filter(function(p){
|
||
|
|
return p.id===$("pfSelect").value; })[0] || PROFILES[0]; }
|
||
|
|
function fillProfileForm(){
|
||
|
|
var p=currentProfile(); if(!p) return;
|
||
|
|
$("pfName2").value=p.name||""; $("pfRating").value=p.maxRating||"";
|
||
|
|
$("pfChild").checked=!!p.isChild; $("pfPin").value="";
|
||
|
|
}
|
||
|
|
$("pfSelect").onchange=fillProfileForm;
|
||
|
|
|
||
|
|
$("pfSave").onclick=async function(){
|
||
|
|
var p=currentProfile(); if(!p) return;
|
||
|
|
var pin=$("pfPin").value.trim();
|
||
|
|
if (pin && !/^\d{4}$/.test(pin)){ setMsg($("pfMsg"),"PIN musí být 4 číslice.","err"); return; }
|
||
|
|
busy($("pfSave"),true,"Ukládám…"); setMsg($("pfMsg"),"");
|
||
|
|
var r=await api("PATCH","/api/collections/profiles/records/"+p.id, {
|
||
|
|
name: $("pfName2").value.trim() || "Profil",
|
||
|
|
isChild: $("pfChild").checked,
|
||
|
|
maxRating: $("pfRating").value,
|
||
|
|
});
|
||
|
|
if (r.ok && pin){
|
||
|
|
// pinHash is a hidden field — PocketBase silently drops a client PATCH to
|
||
|
|
// it, so the PIN must go through the server route that hashes it.
|
||
|
|
var pr=await api("POST","/api/amber/set-pin",{ profileId:p.id, pin:pin });
|
||
|
|
if (!pr.ok) r={ok:false};
|
||
|
|
}
|
||
|
|
busy($("pfSave"),false);
|
||
|
|
setMsg($("pfMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
||
|
|
if (r.ok) await loadProfiles();
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── playback prefs ───────────────────────────────────────────────────────────
|
||
|
|
var LANGS=[["","Beze změny"],["cze","Čeština"],["slo","Slovenština"],
|
||
|
|
["eng","Angličtina"],["jpn","Japonština"],["ger","Němčina"],["pol","Polština"]];
|
||
|
|
function fillLangs(){
|
||
|
|
document.querySelectorAll("select.lang").forEach(function(sel){
|
||
|
|
sel.innerHTML="";
|
||
|
|
LANGS.forEach(function(l){
|
||
|
|
if (l[0]==="" ) return;
|
||
|
|
var o=document.createElement("option"); o.value=l[0]; o.textContent=l[1];
|
||
|
|
sel.appendChild(o);
|
||
|
|
});
|
||
|
|
var none=document.createElement("option");
|
||
|
|
none.value=""; none.textContent = sel.classList.contains("sub") ? "Žádné" : "Bez preference";
|
||
|
|
sel.insertBefore(none, sel.firstChild);
|
||
|
|
if (sel.classList.contains("sub")){
|
||
|
|
var off=document.createElement("option");
|
||
|
|
off.value="off"; off.textContent="Vypnuté"; sel.appendChild(off);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async function loadPrefs(){
|
||
|
|
var pid=$("prProfile").value || (PROFILES[0]&&PROFILES[0].id);
|
||
|
|
if(!pid) return;
|
||
|
|
var r=await api("GET","/api/collections/prefs/records?perPage=1&filter="+
|
||
|
|
encodeURIComponent('profile="'+pid+'"'));
|
||
|
|
var rec=(r.ok && r.data.items && r.data.items[0]) || null;
|
||
|
|
PREFS[pid]=rec;
|
||
|
|
var d=(rec && rec.data) || {};
|
||
|
|
$("prAudio").value=d.audioLanguage||"cze";
|
||
|
|
$("prAudio2").value=d.audioLanguage2||"eng";
|
||
|
|
$("prSub").value=d.subtitleLanguage||"cze";
|
||
|
|
$("prSub2").value=d.subtitleLanguage2||"eng";
|
||
|
|
$("prAnimeAudio").value=d.animeAudioLanguage||"jpn";
|
||
|
|
$("prAnimeSub").value=d.animeSubtitleLanguage||"eng";
|
||
|
|
$("prRes").value=d.preferredResolution ? String(d.preferredResolution) : "";
|
||
|
|
$("prHighest").checked=d.preferHighestQuality!==false;
|
||
|
|
$("prSigns").checked=!!d.combineSignsAndDialogue;
|
||
|
|
$("prFeedback").value=d.feedbackPrompt||"sometimes";
|
||
|
|
}
|
||
|
|
$("prProfile").onchange=loadPrefs;
|
||
|
|
|
||
|
|
$("prSave").onclick=async function(){
|
||
|
|
var pid=$("prProfile").value; if(!pid) return;
|
||
|
|
busy($("prSave"),true,"Ukládám…"); setMsg($("prMsg"),"");
|
||
|
|
var rec=PREFS[pid];
|
||
|
|
var base=(rec && rec.data) || {};
|
||
|
|
// Merge, never replace: the app writes fields this page does not show yet, and
|
||
|
|
// a whole-object PUT would silently drop them.
|
||
|
|
var data=Object.assign({}, base, {
|
||
|
|
appLanguage: base.appLanguage || "cs",
|
||
|
|
audioLanguage: $("prAudio").value || null,
|
||
|
|
audioLanguage2: $("prAudio2").value || null,
|
||
|
|
subtitleLanguage: $("prSub").value || null,
|
||
|
|
subtitleLanguage2: $("prSub2").value || null,
|
||
|
|
animeAudioLanguage: $("prAnimeAudio").value || null,
|
||
|
|
animeSubtitleLanguage: $("prAnimeSub").value || null,
|
||
|
|
animeAudioLanguage2: base.animeAudioLanguage2 || "eng",
|
||
|
|
animeSubtitleLanguage2: base.animeSubtitleLanguage2 || "cze",
|
||
|
|
preferredResolution: $("prRes").value ? parseInt($("prRes").value,10) : null,
|
||
|
|
preferHighestQuality: $("prHighest").checked,
|
||
|
|
combineSignsAndDialogue: $("prSigns").checked,
|
||
|
|
feedbackPrompt: $("prFeedback").value,
|
||
|
|
});
|
||
|
|
// updatedAt is the LWW clock the app compares on; the server's `updated` is
|
||
|
|
// only the pull cursor. Omitting this would make the edit lose to a stale
|
||
|
|
// device, or clobber a newer one unpredictably.
|
||
|
|
var body={ profile:pid, data:data, updatedAt:new Date().toISOString() };
|
||
|
|
var r = rec
|
||
|
|
? await api("PATCH","/api/collections/prefs/records/"+rec.id, body)
|
||
|
|
: await api("POST","/api/collections/prefs/records", body);
|
||
|
|
busy($("prSave"),false);
|
||
|
|
setMsg($("prMsg"), r.ok?"Uloženo. Zařízení si to stáhnou při dalším spuštění."
|
||
|
|
:"Uložení selhalo.", r.ok?"ok":"err");
|
||
|
|
if (r.ok) await loadPrefs();
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── addons ───────────────────────────────────────────────────────────────────
|
||
|
|
function renderAddonGate(){
|
||
|
|
var have = !!PASSWORD;
|
||
|
|
$("adNeedPass").className = have ? "hidden" : "";
|
||
|
|
$("adBody").className = have ? "" : "hidden";
|
||
|
|
if (have) loadAddons();
|
||
|
|
}
|
||
|
|
$("adUnlock").onclick=async function(){
|
||
|
|
var p=$("adPass").value; if(!p){ return; }
|
||
|
|
busy($("adUnlock"),true,"Ověřuji…");
|
||
|
|
// Verify the password by authenticating, rather than by trying to decrypt —
|
||
|
|
// a wrong password and an absent config look identical to the decrypt path.
|
||
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
||
|
|
{ identity:USER.email, password:p }, {anon:true});
|
||
|
|
busy($("adUnlock"),false);
|
||
|
|
if(!r.ok){ setMsg($("adMsg"),"Špatné heslo.","err"); return; }
|
||
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=p; saveSession();
|
||
|
|
setMsg($("adMsg"),""); $("adPass").value="";
|
||
|
|
renderAddonGate();
|
||
|
|
$("acChange").disabled=false;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function loadAddons(){
|
||
|
|
var r=await api("GET","/api/collections/addon_config/records?perPage=200");
|
||
|
|
CONFIGS = r.ok ? (r.data.items||[]) : [];
|
||
|
|
cfgRec = CONFIGS[0] || null;
|
||
|
|
cfg=null;
|
||
|
|
if (cfgRec){
|
||
|
|
try { cfg=JSON.parse(await decryptBlob(cfgRec.blob, PASSWORD, cfgRec.salt)); }
|
||
|
|
catch(_){ cfg=null; }
|
||
|
|
}
|
||
|
|
var configured = cfg && (cfg.addonUrl || cfg.czechAddonUrl);
|
||
|
|
$("adSetup").className = configured ? "hidden" : "";
|
||
|
|
$("adEdit").className = configured ? "" : "hidden";
|
||
|
|
$("adFamilyBox").className="hidden"; $("adByocBox").className="hidden";
|
||
|
|
if (configured){
|
||
|
|
$("adTorbox").value=cfg.addonUrl||"";
|
||
|
|
$("adCzech").value=cfg.czechAddonUrl||"";
|
||
|
|
$("adTmdb").value=cfg.tmdbKey||"";
|
||
|
|
if (USER && USER.nsfwEnabled){
|
||
|
|
$("adAdultBox").className="";
|
||
|
|
$("adAdult").value=cfg.adultAddonUrl||"";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$("acChange").disabled = !PASSWORD;
|
||
|
|
}
|
||
|
|
$("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."
|
||
|
|
: "Rodinné 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();
|
||
|
|
if(!ptU || !ptP){
|
||
|
|
setMsg($("adMsg"),"prehraj.to je zatím povinný — bez něj český doplněk nefunguje.","err");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if ((wsU && !wsP) || (!wsU && wsP)){
|
||
|
|
setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
||
|
|
var out={ tmdbKey:tmdb };
|
||
|
|
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){
|
||
|
|
// `releases.file` is a protected file: the path alone 403s, it needs a
|
||
|
|
// short-lived file token. Minted per link, exactly as the landing page does.
|
||
|
|
var t=await api("POST","/api/files/token",null);
|
||
|
|
var href=(t.ok && t.data && t.data.token)
|
||
|
|
? m.data.downloadPath+"?token="+encodeURIComponent(t.data.token) : null;
|
||
|
|
li.innerHTML='<div class="rowbtn"><div><b>'+esc(p[1])+'</b><div class="meta">'+
|
||
|
|
esc(m.data.version||"")+" · "+esc(m.data.variant||"")+'</div></div>'+
|
||
|
|
(href ? '<a class="btn primary" style="width:auto;padding:9px 14px;margin:0;'+
|
||
|
|
'text-decoration:none;display:inline-block" href="'+href+'">Stáhnout</a>'
|
||
|
|
: '<span class="meta">odkaz se nepodařilo vytvořit</span>')+'</div>';
|
||
|
|
any=true;
|
||
|
|
} else {
|
||
|
|
li.innerHTML="<b>"+esc(p[1])+'</b><div class="meta">zatím nic ke stažení</div>';
|
||
|
|
}
|
||
|
|
box.appendChild(li);
|
||
|
|
}
|
||
|
|
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(); }
|
||
|
|
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+"/tv";
|
||
|
|
if (await restore()) await enter();
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|