amber-backend/pb_public/index.html
Claude 5bfc45cd23 The sources form no longer insists on prehraj.to
The addon accepts either host on its own as of stremio_prehrajto 7ef248d, so the
page that mints the URLs stops claiming otherwise. Each host stays all-or-nothing
and at least one must be complete — the same rule /encode enforces, checked here
too so the message arrives in Czech rather than as a 400.
2026-08-07 09:09:56 +02:00

885 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 <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.</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 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 ({"<":"&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;
}
$("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();
// 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;
}
if (!(ptU && ptP) && !(wsU && wsP)){
setMsg($("adMsg"),"Vyplň aspoň jednoho hosta — prehraj.to nebo webshare.","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>