diff --git a/docs/onboarding-contract.md b/docs/onboarding-contract.md
index de26199..70d3832 100644
--- a/docs/onboarding-contract.md
+++ b/docs/onboarding-contract.md
@@ -105,3 +105,89 @@ run in a real browser produced the user, the named profile, and an
with every field intact (adult fields null/false) · `/get/tv` streamed a
published dummy APK byte-identical with the attachment header · landing's
download login handled the no-releases state.
+
+## The setup flow for someone with their own accounts (2026-08-12)
+
+The family path is the `onboarding_template` route above: one code, and the shared
+credentials arrive already filled in. This is the other path, for someone who is
+trusted with an account but brings their own subscriptions, and until now had to
+work out on their own that Amber needs paid accounts somewhere before it can find
+anything.
+
+`#start` is a tab in `index.html`, not a new page, because the deploy copies files
+into `pb_public` and never deletes the ones a commit removed. That is why stale
+`onboarding.html` and `settings.html` still answer 200 and need shadow routes in
+`web.pb.js`, and one file avoids repeating it.
+
+### The six steps
+
+| | Step | Finished when |
+|---|---|---|
+| 1 | Change the generated password | `onboarding.pwChangedAt` is set |
+| 2 | Choose services | `onboarding.chosen` is non-empty |
+| 3 | Create accounts and pay | an `addon_config` row exists |
+| 4 | Get a TMDB key | the decrypted config has `tmdbKey` |
+| 5 | Enter the credentials in Zdroje | an `addon_config` row exists |
+| 6 | Download and pair | never; you can always want the app again |
+
+Steps 1, 5 and 6 hand off to the existing Účet, Zdroje and Stáhnout tabs. This
+screen owns no credentials and no crypto, deliberately: a second encryptor for one
+blob is how a vault gets corrupted. Step 5 in particular is just a signpost to
+`adByocBox`, which already turns usernames and keys into addon URLs by calling the
+addon's `/encode` straight from the browser.
+
+**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. First, while the vault is still empty, there is nothing to
+re-encrypt and no paired television to strand, and the password is still in memory
+from the sign-in that just happened.
+
+### State: three fields, and everything else derived
+
+`users.onboarding` is json holding only `skipped`, `chosen` and `pwChangedAt`.
+Nothing else needs storing: 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.
+
+`pwChangedAt` is the exception that cannot be derived, because PocketBase records
+no password-changed timestamp and `updated` moves for any edit. Absent means "as
+far as we know, still on the password the owner generated", which is the honest
+default for a reminder.
+
+The flow opens itself only when there is no `addon_config` and `skipped` is not
+set, so the family, who all have sources already, never see it. An explicit
+`#hash` always wins so bookmarks still land where they say.
+
+### Content lives in `providers`, not in the page
+
+Prices and click-paths go stale on somebody else's schedule, and a wrong price on
+our own page reads as a promise. `providers` is editable in the admin UI and
+`priceCheckedAt` renders next to the number, so a stale figure looks stale.
+`scripts/providers.seed.json` is the starting point; `scripts/seed-providers.py`
+upserts by slug and leaves existing rows alone unless given `--force`, so it is
+safe to run after any deploy.
+
+`slug` is load-bearing: the page keys its wiring off `torbox`, `prehrajto`,
+`webshare` and `tmdb`, and the seeder refuses anything else. Everything else is
+free text.
+
+`kind` separates the two questions the flow asks. `source` rows are a choice to
+weigh up; `metadata` (TMDB) is not optional and is not offered as one, because
+without a key there are no posters and no descriptions.
+
+### Verified locally (2026-08-12)
+
+Against PocketBase 0.39.6, the pinned version, run from a throwaway data dir with
+this repo's real `pb_migrations`, `pb_hooks` and `pb_public`. Docker was not used:
+its daemon is disabled on the dev box and the binary alone is enough.
+
+Both migrations applied clean on a fresh database. Then, as a locally created test
+user with no sources: the flow opened itself; choosing prehraj.to and TorBox
+persisted as `{"chosen":["prehrajto","torbox"]}` and nothing else; step 3 rendered
+both guides numbered with working links; skipping moved to Profily and raised the
+banner; a reload kept it skipped rather than reopening; resuming came back in.
+
+Not yet verified: the whole thing against the live instance, and a real TorBox or
+webshare signup followed end to end by someone who does not already know the
+answer.
diff --git a/pb_migrations/1790500000_providers.js b/pb_migrations/1790500000_providers.js
new file mode 100644
index 0000000..bdfe2e2
--- /dev/null
+++ b/pb_migrations/1790500000_providers.js
@@ -0,0 +1,81 @@
+///
+
+// The services a BYOC user has to buy, described in data rather than in markup.
+//
+// **Why a collection and not copy in index.html.** Two of these fields go stale
+// on somebody else's schedule: the price, and the click-path through a signup
+// form the provider is free to redesign. A wrong price on our own page is worse
+// than no price — it reads as a promise — and the fix must not require editing
+// HTML and redeploying the backend the family streams through. Everything here
+// is editable in the admin UI, and `priceCheckedAt` is rendered next to the
+// number so a stale figure is visibly stale instead of quietly wrong.
+//
+// **Why not onboarding_template.** That collection holds the family's actual
+// credentials and is superuser-only on every rule for that reason. Public-facing
+// marketing copy has no business sharing a table with secrets, and widening its
+// read rule to serve a price list is exactly the kind of accident this comment
+// exists to prevent.
+//
+// **Why `kind`.** TMDB is not a source and is not optional — no key means no
+// posters and no descriptions — so the chooser must not offer it as one of three
+// things to weigh up. `source` rows are choices; `metadata` rows are steps that
+// happen regardless. Keeping it a token rather than a boolean leaves room for a
+// third kind without another migration.
+//
+// **Why `price` is text.** These are quoted in different currencies and
+// different periods ("5 $/měsíc", "199 Kč/měsíc", "$30/rok"), and the page only
+// ever displays it. A number plus a currency column would be three fields to get
+// wrong in exchange for arithmetic nobody does.
+//
+// Read is limited to signed-in users. There is no public signup — accounts are
+// owner-created — so nobody who cannot log in has any use for this, and the
+// tighter rule is free. Writing stays superuser-only: this is content the owner
+// maintains, not something a user submits.
+
+migrate(
+ (app) => {
+ const c = new Collection({
+ type: "base",
+ name: "providers",
+ listRule: "@request.auth.id != ''",
+ viewRule: "@request.auth.id != ''",
+ createRule: null,
+ updateRule: null,
+ deleteRule: null,
+ fields: [
+ // torbox | prehrajto | webshare | tmdb. The page keys its per-service
+ // wiring off this, so it is the one field that must not be edited
+ // casually — hence the note here rather than only in the UI.
+ { type: "text", name: "slug", required: true, max: 40 },
+ { type: "text", name: "name", required: true, max: 80 },
+ { type: "url", name: "url", required: true },
+ { type: "text", name: "kind", required: true, max: 20 },
+ // One line: what this unlocks, in the viewer's terms.
+ { type: "text", name: "tagline", max: 300 },
+ { type: "json", name: "pros", maxSize: 4000 },
+ { type: "json", name: "cons", maxSize: 4000 },
+ { type: "text", name: "price", max: 120 },
+ { type: "text", name: "priceNote", max: 300 },
+ { type: "date", name: "priceCheckedAt" },
+ // Ordered strings, rendered as a numbered list. Plain text on purpose:
+ // this is read by someone with a signup form open in the next tab, and
+ // markup would be one more thing to get wrong in an admin textarea.
+ { type: "json", name: "steps", maxSize: 20000 },
+ { type: "number", name: "sort" },
+ // A row is hidden rather than deleted when a service stops being worth
+ // recommending, so its guide survives for anyone who still has it.
+ { type: "bool", name: "enabled" },
+ { type: "bool", name: "recommended" },
+ { type: "autodate", name: "created", onCreate: true },
+ { type: "autodate", name: "updated", onCreate: true, onUpdate: true },
+ ],
+ indexes: [
+ "CREATE UNIQUE INDEX idx_providers_slug ON providers (slug)",
+ ],
+ })
+ app.save(c)
+ },
+ (app) => {
+ app.delete(app.findCollectionByNameOrId("providers"))
+ },
+)
diff --git a/pb_migrations/1790500001_users_onboarding.js b/pb_migrations/1790500001_users_onboarding.js
new file mode 100644
index 0000000..ecc6a54
--- /dev/null
+++ b/pb_migrations/1790500001_users_onboarding.js
@@ -0,0 +1,55 @@
+///
+
+// Where a user got to in the setup flow — **only the parts that cannot be
+// derived.**
+//
+// Everything else about their progress is already knowable from data that
+// exists: whether they have sources at all is a lookup in `addon_config`, which
+// the page loads anyway. Storing "sourcesDone" beside it would create a second
+// answer to a question that already has one, and the two would eventually
+// disagree — at which point the flow either nags someone who is finished or
+// congratulates someone who is not.
+//
+// So this holds three things and no more:
+//
+// { "skipped": true, // chose "nastavím později"
+// "chosen": ["prehrajto", "torbox"], // which services they picked
+// "pwChangedAt": "2026-08-12T09:00:00Z" } // they replaced the owner's password
+//
+// `pwChangedAt` is written by the web page when the change succeeds. It cannot be
+// derived: PocketBase records no password-changed timestamp, and `updated` moves
+// for any edit at all. Absent therefore means "as far as we know, still on the
+// password Richard generated" — which is the honest default for a reminder, and
+// the cost of being wrong is one dismissible nudge.
+//
+// **Per account, not localStorage.** Setup spans devices by nature — the flow is
+// read on a phone or laptop while the app is installed on a television — so
+// progress kept in one browser would ask someone who finished on their laptop to
+// start again on their phone.
+//
+// Json rather than three columns: it is one opaque blob the page reads and writes
+// whole, nothing filters or sorts on it, and adding a fourth thing later should
+// not need a migration. `users` update rules are unchanged — a user may already
+// PATCH their own record (that is how the rating default is set), and this rides
+// on that.
+//
+// Existing accounts are untouched and read as `{}`: no `skipped`, so the family
+// would be offered the flow — except they all have `addon_config` rows already,
+// which is what actually suppresses it. Nobody who is set up sees anything.
+
+migrate(
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.fields.add(new Field({
+ type: "json",
+ name: "onboarding",
+ maxSize: 4000,
+ }))
+ app.save(users)
+ },
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.fields.removeByName("onboarding")
+ app.save(users)
+ },
+)
diff --git a/pb_public/index.html b/pb_public/index.html
index 64381cb..3f73e85 100644
--- a/pb_public/index.html
+++ b/pb_public/index.html
@@ -65,6 +65,42 @@
.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; }
@@ -109,8 +145,26 @@
+
+
Nastavení ještě není dokončené.
+ Amber zatím nemá kde hledat.
+
+
+
+
+
Začínáme
+
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í.
+
+
+
+
+
Profily
@@ -238,8 +292,10 @@
-
Vyplň prehraj.to, webshare, nebo obojí — stačí jeden
- z nich. Obojí najde nejvíc.
+
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.
webshare.cz
@@ -427,7 +483,12 @@ async function api(method, path, body, opts){
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(){
@@ -455,8 +516,9 @@ $("loginBtn").onclick=async function(){
$("logoutBtn").onclick=signOut;
// ── shell ────────────────────────────────────────────────────────────────────
-var TABS=[["profiles","Profily"],["playback","Přehrávání"],["addons","Zdroje"],
- ["devices","Zařízení"],["download","Stáhnout"],["account","Účet"]];
+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");
@@ -478,14 +540,311 @@ async function enter(){
$("anon").className="hidden"; $("app").className="";
$("whoEmail").textContent=(USER&&USER.email)||"";
buildNav();
- await Promise.all([loadProfiles(), loadDevices(), loadDownloads()]);
+ await Promise.all([loadProfiles(), loadDevices(), loadDownloads(),
+ loadProviders(), loadConfigList()]);
$("acEmail").value=(USER&&USER.email)||"";
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
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("#","");
- showTab(TABS.some(function(t){return t[0]===want;}) ? want : "profiles");
+ 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||[]) : [];
+}
+
+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 `
` 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í.");
+ 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í" };
@@ -664,6 +1023,9 @@ async function loadAddons(){
}
}
$("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=""; };
@@ -705,26 +1067,40 @@ $("byGo").onclick=async function(){
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");
+ // **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 };
- 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;
+ // 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
@@ -865,7 +1241,15 @@ $("acChange").onclick=async function(){
// 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(); }
+ 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