<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sala</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%2317111f'/%3E%3Ctext x='15' y='47' font-family='Arial,sans-serif' font-size='44' font-weight='800' fill='%23ece6f7'%3Es%3C/text%3E%3Ccircle cx='47' cy='42' r='5' fill='%237fd6c2'/%3E%3C/svg%3E">
<style>
:root{--bg:#17111f;--surface:#241a33;--raised:#2f2342;--text:#ece6f7;--muted:#9a8fb0;--accent:#7fd6c2;--accent-ink:#0f2a25;--danger:#ff8a9b;--r:14px}
*{box-sizing:border-box;margin:0}
html{color-scheme:dark}
body{background:var(--bg);color:var(--text);font:16px/1.5 "Bricolage Grotesque","Avenir Next","Segoe UI",system-ui,sans-serif;min-height:100vh}
button,input{font:inherit;color:inherit}
button{cursor:pointer;border:0;background:none}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
header{display:flex;gap:20px;align-items:center;padding:14px clamp(16px,4vw,48px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 92%,transparent);backdrop-filter:blur(10px);z-index:5;border-bottom:1px solid var(--raised)}
.logo{font-size:1.6rem;font-weight:800;letter-spacing:-.04em}
.logo i{font-style:normal;color:var(--accent)}
.search{flex:1;max-width:560px;position:relative}
.search input{width:100%;background:var(--surface);border:1px solid var(--raised);border-radius:999px;padding:10px 18px}
.search input::placeholder{color:var(--muted)}
.btn{background:var(--accent);color:var(--accent-ink);padding:9px 18px;border-radius:999px;font-weight:700}
.btn.ghost{background:var(--raised);color:var(--text)}
nav{display:flex;gap:6px;padding:18px clamp(16px,4vw,48px) 0}
nav button{padding:7px 16px;border-radius:999px;color:var(--muted)}
nav button[aria-pressed=true]{background:var(--raised);color:var(--text)}
main{padding:20px clamp(16px,4vw,48px) 64px}
.stage{display:none;margin-bottom:32px}
.stage.on{display:block}
.frame{aspect-ratio:16/9;background:#000;border-radius:var(--r);overflow:hidden;display:grid;place-items:center;max-height:72vh;width:100%}
.frame iframe{width:100%;height:100%;border:0}
.blank{max-width:460px;text-align:center;color:var(--muted);padding:24px}
.blank b{color:var(--text);display:block;font-size:1.1rem;margin-bottom:6px}
.meta{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-top:14px;flex-wrap:wrap}
.meta h2{font-size:1.6rem;letter-spacing:-.02em}
.meta p{color:var(--muted);max-width:62ch}
.eps{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.eps select{background:var(--surface);border:1px solid var(--raised);border-radius:8px;padding:6px 10px}
h3{font-size:1.2rem;margin:8px 0 14px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:18px}
.card{text-align:left;display:block;width:100%}
.poster{aspect-ratio:2/3;border-radius:var(--r);background-size:cover;background-position:center;display:flex;align-items:flex-end;padding:12px;font-weight:800;font-size:1.15rem;line-height:1.1;letter-spacing:-.02em;transition:transform .15s}
.card:hover .poster{transform:translateY(-3px)}
.card small{display:block;color:var(--muted);margin-top:8px}
.card span.t{display:block;margin-top:2px;font-weight:600}
.empty{color:var(--muted);padding:40px 0}
dialog{margin:auto;background:var(--surface);color:var(--text);border:1px solid var(--raised);border-radius:var(--r);padding:26px;width:min(380px,92vw)}
dialog::backdrop{background:#0009}
dialog h2{margin-bottom:14px}
dialog label{display:block;margin-top:10px;color:var(--muted);font-size:.9rem}
dialog input{width:100%;background:var(--bg);border:1px solid var(--raised);border-radius:8px;padding:9px 12px;margin-top:4px}
.row{display:flex;gap:10px;margin-top:18px;align-items:center;justify-content:space-between}
.err{color:var(--danger);min-height:1.4em;margin-top:8px;font-size:.9rem}
.link{color:var(--accent);text-decoration:underline}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
.logo{text-decoration:none;color:inherit}
nav a{padding:7px 16px;border-radius:999px;color:var(--muted);text-decoration:none}
nav a[aria-current=page]{background:var(--raised);color:var(--text)}
a.card{text-decoration:none;color:inherit}
.seasonSel{font-size:1.15rem;font-weight:700;background:var(--raised);border:0;border-radius:12px;padding:12px 18px;margin:20px 0 14px;cursor:pointer}
.eprow{display:flex;gap:14px;overflow-x:auto;padding-bottom:12px;cursor:grab;scroll-snap-type:x proximity;user-select:none}
.eprow.drag{cursor:grabbing;scroll-snap-type:none}
.ep{flex:0 0 230px;text-align:left;scroll-snap-align:start}
.ep .still{aspect-ratio:16/9;border-radius:10px;background:var(--surface) center/cover;display:grid;place-items:center;color:var(--muted);font-weight:700}
.ep[aria-current=true] .still{outline:2px solid var(--accent);outline-offset:2px}
.ep b{display:block;margin-top:8px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ep small{color:var(--muted)}
body{display:flex;flex-direction:column}
main{flex:1}
footer{padding:0 clamp(16px,4vw,48px) 32px;color:var(--muted);font-size:.85rem}
footer p+p{margin-top:6px}
#acct{margin-left:auto;position:relative}
.search{min-width:0}
.avatar{width:38px;height:38px;border-radius:50%;background:var(--raised);font-size:1.25rem;display:grid;place-items:center}
.menu{position:absolute;right:0;top:calc(100% + 8px);background:var(--surface);border:1px solid var(--raised);border-radius:12px;padding:12px;min-width:210px;z-index:10}
.menu:not([hidden]){display:grid;gap:10px}
.menu .who{font-size:.9rem;color:var(--muted);word-break:break-all}
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:20px;background:var(--raised);padding:10px 16px;border-radius:12px;max-width:90vw;z-index:20;box-shadow:0 6px 24px #0008}
@media (max-width:560px){
header{flex-wrap:wrap;gap:10px 12px}
.search{order:3;flex-basis:100%;max-width:none}
}
.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.cardWrap{position:relative}
.rm{position:absolute;top:8px;right:8px;width:30px;height:30px;border-radius:50%;background:#000b;color:#fff;display:grid;place-items:center;font-size:.9rem}
.rm:hover{background:var(--danger);color:#000}
</style>
</head>
<body>
<header>
<a class="logo" href="#/">sala<i>.</i></a>
<form class="search" id="searchForm" role="search">
<input id="q" type="search" placeholder="Search movies and shows" aria-label="Search">
</form>
<div id="acct"></div>
</header>
<nav id="nav" aria-label="Sections">
<a href="#/" data-r="home">Discover</a>
<a href="#/list" data-r="list">My list</a>
<a href="#/history" data-r="history">Recently watched</a>
</nav>
<main id="view"></main>
<footer id="foot">
<p id="tmdbNote"></p>
<p>sala doesn't host or store any media files. Videos and listings are loaded from external third-party sources.</p>
</footer>
<dialog id="authDlg">
<form id="authForm" method="dialog">
<h2 id="authTitle">Sign in</h2>
<label>Email<input id="uName" type="email" autocomplete="email" required></label>
<label>Password<input id="uPass" type="password" autocomplete="current-password" minlength="6" required></label>
<div class="err" id="authErr" role="alert"></div>
<div class="row">
<button type="button" class="link" id="authSwitch">Create account</button>
<span><button type="button" class="btn ghost" id="authCancel">Cancel</button> <button class="btn" id="authGo">Sign in</button></span>
</div>
</form>
</dialog>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.45.4/dist/umd/supabase.js"></script>
<script>
/* =====================================================================
1) A TUA FONTE — preenche só este bloco, não precisas de mexer no resto
---------------------------------------------------------------------
Endereços: copia-os da documentação da tua fonte.
Modelos de URL: usa {id}, {season}, {episode} e {query} onde esses
valores devem entrar. Exemplo: "https://O-TEU-DOMINIO/filme/{id}"
Enquanto estiver tudo vazio, o site mostra os dados de demonstração.
===================================================================== */
const SOURCE_CONFIG = {
movieEmbed: "https://vidsrc.to/embed/movie/{id}", // modelo do URL de um filme (usa {id})
tvEmbed: "https://vidsrc.to/embed/tv/{id}/{season}/{episode}", // modelo do URL de um episódio (usa {id} {season} {episode})
listMovies: "https://vidsrc.to/vapi/movie/{type}/{page}", // endereço da lista de filmes (página inicial)
listTv: "https://vidsrc.to/vapi/tv/{type}/{page}", // endereço da lista de séries (página inicial)
searchUrl: "ENDEREÇO-DA-PESQUISA-COM-{query}", // endereço da pesquisa da tua fonte (usa {query}); ignorado se tmdbKey estiver preenchida
tmdbKey: "0318aa0194bc0bb7264ab57039a3c6dd", // chave da API do TMDB: ativa a pesquisa em todo o catálogo (e a página inicial, se não tiveres listas)
// Em que campo da resposta está cada coisa. Abre um dos endereços de lista
// no browser, olha para o texto que aparece e escreve aqui os nomes que vires.
// Podes pôr alternativas separadas por vírgula (usa-se a primeira que existir)
// e caminhos com ponto para campos dentro de campos (ex.: "info.titulo").
fields: {
results: "result,results,data,items", // onde está a lista (se a resposta já for uma lista, ignora-se)
id: "tmdb_id,imdb_id,id", // O IDENTIFICADOR que o embed usa
title: "title,name",
year: "year,release_date",
overview: "overview,description",
poster: "poster,poster_path",
type: "type,media_type" // "movie" ou "tv" (só preciso na pesquisa)
}
};
/* --- daqui para baixo é o código que usa a configuração acima --- */
const C = SOURCE_CONFIG;
const set = v => v && !/^(ENDEREÇO|CHAVE)/.test(v); // texto que ainda não substituíste conta como vazio
const fill = (tpl, v) => tpl.replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(v[k] ?? ""));
const pick = (o, paths) => {
for (const p of String(paths).split(",")){
let v = o; for (const k of p.trim().split(".")) v = v?.[k];
if (v != null && v !== "") return v;
}
};
const rowsOf = j => Array.isArray(j) ? j : (pick(j, C.fields.results) || []);
const getJson = async url => { const r = await fetch(url); if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); };
function toItem(r, type){
const f = C.fields, id = pick(r, f.id);
if (id == null) return null;
const t = type || (/tv|serie|show/i.test(String(pick(r, f.type))) ? "tv" : "movie");
return {id:String(id), type:t, title:pick(r, f.title) || "Untitled",
year:String(pick(r, f.year) ?? "").slice(0,4), overview:pick(r, f.overview) || "", poster:pick(r, f.poster)};
}
async function tmdb(path, params = {}){
const u = new URL("https://api.themoviedb.org/3" + path), opt = {};
Object.entries({language:"en-US", ...params}).forEach(([k, v]) => u.searchParams.set(k, v));
if (C.tmdbKey.startsWith("eyJ")) opt.headers = {Authorization: "Bearer " + C.tmdbKey};
else u.searchParams.set("api_key", C.tmdbKey);
const r = await fetch(u, opt);
if (!r.ok) throw new Error("TMDB " + r.status);
return r.json();
}
function tmdbItem(r){
const type = r.media_type || (r.first_air_date ? "tv" : "movie");
if (type !== "movie" && type !== "tv") return null;
return {id:String(r.id), type, title:r.title || r.name || "Untitled",
year:String(r.release_date || r.first_air_date || "").slice(0,4), overview:r.overview || "",
poster:r.poster_path ? "https://image.tmdb.org/t/p/w342" + r.poster_path : ""};
}
async function fetchItems(url, type){
try { return rowsOf(await getJson(url)).map(r => toItem(r, type)).filter(Boolean) }
catch (e) { console.error("Falhou:", url, e); return [] }
}
/* Completa capas, títulos e sinopses em falta usando o TMDB (se a chave estiver preenchida) */
async function enrich(items){
if (!set(C.tmdbKey)) return items;
await Promise.allSettled(items.filter(i => !i.poster).slice(0, 40).map(async it => {
try {
let d;
if (/^tt/.test(it.id)){
const f = await tmdb("/find/" + it.id, {external_source:"imdb_id"});
d = (it.type === "tv" ? f.tv_results : f.movie_results)[0];
} else d = await tmdb(`/${it.type}/${it.id}`);
if (!d) return;
if (d.poster_path) it.poster = "https://image.tmdb.org/t/p/w342" + d.poster_path;
if (it.title === "Untitled") it.title = d.title || d.name || it.title;
if (!it.overview) it.overview = d.overview || "";
if (!it.year) it.year = String(d.release_date || d.first_air_date || "").slice(0, 4);
} catch (e) { console.error("TMDB enrich:", it.id, e) }
}));
return items;
}
const SOURCE = {
async list(){
let own = [];
SOURCE.error = "";
if (set(C.listMovies) || set(C.listTv)){
const [m, t] = await Promise.all([
set(C.listMovies) ? fetchItems(C.listMovies, "movie") : [],
set(C.listTv) ? fetchItems(C.listTv, "tv") : []
]);
own = [...m, ...t];
if (!own.length) { SOURCE.error = "the source lists returned no titles"; console.warn("As listas da fonte não devolveram títulos. Confirma os endereços e o mapa 'fields' (ou CORS)."); }
}
if (own.length) return (SOURCE.all = await enrich(own));
if (set(C.tmdbKey)){
try { return (SOURCE.all = (await tmdb("/trending/all/week")).results.map(tmdbItem).filter(Boolean)) }
catch (e) { SOURCE.error = e.message; console.error("TMDB:", e) }
}
return (set(C.listMovies) || set(C.listTv)) ? [] : DEMO;
},
async search(query){
if (set(C.tmdbKey)){
try { return (await tmdb("/search/multi", {query, include_adult:"false"})).results.map(tmdbItem).filter(Boolean) }
catch (e) { console.error("TMDB:", e) }
}
if (!set(C.searchUrl)) return enrich((SOURCE.all?.length ? SOURCE.all : DEMO).filter(i => i.title.toLowerCase().includes(query.toLowerCase())));
return enrich(await fetchItems(fill(C.searchUrl, {query}), null));
},
async item(type, id){ // para abrir uma página de título diretamente (ex.: ao recarregar)
const known = [...(SOURCE.all || []), ...DEMO].find(i => i.type === type && i.id === id);
if (known) return known;
if (set(C.tmdbKey)){
try { const it = tmdbItem({...(await tmdb(`/${type}/${id}`)), media_type:type}); if (it) return it }
catch (e) { console.error("TMDB:", e) }
}
return {id, type, title:"Untitled", year:"", overview:"", poster:""};
},
async episodes(item, season, count){ // episódios de uma temporada, com título e imagem
if (set(C.tmdbKey)){
try {
return (await tmdb(`/tv/${item.id}/season/${season}`)).episodes.map(e => ({n:e.episode_number, name:e.name,
still:e.still_path ? "https://image.tmdb.org/t/p/w300" + e.still_path : ""}));
} catch (e) { console.error("TMDB:", e) }
}
return Array.from({length: count || 12}, (_, i) => ({n:i + 1, name:"Episode " + (i + 1), still:""}));
},
async seasons(item){ // nº de episódios por temporada, via TMDB
if (!set(C.tmdbKey) || item.type !== "tv") return null;
try {
const o = {};
((await tmdb("/tv/" + item.id)).seasons || []).filter(x => x.season_number > 0 && x.episode_count > 0)
.forEach(x => o[x.season_number] = x.episode_count);
return Object.keys(o).length ? o : null;
} catch { return null }
},
embedUrl(item, ep){
const tpl = item.type === "tv" ? C.tvEmbed : C.movieEmbed;
return set(tpl) ? fill(tpl, {id:item.id, season:ep?.season, episode:ep?.episode}) : "";
}
};
/* Dados de demonstração para veres o design a funcionar. Apaga quando quiseres. */
const DEMO = [
{id:"d1",type:"movie",title:"Low Tide",year:2021,overview:"Sample movie to test the layout."},
{id:"d2",type:"tv",title:"The Lighthouse Keepers",year:2023,overview:"Sample show with seasons and episodes.",seasons:{1:6,2:8}},
{id:"d3",type:"movie",title:"Line Seven",year:2019,overview:"Another sample movie."},
{id:"d4",type:"tv",title:"Letters from the Interior",year:2022,overview:"Sample show.",seasons:{1:10}},
{id:"d5",type:"movie",title:"The Last Station",year:2024,overview:"Another sample title."},
{id:"d6",type:"movie",title:"North Wind",year:2018,overview:"Another sample title."}
];
/* =====================================================================
2) CONTAS COM SINCRONIZAÇÃO (Supabase)
Cola aqui os dois valores do teu projeto Supabase
(Project Settings -> API): o "Project URL" e a chave "anon public".
===================================================================== */
const SUPABASE_URL = "https://lxoouwuouxbmncfntrbn.supabase.co"; // ex.: https://abcdefgh.supabase.co
const SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx4b291d3VvdXhibW5jZm50cmJuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTE3MzA1NTksImV4cCI6MjEwNzMwNjU1OX0.kmbAWRisiSSc76HRYZ1gxVA34IbD0GEXQZ2D51gWzTA"; // chave "anon public" (é feita para ficar no site)
const $ = s => document.querySelector(s);
const sb = (window.supabase && SUPABASE_URL && SUPABASE_KEY) ? window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY) : null;
let user = null;
let data = {list:[], hist:[]};
const me = () => user ? data : null;
function notify(msg){
let t = $("#toast");
if (!t){ t = document.createElement("div"); t.id = "toast"; t.setAttribute("role", "status"); document.body.append(t); }
t.textContent = msg; t.hidden = false;
clearTimeout(notify.t); notify.t = setTimeout(() => t.hidden = true, 9000);
}
async function report(query, what){
try { const {error} = await query; if (error) throw error; }
catch (e) { console.error("Sync:", what, e); notify("Couldn't sync " + what + ": " + (e.message || e)); }
}
async function loadData(){
data = {list:[], hist:[]};
if (!sb || !user) return;
const {data: rows, error} = await sb.from("library").select("kind,item").order("updated_at", {ascending:false});
if (error){ console.error("Sync:", error); notify("Couldn't load your saved titles: " + error.message); return; }
(rows || []).forEach(r => data[r.kind]?.push(r.item));
data.hist = data.hist.slice(0, 30);
}
const setRow = (kind, it) => report(sb.from("library").upsert({user_id:user.id, kind, item_id:String(it.id), item:it, updated_at:new Date().toISOString()}), kind === "list" ? "your list" : "your history");
const delRow = (kind, it) => report(sb.from("library").delete().eq("kind", kind).eq("item_id", String(it.id)), kind === "list" ? "your list" : "your history");
function addHist(it){
if (!me()) return;
data.hist = [it, ...data.hist.filter(x => x.id !== it.id)].slice(0, 30);
setRow("hist", it);
}
function toggleList(it, saved){
if (saved){ data.list = data.list.filter(x => x.id !== it.id); delRow("list", it); }
else { data.list = [it, ...data.list]; setRow("list", it); }
renderFav();
}
async function initAuth(){
renderAcct();
if (!sb) return route();
const {data:{session}} = await sb.auth.getSession();
user = session?.user ?? null;
await loadData(); renderAcct(); route();
sb.auth.onAuthStateChange(async (_e, s) => {
const next = s?.user ?? null;
if (next?.id === user?.id) return;
user = next; await loadData(); renderAcct(); route();
});
}
/* =====================================================================
3) PÁGINAS — cada ecrã tem o seu endereço:
#/ (Discover) #/list #/history #/search/texto #/title/movie/123
===================================================================== */
const view = $("#view");
const state = {current:null, ep:{season:1, episode:1}, token:0};
const cache = new Map();
const keyOf = it => it.type + ":" + it.id;
const hues = id => [...id].reduce((a, c) => a + c.charCodeAt(0), 0) * 47 % 360;
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
const safe = s => { try { return decodeURIComponent(s) } catch { return s } };
function posterStyle(it){
if (it.poster) return `background-image:url('${esc(it.poster)}')`;
const h = hues(String(it.id));
return `background-image:linear-gradient(160deg,hsl(${h} 45% 38%),hsl(${(h+50)%360} 40% 18%))`;
}
function card(it){
cache.set(keyOf(it), it);
const a = document.createElement("a");
a.className = "card";
a.href = "#/title/" + it.type + "/" + encodeURIComponent(it.id);
a.innerHTML = `<div class="poster" style="${posterStyle(it)}">${it.poster ? "" : esc(it.title)}</div>
<span class="t">${esc(it.title)}</span><small>${it.type === "tv" ? "Show" : "Movie"}${it.year ? " · " + esc(it.year) : ""}</small>`;
return a;
}
function gridPage(items, title, emptyMsg, opts = {}){
view.replaceChildren();
const bar = document.createElement("div"); bar.className = "bar";
const h = document.createElement("h3"); h.textContent = title; bar.append(h);
if (opts.clear && items.length){
const c = document.createElement("button"); c.className = "btn ghost"; c.textContent = "Clear all"; c.onclick = opts.clear; bar.append(c);
}
view.append(bar);
if (!items.length){
const p = document.createElement("p"); p.className = "empty"; p.textContent = emptyMsg; view.append(p); return;
}
const g = document.createElement("div"); g.className = "grid"; view.append(g);
items.forEach(it => {
const c = card(it);
if (!opts.remove) return g.append(c);
const w = document.createElement("div"); w.className = "cardWrap";
const x = document.createElement("button"); x.className = "rm"; x.textContent = "✕";
x.setAttribute("aria-label", "Remove " + it.title + " from recently watched");
x.onclick = () => opts.remove(it);
w.append(c, x); g.append(w);
});
}
/* Recently watched: remover um título ou limpar tudo */
const HIST_EMPTY = "You haven't watched anything yet.";
const histOpts = () => ({remove:removeHist, clear:clearHist});
function removeHist(it){
data.hist = data.hist.filter(x => x.id !== it.id);
delRow("hist", it);
gridPage(data.hist, "Recently watched", HIST_EMPTY, histOpts());
}
function clearHist(){
if (!confirm("Clear your recently watched list?")) return;
data.hist = [];
report(sb.from("library").delete().eq("kind", "hist"), "your history");
gridPage(data.hist, "Recently watched", HIST_EMPTY, histOpts());
}
function renderAcct(){
const a = $("#acct");
if (me()){
a.innerHTML = `<button class="avatar" id="avBtn" aria-label="Account menu" aria-expanded="false">😎</button>
<div class="menu" id="acctMenu" hidden><div class="who">${esc(user.email)}</div><button class="btn ghost" id="outBtn">Sign out</button></div>`;
$("#avBtn").onclick = e => {
e.stopPropagation();
const m = $("#acctMenu"); m.hidden = !m.hidden;
$("#avBtn").setAttribute("aria-expanded", String(!m.hidden));
};
$("#outBtn").onclick = () => sb.auth.signOut();
} else {
a.innerHTML = `<button class="btn" id="inBtn">Sign in</button>`;
$("#inBtn").onclick = () => openAuth("in");
}
}
const closeMenu = () => { const m = $("#acctMenu"); if (m) { m.hidden = true; $("#avBtn")?.setAttribute("aria-expanded", "false"); } };
document.addEventListener("click", e => { const m = $("#acctMenu"); if (m && !m.hidden && !m.contains(e.target)) closeMenu(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") closeMenu(); });
async function route(){
const my = ++state.token; // ignora respostas atrasadas de páginas anteriores
const parts = location.hash.slice(2).split("/").map(safe);
const r = parts[0] || "home";
document.querySelectorAll("#nav a").forEach(a => a.setAttribute("aria-current", a.dataset.r === r ? "page" : "false"));
scrollTo(0, 0);
if (r === "title") return titlePage(parts[1], parts.slice(2).join("/"), my);
view.innerHTML = '<p class="empty">Loading…</p>';
if (r === "search"){
const q = parts.slice(1).join("/"); $("#q").value = q;
const items = await SOURCE.search(q);
if (my === state.token) gridPage(items, `Results for “${q}”`, "Nothing found. Try another title.");
} else if (r === "list" || r === "history"){
const title = r === "list" ? "My list" : "Recently watched";
if (!me()) return gridPage([], title, "Sign in to see this section.");
gridPage(r === "list" ? me().list : me().hist, title,
r === "list" ? "Nothing saved yet. Open a title and choose Save to list." : HIST_EMPTY,
r === "history" ? histOpts() : {});
} else {
const items = await SOURCE.list();
if (my === state.token) gridPage(items, "Discover", SOURCE.error
? `Couldn't load titles (${SOURCE.error}). Check tmdbKey and the list addresses; press F12 for details.` : "No results.");
}
}
async function titlePage(type, id, my){
view.innerHTML = '<p class="empty">Loading…</p>';
const it = cache.get(type + ":" + id) || await SOURCE.item(type, id);
if (my !== state.token) return;
state.current = it; state.ep = {season:1, episode:1};
view.innerHTML = `<section class="stage on"><div class="frame" id="frame"></div>
<div class="meta"><div><h2>${esc(it.title)}${it.year ? " (" + esc(it.year) + ")" : ""}</h2><p>${esc(it.overview)}</p></div>
<div><button class="btn ghost" id="favBtn"></button></div></div><div id="eps"></div></section>`;
renderFav(); play(); addHist(it);
if (it.type === "tv") loadEpisodes(it, my);
}
async function loadEpisodes(it, my){
const seasons = it.seasons || await SOURCE.seasons(it) || {1:12};
if (my !== state.token) return;
const box = $("#eps");
const sel = document.createElement("select"); sel.className = "seasonSel"; sel.setAttribute("aria-label", "Season");
Object.keys(seasons).forEach(n => sel.add(new Option("Season " + n, n)));
const row = document.createElement("div"); row.className = "eprow";
box.replaceChildren(sel, row);
const mark = () => row.querySelectorAll(".ep").forEach(b =>
b.setAttribute("aria-current", state.ep.season === +sel.value && state.ep.episode === +b.dataset.n));
const show = async () => {
const s = +sel.value, eps = await SOURCE.episodes(it, s, seasons[s]);
if (my !== state.token || +sel.value !== s) return;
row.replaceChildren(...eps.map(e => {
const b = document.createElement("button"); b.className = "ep"; b.dataset.n = e.n;
b.innerHTML = `<div class="still" style="${e.still ? `background-image:url('${esc(e.still)}')` : ""}">${e.still ? "" : "E" + e.n}</div>
<b>${e.n}. ${esc(e.name)}</b><small>Season ${s} · Episode ${e.n}</small>`;
b.onclick = () => {
if (suppressClick) return;
state.ep = {season:s, episode:e.n}; mark(); play();
$("#frame").scrollIntoView({behavior:"smooth", block:"center"});
};
return b;
}));
row.scrollLeft = 0; mark();
};
sel.onchange = show;
show();
}
/* Arrastar a fila de episódios com o rato (no telemóvel basta deslizar) */
let drag = null, suppressClick = false;
document.addEventListener("pointerdown", e => {
const el = e.target.closest?.(".eprow");
if (el && e.pointerType === "mouse") drag = {el, sx:e.clientX, sl:el.scrollLeft, moved:false};
});
document.addEventListener("pointermove", e => {
if (!drag) return;
const dx = e.clientX - drag.sx;
if (Math.abs(dx) > 5){ drag.moved = true; drag.el.classList.add("drag"); }
if (drag.moved) drag.el.scrollLeft = drag.sl - dx;
});
document.addEventListener("pointerup", () => {
if (!drag) return;
drag.el.classList.remove("drag");
if (drag.moved){ suppressClick = true; setTimeout(() => suppressClick = false, 0); }
drag = null;
});
function renderFav(){
const b = $("#favBtn"), it = state.current;
if (!b || !it) return;
const saved = !!me() && me().list.some(x => x.id === it.id);
b.textContent = saved ? "Remove from list" : "Save to list";
b.onclick = () => { if (!me()) return openAuth("in"); toggleList(it, saved); };
}
function play(){
const it = state.current, f = $("#frame");
const url = SOURCE.embedUrl(it, it.type === "tv" ? state.ep : null);
f.replaceChildren();
if (!url){
f.innerHTML = `<div class="blank"><b>Embed not set up</b>Fill in <code>movieEmbed</code> and <code>tvEmbed</code> in SOURCE_CONFIG, at the top of the script, and the video will appear here.</div>`;
return;
}
const ifr = document.createElement("iframe");
ifr.src = url;
ifr.allowFullscreen = true;
ifr.allow = "autoplay; fullscreen; picture-in-picture";
ifr.referrerPolicy = "origin";
ifr.title = it.title;
f.append(ifr);
}
/* Pesquisa: abre a página de resultados */
$("#searchForm").onsubmit = e => {
e.preventDefault();
const q = $("#q").value.trim(), h = q ? "#/search/" + encodeURIComponent(q) : "#/";
if (location.hash === h) route(); else location.hash = h;
};
/* Janela de conta */
let mode = "in";
function openAuth(m){
mode = m;
$("#authTitle").textContent = m === "in" ? "Sign in" : "Create account";
$("#authGo").textContent = m === "in" ? "Sign in" : "Create account";
$("#authSwitch").textContent = m === "in" ? "Create account" : "I have an account";
$("#authErr").textContent = "";
$("#authDlg").showModal();
}
$("#authSwitch").onclick = () => openAuth(mode === "in" ? "up" : "in");
$("#authCancel").onclick = () => $("#authDlg").close();
$("#authForm").onsubmit = async e => {
e.preventDefault();
const err = $("#authErr");
if (!sb){ err.textContent = "Paste SUPABASE_URL and SUPABASE_KEY into the code first."; return; }
const email = $("#uName").value.trim(), password = $("#uPass").value;
const {data: d, error} = mode === "up" ? await sb.auth.signUp({email, password}) : await sb.auth.signInWithPassword({email, password});
if (error){ err.textContent = error.message; return; }
if (mode === "up" && !d.session){ err.textContent = "Account created. Confirm the email we sent you, then sign in."; return; }
$("#authDlg").close(); $("#authForm").reset();
};
if (set(C.tmdbKey)) $("#tmdbNote").textContent = "This product uses the TMDB API but is not endorsed or certified by TMDB.";
addEventListener("hashchange", route);
initAuth();
</script>
</body>
</html>