PageSourceSearch

https://festivaliminente.com/shared.js

js festivaliminente.com collected 2026-10-02 09:55:21 UTC 46,410 bytes, 1,033 lines download raw bytes

1
2"use strict";
3
4const LOGO = {
5  desktop: "/assets/images/logo-2026/IMNT-WebsiteAssets-10LayoutLogo2-Desktop.svg",
6  tablet: "/assets/images/logo-2026/IMNT-WebsiteAssets-10LayoutLogo2-Tablet.svg",
7  mobile: "/assets/images/logo-2026/IMNT-WebsiteAssets-10LayoutLogo2-Mobile.svg"
8};
9
10const SPOTIFY_PLAYLIST = "4LUeXfUtL55p3AbszRmOPZ";
11
12const NEWSLETTER_ENDPOINT = "https://hook.eu1.make.com/qvearhjtb5zta0stbqbc2pj9ungh5mpr";
13const SIG = { pt: "/assets/images/signature-2026/IMNT2026-Signature-PT.png", en: "/assets/images/signature-2026/IMNT2026-Signature-EN.png" };
14
15const CAROUSEL_PHOTOS = [
16  "/assets/images/carrossel-2026/imnt26-01.webp",
17  "/assets/images/carrossel-2026/imnt26-02.webp",
18  "/assets/images/carrossel-2026/imnt26-03.webp",
19  "/assets/images/carrossel-2026/imnt26-04.webp",
20  "/assets/images/carrossel-2026/imnt26-05.webp",
21  "/assets/images/carrossel-2026/imnt26-06.webp",
22  "/assets/images/carrossel-2026/imnt26-07.webp",
23  "/assets/images/carrossel-2026/imnt26-08.webp",
24  "/assets/images/carrossel-2026/imnt26-09.webp",
25  "/assets/images/carrossel-2026/imnt26-10.webp",
26  "/assets/images/carrossel-2026/imnt26-11.webp",
27  "/assets/images/carrossel-2026/imnt26-12.webp",
28  "/assets/images/carrossel-2026/imnt26-13.webp",
29  "/assets/images/carrossel-2026/imnt26-14.webp",
30  "/assets/images/carrossel-2026/imnt26-15.webp",
31  "/assets/images/carrossel-2026/imnt26-16.webp",
32  "/assets/images/carrossel-2026/imnt26-17.webp",
33  "/assets/images/carrossel-2026/imnt26-18.webp",
34  "/assets/images/carrossel-2026/imnt26-19.webp",
35  "/assets/images/carrossel-2026/imnt26-20.webp",
36  "/assets/images/carrossel-2026/imnt26-21.webp",
37  "/assets/images/carrossel-2026/imnt26-22.webp",
38  "/assets/images/carrossel-2026/imnt26-23.webp",
39  "/assets/images/carrossel-2026/imnt26-24.webp",
40  "/assets/images/carrossel-2026/imnt26-25.webp",
41  "/assets/images/carrossel-2026/imnt26-26.webp",
42  "/assets/images/carrossel-2026/imnt26-27.webp",
43  "/assets/images/carrossel-2026/imnt26-28.webp",
44  "/assets/images/carrossel-2026/imnt26-29.webp",
45  "/assets/images/carrossel-2026/imnt26-30.webp",
46  "/assets/images/carrossel-2026/imnt26-31.webp",
47  "/assets/images/carrossel-2026/imnt26-32.webp",
48  "/assets/images/carrossel-2026/imnt26-33.webp"
49];
50
51const SPONSORS = {
52  pt: {
53    mobile: "/assets/images/sponsors-v6/IMNT2026-APOIOS_4Linhas-Preto.svg",
54    tablet: "/assets/images/sponsors-v6/IMNT2026-APOIOS_3Linhas-Preto.svg",
55    desktop: "/assets/images/sponsors-v6/IMNT2026-APOIOS_2Linhas-Preto.svg"
56  },
57  en: {
58    mobile: "/assets/images/sponsors-v6/IMNT2026-SPONSORS_4Lines-Black.svg",
59    tablet: "/assets/images/sponsors-v6/IMNT2026-SPONSORS_3Lines-Black.svg",
60    desktop: "/assets/images/sponsors-v6/IMNT2026-SPONSORS_2Lines-Black.svg"
61  }
62};
63
64const MAP = {
65  display: "/assets/images/mapa-2026/IMNT2026-MapaRecinto-2400.webp",
66  full: "/assets/images/mapa-2026/IMNT2026-MapaRecinto-full.webp"
67};
68
69const T = {
70  pt: {
71    lang: "pt",
72    keyinfo: "17–20 SETEMBRO 2026 · MARVILA, LISBOA",
73    
74    hdr_loc: "ESCOLA INDUSTRIAL AFONSO DOMINGUES · MARVILA, LISBOA",
75    hdr_dates: "17 · 18 · 19 · 20 SETEMBRO 2026",
76    ticker: "FESTIVAL IMINENTE 2026 · 17–20 SETEMBRO · ESCOLA INDUSTRIAL AFONSO DOMINGUES, MARVILA, LISBOA · ",
77    masthead_alt: "Festival Iminente — 17 a 20 de setembro de 2026, Escola Afonso Domingues, Marvila",
78
79    nav: { art: "ARTISTAS", ed: "EDIÇÕES", press: "PRESS", ft: "FICHA TÉCNICA" },
80    
81    ed_title: "10 ANOS, EDIÇÃO A EDIÇÃO",
82    ed_lede: "De um jardim municipal em Oeiras a mais de 70.000 pessoas na praça mais central do país. Cada edição num lugar que a cidade tinha desistido de usar.",
83    ed_teaser: "10 ANOS, EDIÇÃO A EDIÇÃO",
84    ed_teaser_cta: "VER TODAS AS EDIÇÕES",
85    ed_global: "ALÉM DE PORTUGAL",
86    ed_global_lede: "Londres, Xangai, Rio de Janeiro, Marselha.",
87    ed_photo: "FOTOGRAFIA",
88    ed_film: "VER O FILME DA EDIÇÃO",
89    ed_lineup: "CARTAZ",
90    ed_provisional: "Textos e fotografias vindos da apresentação dos 10 anos — provisórios, por aprovar. Ficheiros finais a pedido.",
91    dnames: { "17": "QUINTA-FEIRA", "18": "SEXTA-FEIRA", "19": "SÁBADO", "20": "DOMINGO" },
92    off: "OFF STAGE", off_perm: "EM PERMANÊNCIA", all: "TODOS",
93    prog_note: "Programa sujeito a alterações.",
94    
95    nota_nenny: "Por motivos de saúde, a Nenny não irá atuar no Festival Iminente 2026.",
96    
97    pf_title: "Quem veio, sabe. Quem não veio, já percebeu.",
98    pf_car_label: "FOTOGRAFIAS DO FESTIVAL IMINENTE 2026",
99    pf_prev: "FOTOS ANTERIORES", pf_next: "FOTOS SEGUINTES",
100    pf_alt: "Festival Iminente 2026, fotografia",
101    
102    claim: "SÃO 10 ANOS DE IMINENTE.",
103    sig_alt: "Quem é que te convidou?",
104
105    map_title: "Fomos felizes aqui. E sabíamos.",
106    map_open: "ABRIR MAPA EM TAMANHO REAL",
107    map_alt: "Mapa do recinto do Festival Iminente 2026 — palcos, arte, comida, lojas e serviços na Escola Industrial Afonso Domingues, Marvila",
108    
109    playlist_title: "Lembra-te Como Foi",
110    fsbtn: "VER EM FULLSCREEN COM SOM",
111    
112    cat_lbl: "CATEGORIA", day_lbl: "DIA",
113    art_title: "ARTISTAS",
114    gridnote: "Categorias provisórias — por confirmar.",
115    cats: { "MÚSICA": "MÚSICA", "PERFORMANCE": "PERFORMANCE", "ARTES VISUAIS": "ARTES VISUAIS", "EXPOSIÇÕES": "EXPOSIÇÕES", "CINEMA": "CINEMA", "CONVERSAS IMINENTE": "CONVERSAS IMINENTE", "TALKS": "TALKS" },
116    see: "QUERES VER", 
117    bio: "Apresentação do artista e do projeto que traz ao Iminente 2026. Percurso, contexto e o que esperar em Marvila.",
118    bio_pending: "Texto de apresentação — a fornecer",
119    close: "FECHAR ✕",
120    
121    info_title: "INFORMAÇÕES",
122    ft_title: "FICHA TÉCNICA",
123    
124    press_title: "PRESS", epk: "DESCARREGAR EPK",
125    pf: { nome: "NOME", cart: "Nº CARTEIRA DE JORNALISTA", txt: "TEXTO", send: "PEDIR ACREDITAÇÃO", subject: "Pedido de acreditação de imprensa", ok: "Abrimos o teu email com o pedido preenchido — carrega em enviar." },
126    news_hd: "NOTÍCIAS", news_row: "Título da notícia — conteúdo a fornecer",
127    press_contacts: "CONTACTOS DE IMPRENSA", pc_name: "Nome", pc_email: "Email", pc_phone: "Telefone", pc_tbc: "A fornecer",
128    
129    nl_title: "É MELHOR ESTARES NA NEWSLETTER!",
130    nl_ph: "[email protected]", nl_btn: "SUBSCREVER",
131    nl_ok: "Obrigado! Estás na lista.", nl_bad: "Email inválido.",
132    nl_err: "Não foi possível subscrever agora. Tenta outra vez.",
133    nl_consent_a: "Ao forneceres o teu email estás a indicar que leste e aceitaste a nossa ",
134    nl_consent_priv: "Política de Privacidade",
135    nl_consent_mid: " e ",
136    nl_consent_terms: "Termos e Condições",
137    nl_consent_b: ".",
138    quick: "LINKS RÁPIDOS", redes: "REDES", legal: "LEGAL",
139    terms: "TERMOS E CONDIÇÕES", privacy: "POLÍTICA DE PRIVACIDADE E COOKIES",
140    sponsors_alt: "Parceiros do Festival Iminente 2026",
141    load_fail: "Não foi possível carregar a programação. Tenta recarregar a página."
142  },
143  en: {
144    lang: "en",
145    keyinfo: "17–20 SEPTEMBER 2026 · MARVILA, LISBON",
146    hdr_loc: "ESCOLA INDUSTRIAL AFONSO DOMINGUES · MARVILA, LISBON",
147    hdr_dates: "17 · 18 · 19 · 20 SEPTEMBER 2026",
148    ticker: "FESTIVAL IMINENTE 2026 · 17–20 SEPTEMBER · ESCOLA INDUSTRIAL AFONSO DOMINGUES, MARVILA, LISBON · ",
149    masthead_alt: "Festival Iminente — 17 to 20 September 2026, Escola Afonso Domingues, Marvila",
150    nav: { art: "ARTISTS", ed: "EDITIONS", press: "PRESS", ft: "CREDITS" },
151    ed_title: "TEN YEARS, EDITION BY EDITION",
152    ed_lede: "From a municipal garden in Oeiras to more than 70,000 people in the most central square in the country. Every edition built in a place the city had given up on.",
153    ed_teaser: "TEN YEARS, EDITION BY EDITION",
154    ed_teaser_cta: "SEE EVERY EDITION",
155    ed_global: "BEYOND PORTUGAL",
156    ed_global_lede: "London, Shanghai, Rio de Janeiro, Marseille.",
157    ed_photo: "PHOTOGRAPHY",
158    ed_film: "WATCH THE EDITION FILM",
159    ed_lineup: "LINE-UP",
160    ed_provisional: "Copy and photographs taken from the 10th-anniversary presentation — provisional, pending approval. Final files on request.",
161    dnames: { "17": "THURSDAY", "18": "FRIDAY", "19": "SATURDAY", "20": "SUNDAY" },
162    off: "OFF STAGE", off_perm: "ALL FESTIVAL LONG", all: "ALL",
163    prog_note: "Programme subject to change.",
164    nota_nenny: "For health reasons, Nenny will not be performing at Festival Iminente 2026.",
165    
166    pf_title: "If you were there, you know. If you weren't, you get it now.",
167    pf_car_label: "PHOTOS FROM FESTIVAL IMINENTE 2026",
168    pf_prev: "PREVIOUS PHOTOS", pf_next: "NEXT PHOTOS",
169    pf_alt: "Festival Iminente 2026, photo",
170    claim: "TEN YEARS OF IMINENTE.",
171    sig_alt: "Who invited you?",
172    map_title: "We were happy here. And we knew it.",
173    map_open: "OPEN FULL-SIZE MAP",
174    map_alt: "Festival Iminente 2026 venue map — stages, art, food, stores and services at Escola Industrial Afonso Domingues, Marvila",
175    playlist_title: "Remember How It Was",
176    fsbtn: "WATCH FULLSCREEN WITH SOUND",
177    cat_lbl: "CATEGORY", day_lbl: "DAY",
178    art_title: "ARTISTS",
179    gridnote: "Categories provisional — to be confirmed.",
180    cats: { "MÚSICA": "MUSIC", "PERFORMANCE": "PERFORMANCE", "ARTES VISUAIS": "VISUAL ARTS", "EXPOSIÇÕES": "EXHIBITIONS", "CINEMA": "CINEMA", "CONVERSAS IMINENTE": "CONVERSAS IMINENTE", "TALKS": "TALKS" },
181    see: "WANT TO SEE",
182    bio: "Introduction to the artist and the project they bring to Iminente 2026. Backgroun
182d, context and what to expect in Marvila.",
183    bio_pending: "Artist introduction — to be provided",
184    close: "CLOSE ✕",
185    info_title: "INFORMATION",
186    ft_title: "CREDITS",
187    press_title: "PRESS", epk: "DOWNLOAD EPK",
188    pf: { nome: "NAME", cart: "PRESS CARD Nº", txt: "MESSAGE", send: "REQUEST ACCREDITATION", subject: "Press accreditation request", ok: "We opened your mail app with the request filled in — press send." },
189    news_hd: "NEWS", news_row: "News headline — content to be provided",
190    press_contacts: "PRESS CONTACTS", pc_name: "Name", pc_email: "Email", pc_phone: "Phone", pc_tbc: "To be provided",
191    nl_title: "YOU'D BETTER BE ON THE NEWSLETTER!",
192    nl_ph: "[email protected]", nl_btn: "SUBSCRIBE",
193    nl_ok: "Thanks! You're on the list.", nl_bad: "Invalid email.",
194    nl_err: "Could not subscribe right now. Please try again.",
195    nl_consent_a: "By giving us your email you confirm that you have read and accepted our ",
196    nl_consent_priv: "Privacy Policy",
197    nl_consent_mid: " and ",
198    nl_consent_terms: "Terms and Conditions",
199    nl_consent_b: ".",
200    quick: "QUICK LINKS", redes: "SOCIAL", legal: "LEGAL",
201    terms: "TERMS AND CONDITIONS", privacy: "PRIVACY AND COOKIE POLICY",
202    sponsors_alt: "Festival Iminente 2026 partners",
203    load_fail: "Could not load the programme. Try reloading the page."
204  }
205};
206
207const CATS = ["MÚSICA", "PERFORMANCE", "ARTES VISUAIS", "EXPOSIÇÕES", "CINEMA", "CONVERSAS IMINENTE"];
208const DAYS = ["17", "18", "19", "20"];
209
210function frameFor(a) {
211  const d = (a.days || []).find(x => x !== "off-stage");
212  return d ? "f-d" + d : "f-doff";
213}
214const CATSLUG = { "musica": "MÚSICA", "performance": "PERFORMANCE", "artes-visuais": "ARTES VISUAIS", "exposicoes": "EXPOSIÇÕES", "cinema": "CINEMA", "conversas": "CONVERSAS IMINENTE" };
215
216const BASE = "";
217
218const $ = s => document.querySelector(s);
219const PAGE = document.body.dataset.page;
220
221const HDR_POOL = ["--yellow", "--cyan", "--green", "--pink", "--blue"];
222const PICK_POOL = ["--yellow", "--cyan", "--green", "--pink", "--blue"];
223const rnd = a => a[Math.floor(Math.random() * a.length)];
224(function paintOnce() {
225  const r = document.documentElement.style;
226  r.setProperty("--hdr-bg", `var(${rnd(HDR_POOL)})`);
227  r.setProperty("--pick", `var(${rnd(PICK_POOL)})`);
228})();
229
230const CAROUSEL_ORDER = (() => {
231  const a = CAROUSEL_PHOTOS.slice();
232  for (let i = a.length - 1; i > 0; i--) {
233    const j = Math.floor(Math.random() * (i + 1));
234    const t = a[i]; a[i] = a[j]; a[j] = t;
235  }
236  return a;
237})();
238
239function pickLang() {
240  const q = new URLSearchParams(location.search).get("lang");
241  if (q === "en" || q === "pt") return q;
242  try { const s = localStorage.getItem("imnt-lang"); if (s === "en" || s === "pt") return s; } catch (e) { }
243  return "pt";
244}
245let L = pickLang();
246let S = T[L];
247function setLang(next) {
248  if (next === L) return;
249  L = next; S = T[L];
250  try { localStorage.setItem("imnt-lang", L); } catch (e) { }
251  const p = new URLSearchParams(location.search);
252  L === "pt" ? p.delete("lang") : p.set("lang", "en");
253  const q = p.toString();
254  history.replaceState(null, "", location.pathname + (q ? "?" + q : "") + location.hash);
255  render();
256}
257function esc(x) { const d = document.createElement("div"); d.textContent = x; return d.innerHTML; }
258
259const ICONS = {
260  instagram: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.2c3.2 0 3.6 0 4.9.07 1.2.05 1.8.25 2.2.42.6.23 1 .5 1.4.95.45.4.72.8.95 1.4.17.4.37 1 .42 2.2.07 1.3.07 1.7.07 4.9s0 3.6-.07 4.9c-.05 1.2-.25 1.8-.42 2.2-.23.6-.5 1-.95 1.4-.4.45-.8.72-1.4.95-.4.17-1 .37-2.2.42-1.3.07-1.7.07-4.9.07s-3.6 0-4.9-.07c-1.2-.05-1.8-.25-2.2-.42-.6-.23-1-.5-1.4-.95-.45-.4-.72-.8-.95-1.4-.17-.4-.37-1-.42-2.2C2.2 15.6 2.2 15.2 2.2 12s0-3.6.07-4.9c.05-1.2.25-1.8.42-2.2.23-.6.5-1 .95-1.4.4-.45.8-.72 1.4-.95.4-.17 1-.37 2.2-.42C8.4 2.2 8.8 2.2 12 2.2zm
2600 3.2A6.6 6.6 0 1 0 18.6 12A6.6 6.6 0 0 0 12 5.4zm0 2.2a4.4 4.4 0 1 1 0 8.8a4.4 4.4 0 0 1 0-8.8zm5.6-2.9a1.4 1.4 0 1 1 0 2.8a1.4 1.4 0 0 1 0-2.8z"/></svg>',
261  youtube: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21.6 7.2c-.2-1.1-1-1.9-2-2.1C17.7 4.7 12 4.7 12 4.7s-5.7 0-7.6.4c-1 .2-1.8 1-2 2.1C2 9.1 2 12 2 12s0 2.9.4 4.8c.2 1.1 1 1.9 2 2.1 1.9.4 7.6.4 7.6.4s5.7 0 7.6-.4c1-.2 1.8-1 2-2.1.4-1.9.4-4.8.4-4.8s0-2.9-.4-4.8zM9.8 15.5v-7l6 3.5-6 3.5z"/></svg>',
262  spotify: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20a10 10 0 0 0 0-20zm4.6 14.4a.9.9 0 0 1-1.2.3c-3.1-1.9-7-2.3-11.1-1.4a.9.9 0 1 1-.4-1.7c4.5-1 8.9-.5 12.4 1.6a.9.9 0 0 1 .3 1.2zm1.3-3a1.1 1.1 0 0 1-1.5.4c-3.5-2.2-8.9-2.8-13-1.5a1.1 1.1 0 1 1-.7-2.1c4.8-1.5 10.7-.8 14.8 1.7a1.1 1.1 0 0 1 .4 1.5zm.1-3.1C14.1 7.8 7.6 7.6 3.9 8.8a1.3 1.3 0 1 1-.8-2.5C7.4 5 14.6 5.2 19 7.9a1.3 1.3 0 0 1-1 2.4z"/></svg>'
263};
264const SOCIALS = [
265  ["instagram", "https://www.instagram.com/festivaliminente/", "Instagram"],
266  ["youtube", "https://www.youtube.com/@festival_iminente", "YouTube"],
267  ["spotify", "https://open.spotify.com/user/neh3z7sjssodwfo47yoxzqynq", "Spotify"]
268];
269
270const PRESS_CONTACTS = [{ name: "Helena César", email: "[email protected]" }];
271
272const EPK_URL = "https://drive.google.com/drive/folders/1FJqWTQk3c0y7P2RfQc2nyVckHengordV?usp=sharing";
273const PRESS_INBOX = "[email protected]";
274
275function langswHTML() {
276  return `<div class="langsw" role="group" aria-label="PT / EN">
277      <button type="button" data-lang="pt" aria-pressed="${L === "pt"}">PT</button>
278      <button type="button" data-lang="en" aria-pressed="${L === "en"}">EN</button>
279    </div>`;
280}
281
282function headerHTML() {
283  const cur = k => (PAGE === k ? ' aria-current="page"' : "");
284  return `
285<div class="topbar">
286  ${langswHTML()}
287</div>
288<header class="site">
289  <a class="masthead" href="${BASE}/${L === "en" ? "?lang=en" : ""}">
290    <picture>
291      <img src="${LOGO.desktop}" alt="${esc(S.masthead_alt)}">
292    </picture>
293  </a>
294  <p class="hdrinfo">
295    <span class="loc">${esc(S.hdr_loc)}</span>
296    <span class="dat">${esc(S.hdr_dates)}</span>
297  </p>
298  <div class="navwrap">
299    <nav class="main" aria-label="${L === "en" ? "Main navigation" : "Navegação principal"}">
300      <a href="${BASE}/artistas"${cur("artistas")}>${S.nav.art}</a>
301      <a href="${BASE}/edicoes"${cur("edicoes")}>${S.nav.ed}</a>
302      <a href="${BASE}/press"${cur("press")}>${S.nav.press}</a>
303      <a href="${BASE}/ficha-tecnica"${cur("ficha")}>${S.nav.ft}</a>
304    </nav>
305  </div>
306</header>
307`;
308}
309
310function tenBandHTML() {
311  return `
312<section class="tenband" aria-label="${esc(S.claim)}">
313  <p class="claim">${S.claim}</p>
314  <div class="sig"><img src="${SIG[L]}" alt="${esc(S.sig_alt)}"></div>
315</section>`;
316}
317
318function mapBlockHTML(cls) {
319  return `
320<section class="mapblock${cls ? " " + cls : ""}" aria-labelledby="maphd">
321  <h2 id="maphd">${S.map_title}</h2>
322  <a class="mapshot" href="${MAP.full}" target="_blank" rel="noopener">
323    <img src="${MAP.display}" width="2400" height="2400" alt="${esc(S.map_alt)}" loading="lazy" decoding="async">
324  </a>
325  <a class="mapopen" href="${MAP.full}" target="_blank" rel="noopener">${S.map_open}</a>
326</section>`;
327}
328
329const PF_EAGER = 1;
330function pfBlockHTML() {
331  const i = S.pf_title.indexOf(". ");
332  const halves = i < 0 ? [S.pf_title] : [S.pf_title.slice(0, i + 1), S.pf_title.slice(i + 2)];
333  const n = CAROUSEL_ORDER.length;
334  const slides = CAROUSEL_ORDER.map((src, k) => `
335      <li><img ${k < PF_EAGER ? `src="${src}"` : `data-src="${src}"`} width="480" height="600"
336        alt="${esc(S.pf_alt)} ${k + 1}/${n}"
337        ${k === 0 ? 'loading="eager" fetchpriority="high"' : 'loading="lazy"'} decoding="async"></li>`).join("");
338  return `
339<section class="pfblock" aria-labelledby="pfhd">
340  <h2 id="pfhd" class="pfline">${halves.map(h => `<span>${esc(h)}</span>`).join(" ")}</h2>
341  <div class="pfcar" role="group" aria-label="${esc(S.pf_car_label)}">
342    <button type="button" class="pfnav pfprev" aria-label="${esc(S.pf_prev)}"><span aria-hidden="true">&lt;</span></button>
343    <div class="pfview"><ul class="pftrack">${slides}</ul></div>
344    <button type="button" class="pfnav pfnext" aria-label="${esc(S.pf_next)}"><span aria-hidden="true">&gt;</span></button>
345  </div>
346</section>`;
347}
348
349function bindCarousel() {
350  const car = document.querySelector(".pfcar");
351  if (!car) return;
352  const view = car.querySelector(".pfview");
353  const track = car.querySelector(".pftrack");
354  const items = [...track.children];
355  const prev = car.querySelector(".pfprev"), next = car.querySelector(".pfnext");
356  let at = 0;
357
358  const step = () => {
359    if (items.length < 2) return items.length ? items[0].getBoundingClientRect().width : 1;
360    
361    return items[1].getBoundingClientRect().left - items[0].getBoundingClientRect().left;
362  };
363  const perView = () => {
364    const s = step();
365    return s > 0 ? Math.max(1, Math.round(view.clientWidth / s)) : 1;
366  };
367  function apply() {
368    const last = Math.max(0, items.length - perView());
369    at = Math.min(Math.max(at, 0), last);
370    track.style.transform = `translateX(${-at * step()}px)`;
371    
372    prev.disabled = at === 0;
373    next.disabled = at >= last;
374  }
375  const go = d =>
375 { at += d; apply(); };
376  prev.onclick = () => go(-1);
377  next.onclick = () => go(1);
378  
379  car.addEventListener("keydown", e => {
380    if (e.key === "ArrowLeft") { go(-1); e.preventDefault(); }
381    else if (e.key === "ArrowRight") { go(1); e.preventDefault(); }
382  });
383
384  if (typeof IntersectionObserver === "function") {
385    const io = new IntersectionObserver((entries, obs) => {
386      entries.forEach(en => {
387        if (!en.isIntersecting) return;
388        const img = en.target.querySelector("img");
389        if (img && img.dataset.src) { img.src = img.dataset.src; delete img.dataset.src; }
390        obs.unobserve(en.target);
391      });
392    }, { root: view, rootMargin: "0px 25% 0px 25%" });
393    items.forEach(li => { if (li.querySelector("img[data-src]")) io.observe(li); });
394  } else {
395    items.forEach(li => { const i = li.querySelector("img[data-src]"); if (i) { i.src = i.dataset.src; delete i.dataset.src; } });
396  }
397  carouselApply = apply;
398  apply();
399}
400let carouselApply = null;
401addEventListener("resize", () => { if (carouselApply) carouselApply(); });
402
403function footerHTML() {
404  return `
405<footer class="site">
406  <div class="news-block">
407    <h2>${S.nl_title}</h2>
408    <form id="nlform" novalidate>
409      <input id="nlemail" type="email" placeholder="${esc(S.nl_ph)}" aria-label="Email">
410      <button type="submit">${S.nl_btn}</button>
411    </form>
412    <p class="msg" id="nlmsg" role="status"></p>
413    <p class="consent">${S.nl_consent_a}<a href="${BASE}/privacidade${qLang()}">${S.nl_consent_priv}</a>${S.nl_consent_mid}<a href="${BASE}/termos${qLang()}">${S.nl_consent_terms}</a>${S.nl_consent_b}</p>
414  </div>
415  <div class="sponsors">
416    <picture>
417      <source media="(max-width:640px)" srcset="${SPONSORS[L].mobile}">
418      <source media="(max-width:1200px)" srcset="${SPONSORS[L].tablet}">
419      <img src="${SPONSORS[L].desktop}" alt="${esc(S.sponsors_alt)}" loading="lazy">
420    </picture>
421  </div>
422  <div class="footgrid">
423    <div>
424      <ul>
425        <li><a href="${BASE}/artistas">${S.nav.art}</a></li>
426        <li><a href="${BASE}/edicoes">${S.nav.ed}</a></li>
427        <li><a href="${BASE}/press">${S.nav.press}</a></li>
428        <li><a href="${BASE}/ficha-tecnica">${S.nav.ft}</a></li>
429      </ul>
430    </div>
431    <div>
432      <ul>
433        <li><a href="${BASE}/termos${qLang()}">${S.terms}</a></li>
434        <li><a href="${BASE}/privacidade${qLang()}">${S.privacy}</a></li>
435        <li><a class="mail" href="mailto:[email protected]">[email protected]</a></li>
436      </ul>
437    </div>
438    <div>
439      <ul class="socials">
440        ${SOCIALS.map(([k, href, label]) => `<li><a href="${href}" target="_blank" rel="noopener">${label}</a></li>`).join("")}
441      </ul>
442    </div>
443  </div>
444</footer>`;
445}
446
447function bindShrink() {
448  if (bindShrink.done) return; bindShrink.done = true;
449  let compact = false, queued = false;
450  const SHRINK_AT = 4, RESTORE_AT = 0;
451  const apply = () => {
452    queued = false;
453    const y = window.scrollY;
454    if (!compact && y > SHRINK_AT) compact = true;
455    else if (compact && y <= RESTORE_AT) compact = false;
456    else return;
457    document.body.classList.toggle("scrolled", compact);
458  };
459  addEventListener("scroll", () => { if (!queued) { queued = true; requestAnimationFrame(apply); } }, { passive: true });
460  apply();
461}
462
463function syncHeaderHeight() {
464  const h = document.querySelector("header.site");
465  if (!h) return;
466  const set = () => document.documentElement.style.setProperty("--hdr-h", Math.round(h.getBoundingClientRect().height) + "px");
467  set();
468  if (window.ResizeObserver) new ResizeObserver(set).observe(h);
469  else addEventListener("resize", set);
470}
471
472function slug(n) {
473  return n.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
474    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
475}
476function initials(n) { return n.replace(/[^\p{L}\d ]/gu, "").split(" ").filter(Boolean).slice(0, 2).map(w => w[0]).join("").toUpperCase() || "?"; }
477
478function dayTag(a) {
479  
480  const pd = a.presence_days || [];
481  const pres = pd.length ? pd.join(" · ") + (L === "en" ? " SEP" : " SET") : "";
482  if (a.days.includes("off-stage")) return pres ? S.off + " · " + pres : S.off;
483  return a.days.map(d => d + (L === "en" ? " SEP" : " SET")).join(" · ");
484}
485function catLabel(c) { return (S.cats && S.cats[c]) || c; }
486
487function qLang() { return L === "en" ? "?lang=en" : ""; }
488
489let DATA = null;
490function loadData() {
491  if (DATA) return Promise.resolve(DATA);
492  return fetch("/data/artists.json")
493    .then(r => { if (!r.ok) throw new Error("artists.json " + r.status); return r.json(); }).then(j => {
494    
495    j.artists = j.artists.filter(a => !a.off_programme);
496    const seen = {};
497    j.artists.forEach((a, i) => {
498      a.slug = slug(a.name) || ("artista-" + i);
499      if (seen[a.slug]) a.slug += "-" + i;
500      seen[a.slug] = true;
501    });
502    DATA = j;
503    return j;
504  });
505}
506
507function festivalMinutes(t) {
508  const [h, m] = t.split(":").map(Number);
509  return (h < 6 ? h + 24 : h) * 60 + m;
510}
511function firstStart(a, day) {
512  const s = slotsFor(a, day);
513  return s.length ? Math.min(...s.map(x => festivalMinutes(x.time))) : Infinity;
514}
515function sortedForDay(list, day) {
516  return list.map((a, i) => ({ a, i, t: firstStart(a, day) }))
517    .sort((x, y) => x.t - y.t || x.i - y.i)
518    .map(x => x.a);
519}
520function slotsFor(a, day) { return (a.slots || []).filter(s => s.day === day); }
521
522function railFor(slots) {
523  if (!slots.length) return null;
524  slots = [...slots].sort((x, y) => festivalMinutes(x.time) - festivalMinutes(y.time));
525  const times = slots.map(s => s.time).join(" · ");
526  const stages = [...new Set(slots.map(s => s.stage).filter(Boolean))];
527  if (!stages.length) return { time: times, stage: null };
528  if (stages.length === 1) return { time: times, stage: stages[0] };
529  return { time: times, stage: slots.map(s => s.time + " " + s.stage).join(" · ") };
530}
531
532function progItem(a, opts) {
533  const li = document.createElement("li");
534  let h = "";
535  if (opts && opts.time != null) { li.className = "timed"; h += `<span class="tm"><b>${esc(opts.time)}</b></span>`; }
536  if (a.block) h += `<span class="blk">${esc(a.block)}</span>`;
537  h += `<span class="nm">${esc(a.name.toUpperCase())}</span>`;
538  if (opts && opts.stage) h += `<span class="stg">${esc(opts.stage)}</span>`;
539  if (a.detail) h += `<span class="dt">${esc(a.detail)}</span>`;
540  li.innerHTML = h;
541  return li;
542}
543
544function renderProgram() {
545  const ag = $("#agenda"); if (!ag || !DATA) return;
546  ag.innerHTML = "";
547  DAYS.forEach(d => {
548    const list = DATA.artists.filter(a => a.days.includes(d));
549    const col = document.createElement("div");
550    col.className = "daycol d" + d; col.id = "dia" + d;
551    
552    col.innerHTML = `<div class="daycol__head"><span class="dnum">${d} ${L === "en" ? "SEP" : "SET"}</span><br><span class="dname">${S.dnames[d]}</span></div>`;
553    const ul = document.createElement("ul");
554    sortedForDay(list, d).forEach(a => ul.appendChild(progItem(a, railFor(slotsFor(a, d)))));
555    col.appendChild(ul); ag.appendChild(col);
556  });
557  const pn = $("#prognote");
558  if (pn) pn.textContent = S.prog_note;
559  const off = DATA.artists.filter(a => a.days.includes("off-stage"));
560  const ohead = $("#offhead");
561  if (ohead) ohead.innerHTML = `<span class="dnum">${S.off}</span> <span class="dname">${S.off_perm}</span>`;
562  const ol = $("#offlist");
563  ol.innerHTML = "";
564  
565  off.forEach(a => {
566    const st = [...new Set((a.slots || []).map(s => s.stage).filter(Boolean))];
567    ol.appendChild(progItem(a, st.length ? { stage: st.join(" · ") } : null));
568  });
569}
570
571function renderHome() {
572  
573  $("#playtitle").textContent = S.playlist_title;
574  renderProgram();
575}
576
577function initHome() {
578  const v = $("#promo"), fb = $("#fsbtn");
579  fb.onclick = async () => { try { await v.requestFullscreen(); v.muted = false; } catch (e) { v.muted = false; } };
580  document.addEventListener("fullscreenchange", () => { if (!document.fullscreenElement) v.muted = true; });
581  if (matchMedia("(prefers-reduced-motion: reduce)").matches) { v.autoplay = false; v.pause(); }
582  $("#spotifyframe").src = "https://open.spotify.com/embed/playlist/" + SPOTIFY_PLAYLIST;
583  loadData().then(renderHome).catch(() => { $("#agenda").innerHTML = `<p style="padding:20px" class="small">${S.load_fail}</p>`; });
584}
585
586const state = { cat: "ALL", day: "ALL" };
587
588function readURL() {
589  const p = new URLSearchParams(location.search);
590  const c = p.get("cat"), d = p.get("dia");
591  state.cat = (c && CATSLUG[c]) || "ALL";
592  state.day = (d && (DAYS.includes(d) || d === "off")) ? (d === "off" ? "off-stage" : d) : "ALL";
593}
594function writeURL() {
595  const p = new URLSearchParams();
596  if (state.cat !== "ALL") p.set("cat", Object.keys(CATSLUG).find(k => CATSLUG[k] === state.cat));
597  if (state.day !== "ALL") p.set("dia", state.day === "off-stage" ? "off" : state.day);
598  if (L === "en") p.set("lang", "en");
599  const q = p.toString();
600  history.replaceState(null, "", location.pathname + (q ? "?" + q : "") + location.hash);
601}
602
603const DAYCOL = { "17": "c17", "18": "c18", "19": "c19", "20": "c20", "off-stage": "coff" };
604function chip(label, val, kind) {
605  const b = document.createElement("button");
606  b.className = "chip" + (kind === "day" ? " day " + (DAYCOL[val] || "") : ""); b.textContent = label;
607  b.setAttribute("aria-pressed", String((kind === "cat" ? state.cat : state.day) === val));
608  b.onclick = () => { if (kind === "cat") state.cat = val; else state.day = val; writeURL(); renderFilters(); renderGrid(); };
609  return b;
610}
611function renderFilters() {
612  $("#daylbl").textContent = S.day_lbl;
613  const c = $("#catchips"); c.innerHTML = "";
614  c.appendChild(chip(S.all, "ALL", "cat"));
615  CATS.forEach(x => c.appendChild(chip(catLabel(x), x, "cat")));
616  const d = $("#daychips"); d.innerHTML = "";
617  d.appendChild(chip(S.all, "ALL", "day"));
618  DAYS.forEach(x => d.appendChild(chip(x, x, "day")));
619  d.appendChild(chip(S.off, "off-stage", "day"));
620}
621function visible() {
622  let l = DATA.artists.filter(a => !a.programme_only);
623  if (state.cat !== "ALL") l = l.filter(a => a.category === state.cat);
624  if (state.day !== "ALL") {
625    if (state.day === "off-stage") l = l.filter(a => a.days.includes("off-stage"));
626    else l = l.filter(a => a.days.includes(state.day) || a.days.includes("off-stage"));
627  }
628  return l.sort((a, b) => a.name.localeCompare(b.name, "pt", { sensitivity: "base" }));
629}
630function renderGrid() {
631  const g = $("#agrid"); g.innerHTML = "";
632  visible().forEach((a, i) => {
633    const b = document.createElement("button");
634    b.className = "acell " + frameFor(a);
635    b.setAttribute("aria-label", a.name);
636    b.dataset.slug = a.slug;
637    
638    b.innerHTML = `<span class="thumb">${a.photo ? `<img src="${a.photo}" alt="" loading="lazy" decoding="async">` : `<span class="init" aria-hidden="true">${initials(a.name)}</span>`}<span class="dtag">${dayTag(a)}</span><span class="plus" aria-hidden="true">+</span></span>
639      <span class="nm">${esc(a.name.toUpperCase())}</span>`;
640    b.onclick = () => { openDrawer(a, i); history.replaceState(null, "", location.pathname + location.search + "#" + a.slug); };
641    g.appendChild(b);
642  });
643}
644
645let lastFocus = null;
646let openArtist = null;
647function openDrawer(a, i) {
648  lastFocus = document.activeElement;
649  openArtist = { a, i };
650  const t = $("#dr-thumb");
651  t.className = "thumb " + frameFor(a);
652  t.innerHTML = a.photo ? `<img src="${a.photo}" alt="">` : `<span class="init">${initials(a.name)}</span>`;
653  
654  $("#dr-more").innerHTML = (a.photos && a.photos.length > 1)
655    ? a.photos.slice(1).map(p => `<span class="thumb"><img src="${p}" alt="" loading="lazy"></span>`).join("")
656    : "";
657  $("#dr-name").textContent = a.name.toUpperCase();
658
659  const slotChips = (a.slots || []).map(sl =>
660    `<span>${sl.day} ${L === "en" ? "SEP" : "SET"} · ${esc(sl.time)}`
661    + (sl.stage ? ` · ${esc(sl.stage.toUpperCase())}` : "")
662    + (sl.who ? ` · ${esc(sl.who.toUpperCase())}` : "") + `</span>`).join("");
663  
664  const hasSlotStage = (a.slots || []).some(sl => sl.stage);
665  $("#dr-meta").innerHTML = `<span>${dayTag(a)}</span><span>${esc(catLabel(a.category))}</span>`
666    + (!hasSlotStage && a.stage && a.stage.toUpperCase() !== "TBD"
667        ? `<span>${esc(a.stage.toUpperCase())}</span>` : "")
668    
669    + (a.block && a.block !== a.category ? `<span>${esc(a.block)}</span>` : "")
670    + slotChips;
671  
672  const bios = a.bios || [];
673  const multi = bios.length > 1;
674  $("#dr-bio").innerHTML = bios.length
675    ? bios.map(b => (multi ? `<h3 class="bioname">${esc(b.name)}</h3>` : "")
676        + (b[L] || b.pt || []).map(p => `<p>${esc(p)}</p>`).join("")).join("")
677    : `<p>${esc(S.bio)}</p>`;
678  $("#dr-pending").textContent = bios.length ? "" : S.bio_pending;
679  $("#dr-pending").hidden = bios.length > 0;
680  
681  $("#drclose").textContent = S.close;
682  $("#drawer").hidden = false; $("#scrim").classList.add("on");
683  requestAnimationFrame(() => $("#drawer").classList.add("open"));
684  $("#drclose").focus();
685}
686function closeDrawer() {
687  openArtist = null;
688  $("#drawer").classList.remove("open"); $("#scrim").classList.remove("on");
689  setTimeout(() => { $("#drawer").hidden = true; }, 200);
690  history.replaceState(null, "", location.pathname + location.search);
691  if (lastFocus) lastFocus.focus();
692}
693
694function renderArtistas() {
695  $("#arttitle").textContent = S.art_title;
696  
697  $("#gridnote").hidden = true;
698  renderFilters(); renderGrid();
699  
700  if (openArtist && !$("#drawer").hidden) openDrawer(openArtist.a, openArtist.i);
701}
702
703function initArtistas() {
704  readURL();
705  loadData().then(() => {
706    renderArtistas();
707    const h = location.hash.replace(/^#/, "");
708    if (h) { const a = DATA.artists.find(x => x.slug === h && !x.programme_only); if (a) openDrawer(a, DATA.artists.indexOf(a)); }
709  }).catch(() => { $("#agrid").innerHTML = `<p style="padding:20px" class="small">${S.load_fail}</p>`; });
710  $("#drclose").onclick = closeDrawer;
711  $("#scrim").onclick = closeDrawer;
712  addEventListener("keydown", e => { if (e.key === "Escape" && !$("#drawer").hidden) closeDrawer(); });
713  addEventListener("hashchange", () => {
714    const h = location.hash.replace(/^#/, "");
715    if (!h) { if (!$("#drawer").hidden) closeDrawer(); return; }
716    const a = DATA && DATA.artists.find(x => x.slug === h && !x.programme_only);
717    if (a) openDrawer(a, DATA.artists.indexOf(a));
718  });
719}
720
721let INFO = null;
722function loadInfos() {
723  if (INFO) return Promise.resolve(INFO);
724  return fetch("/data/informacoes.json")
725    .then(r => { if (!r.ok) throw new Error("informacoes.json " + r.status); return r.json(); })
726    .then(j => { INFO = j; return j; });
727}
728
729function infoIntroHTML(secs) {
730  const first = secs && secs[0] && secs[0].items && secs[0].items[0];
731  if (!first) return "";
732  const d = document.createElement("div");
733  d.innerHTML = first.a || "";
734  const t = (d.textContent || "").trim();
735  const m = t.match(/^[^.!?]{40,400}[.!?]/);
736  return m ? `<p>${esc(m[0].trim())}</p>` : "";
737}
738
739function renderInfos() {
740  $("#infotitle").textContent = S.info_title;
741  const secs = INFO && (INFO[L] || INFO.pt);
742  if (!secs) return;
743  $("#infointro").innerHTML = infoIntroHTML(secs);
744  $("#infolist").innerHTML = secs.map(sec =>
745    `<section class="infosec"><h2>${esc(sec.title)}</h2>` + sec.items.map(it =>
746      it.q && !it.open
747        ? `<details><summary>${esc(it.q)}</summary><div class="body">${it.a}</div></details>`
748        
749        : it.q
750          ? `<div class="lead"><p><strong>${esc(it.q)}</strong></p>${it.a}</div>`
751          : `<div class="lead">${it.a}</div>`).join("") + `</section>`).join("");
752}
753
754let FT = null;
755function loadFicha() {
756  if (FT) return Promise.resolve(FT);
757  return fetch("/data/ficha-tecnica.json")
758    .then(r => { if (!r.ok) throw new Error("ficha-tecnica.json " + r.status); return r.json(); })
759    .then(j => { FT = j; return j; });
760}
761function renderFicha() {
762  $("#fttitle").textContent = S.ft_title;
763  if (!FT) return;
764  const k = L === "en" ? "en" : "pt";
765  $("#ftlist").innerHTML = FT.groups.map(g =>
766    (g.title ? `<h2 class="ftgroup">${esc(g.title)}</h2>` : "") + g.sections.map(sec =>
767      `<section class="ftsec${sec.names.length > 8 ? " ftmany" : ""}"><h3>${esc(sec[k])}</h3><ul>` +
768      sec.names.map(n => `<li>${esc(n.name)}${n["role_" + k] ? ` <span class="ftrole">— ${esc(n["role_" + k])}</span>` : ""}</li>`).join("") +
769      `</ul></section>`).join("")).join("");
770}
771
772function renderPress() {
773  $("#presstitle").textContent = S.press_title;
774  const epk = $("#epk");
775  epk.textContent = S.epk;
776  epk.href = EPK_URL;
777  epk.target = "_blank";
778  epk.rel = "noopener";
779  $("#lb-nome").textContent = S.pf.nome;
780  $("#lb-cart").textContent = S.pf.cart;
781  $("#lb-txt").textContent = S.pf.txt;
782  $("#pf-send").textContent = S.pf.send;
783  $("#pressok").textContent = S.pf.ok;
784  
785  $("#newshd").hidden = true;
786  document.querySelector(".newslist").hidden = true;
787  
788  const pcRow = (label, val, href) =>
789    `<div class="row"><span class="k">${esc(label)}</span>` +
790    (val
791      ? (href
792        ? `<a class="v" href="${href}${esc(href === "tel:" ? val.replace(/[^+\d]/g, "") : val)}">${esc(val)}</a>`
793        : `<span class="v">${esc(val)}</span>`)
794      : `<span class="tbc">${esc(S.pc_tbc)}</span>`) +
795    `</div>`;
796  $("#pcontacts").innerHTML =
797    `<h3 class="pchd">${esc(S.press_contacts)}</h3><div class="pclist">` +
798    PRESS_CONTACTS.map(c =>
799      `<div class="pcbox">${pcRow(S.pc_name, c.name)}${pcRow(S.pc_email, c.email, "mailto:")}`
800      + (c.phone ? pcRow(S.pc_phone, c.phone, "tel:") : "") + `</div>`
801    ).join("") + `</div>`;
802}
803function initPress() {
804  $("#pressform").onsubmit = e => {
805    e.preventDefault();
806    const nome = $("#pf-nome").value.trim();
807    const cart = $("#pf-cart").value.trim();
808    const txt = $("#pf-txt").value.trim();
809    const body = `${S.pf.nome}: ${nome}\n${S.pf.cart}: ${cart}\n\n${txt}`;
810    location.href = `mailto:${PRESS_INBOX}?subject=`
811      + encodeURIComponent(`${S.pf.subject} — ${nome}`) + `&body=` + encodeURIComponent(body);
812    $("#pressok").style.display = "block";
813  };
814}
815
816const EDCOLS = ["e-yellow", "e-pink", "e-blue", "e-green", "e-cyan"];
817let ED = null;
818function loadEditions() {
819  if (ED) return Promise.resolve(ED);
820  return fetch("/data/past-editions.json")
821    .then(r => { if (!r.ok) throw new Error("past-editions.json " + r.status); return r.json(); }).then(j => { ED = j; return j; });
822}
823function edTitle(e) { return (L === "pt" && e.title_pt) ? e.title_pt : e.title; }
824function edTag(e) { return (L === "en" && e.tag_en) ? e.tag_en : e.tag; }
825function edScale(e) { return (L === "en" && e.scale_en) ? e.scale_en : e.scale; }
826function edNote(g) { return (L === "en" && g.note_en) ? g.note_en : g.note; }
827
828function edField(o, k) { return (L === "en" && o[k + "_en"]) ? o[k + "_en"] : o[k]; }
829
830function filmHTML(v, poster) {
831  if (!v || !(v.id || v.file)) return "";
832  
833  if (!poster && v.poster) poster = { src: v.poster };
834  const p = poster ? `<img class="poster" src="${poster.src}" alt="" loading="lazy" decoding="async">` : "";
835  const cred = poster && poster.credit ? `<span class="pcred">© ${esc(poster.credit)}</span>` : "";
836  
837  return `<div class="edfilm${poster ? " hasposter" : ""}${v.portrait ? " portrait" : ""}" data-yt="${v.id || ""}" data-file="${v.file || ""}" data-poster="${v.poster || ""}" data-start="${v.start || 0}">
838    <button type="button" class="playbtn">${p}
839      <span class="tri" aria-hidden="true"></span>
840      <span class="lbl">${S.ed_film}${v.title ? `<i>${esc(v.title)}</i>` : ""}</span>
841    </button>${cred}
842  </div>`;
843}
844
845function leadPhotoHTML(im) {
846  if (!im) return "";
847  return `<figure class="edlead"><img src="${im.src}" alt="" loading="lazy" decoding="async">${im.credit ? `<figcaption>© ${esc(im.credit)}</figcaption>` : ""}</figure>`;
848}
849function bindFilms(root) {
850  root.querySelectorAll(".edfilm .playbtn").forEach(btn => {
851    btn.onclick = () => {
852      const wrap = btn.closest(".edfilm");
853      const start = parseInt(wrap.dataset.start, 10) || 0;
854      wrap.innerHTML = wrap.dataset.file
855        ? `<video src="${wrap.dataset.file}" poster="${wrap.dataset.poster}" controls autoplay playsinline preload="metadata"></video>`
856        : `<iframe src="https://www.youtube.com/embed/${wrap.dataset.yt}?autoplay=1&rel=0${start ? "&start=" + start : ""}" title="Festival Iminente" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture" allowfullscreen loading="lazy"></iframe>`;
857    };
858  });
859}
860
861function gridClass(n) { return n >= 4 ? "g4" : n === 3 ? "g3" : n === 2 ? "g2" : "g1"; }
862function photoHTML(images, cls) {
863  if (!images || !images.length) return "";
864  return `<div class="edphotos${cls ? " " + cls : ""}">${images.map(im =>
865    `<figure><img src="${im.src}" alt="" loading="lazy" decoding="async">${im.credit ? `<figcaption>© ${esc(im.credit)}</figcaption>` : ""}</figure>`).join("")}</div>`;
866}
867
868function renderEdicoes() {
869  if (!ED) return;
870  $("#edtitle").textContent = S.ed_title;
871  $("#edlede").textContent = S.ed_lede;
872  
873  $("#edprov").hidden = true;
874  
875  $("#edstats").innerHTML = ED.stats.map(s => `<div class="stat"><span class="v">${esc(s.v)}</span><span class="k">${esc((L === "en" && s.k_en) ? s.k_en : s.k)}</span></div>`).join("");
876
877  $("#edlist").innerHTML = ED.editions.map((e, i) => `
878    <article class="ed ${EDCOLS[i % EDCOLS.length]}" id="e${e.year}-${slug(e.title)}">
879      <div class="edhead">
880        <span class="edyear">${e.year}</span>
881        <div class="edwho">
882          <h2>${esc(edTitle(e))}</h2>
883          <p class="edvenue">${esc(edField(e, "sub"))} · ${esc(edField(e, "dates"))}</p>
884          <p class="edtag">${esc(edTag(e))}</p>
885        </div>
886      </div>
887      <div class="edmedia">
888        ${e.video && (e.video.id || e.video.file) ? filmHTML(e.video, (e.images || [])[0]) : leadPhotoHTML((e.images || [])[0])}
889        ${photoHTML((e.images || []).slice(1), gridClass(((e.images || []).length - 1)))}
890      </div>
891      <p class="edscale">${esc(edScale(e))}</p>
892      ${lineupHTML(e)}
893    </article>`).join("");
894
895  function lineupHTML(e) {
896    if (!e.headliners || !e.headliners.length) return "";
897    return `<div class="edline"><h3>${S.ed_lineup} <span class="edn">${e.headliners.length}</span></h3><p>${e.headliners.map(esc).join(" · ")}</p></div>`;
898  }
899  $("#edglobaltitle").textContent = S.ed_global;
900  $("#edgloballede").textContent = S.ed_global_lede;
901  $("#edglobal").innerHTML = ED.global.map((g, i) => `
902    <article class="gcard ${EDCOLS[(i + 2) % EDCOLS.length]}">
903      <span class="gyear">${g.year}</span>
904      <h3>${esc((L === "pt" && g.title_pt) ? g.title_pt : g.title)}</h3>
905      <p class="gvenue">${esc(edField(g, "venue"))} · ${esc(edField(g, "dates"))}</p>
906      ${g.video && (g.video.id || g.video.file) ? filmHTML(g.video, (g.images || [])[0]) : leadPhotoHTML((g.images || [])[0])}
907      <p class="gnote">${esc(edNote(g))}</p>
908      ${photoHTML((g.images || []).slice(1))}
909      ${lineupHTML(g)}
910    </article>`).join("");
911
912  $("#edphototitle").textContent = S.ed_photo;
913  $("#edphotogs").innerHTML = ED.photographers.map(p =>
914    `<li><b>${esc(p.name)}</b><span>${esc(edField(p, "role"))}${p.editions ? " · " + esc(edField(p, "editions")) : ""}</span></li>`).join("");
915
916  bindFilms(document);
917  const h = location.hash.replace(/^#/, "");
918  if (h) { const t = document.getElementById(h); if (t) t.scrollIntoView(); }
919}
920
921const TEASER_YEARS = [2016, 2018, 2019, 2022, 2024];
922function renderTeaser() {
923  const m = $("#edteaser"); if (!m || !ED) return;
924  const pick = TEASER_YEARS.map(y => ED.editions.find(e => e.year === y && e.country === "Portugal")).filter(Boolean);
925  m.innerHTML = `
926<section class="teaser">
927  <div class="teaserhead">
928    <h2>${S.ed_teaser}</h2>
929    <a class="tcta" href="${BASE}/edicoes${L === "en" ? "?lang=en" : ""}">${S.ed_teaser_cta}</a>
930  </div>
931  <div class="tstrip">
932    ${pick.map((e, i) => `
933      <a class="tile ${EDCOLS[i % EDCOLS.length]}" href="${BASE}/edicoes${L === "en" ? "?lang=en" : ""}#e${e.year}-${slug(e.title)}">
934        ${e.images && e.images[0] ? `<img src="${e.images[0].src}" alt="" loading="lazy" decoding="async">` : ""}
935        <span class="ty">${e.year}</span>
936        <span class="tt">${esc(edTitle(e))}</span>
937      </a>`).join("")}
938  </div>
939</section>`;
940}
941
942function renderLegal() {
943  const doc = LEGAL[document.body.dataset.doc];
944  const d = doc[L] || doc.pt;
945  $("#legaltitle").textContent = d.title;
946  const mail = `<a class="mail" href="mailto:${esc(LEGAL_MAIL)}">${esc(LEGAL_MAIL)}</a>`;
947  $("#legalbody").innerHTML = d.blocks.map(([kind, v]) => {
948    if (kind === "h") return `<h2>${esc(v)}</h2>`;
949    if (kind === "h2") return `<h3>${esc(v)}</h3>`;
950    if (kind === "p") return `<p>${esc(v)}</p>`;
951    if (kind === "ul") return `<ul>${v.map(li => `<li>${esc(li)}</li>`).join("")}</ul>`;
952    if (kind === "addr") return `<p class="addr">${v.map(esc).join("<br>")}</p>`;
953    if (kind === "mail") return `<p><a class="mail" href="mailto:${esc(v)}">${esc(v)}</a></p>`;
954    
955    if (kind === "pmail") return `<p>${esc(v[0])}${mail}${esc(v[1])}</p>`;
956    if (kind === "links") return `<ul>${v.map(([t, href]) =>
957      `<li><a href="${href}" target="_blank" rel="noopener">${esc(t)}</a></li>`).join("")}</ul>`;
958    return "";
959  }).join("");
960}
961
962function bindNewsletter() {
963  const f = $("#nlform"); if (!f) return;
964  f.onsubmit = e => {
965    e.preventDefault();
966    const msg = $("#nlmsg");
967    const btn = f.querySelector("button[type=submit]");
968    const v = $("#nlemail").value.trim();
969    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { msg.textContent = S.nl_bad; return; }
970    btn.disabled = true;
971    
972    fetch(NEWSLETTER_ENDPOINT, {
973      method: "POST",
974      headers: { "Content-Type": "application/json" },
975      body: JSON.stringify({ email: v })
976    }).then(r => {
977      if (!r.ok) throw new Error("signup " + r.status);
978      msg.textContent = S.nl_ok;
979      f.reset();
980    }).catch(() => {
981      msg.textContent = S.nl_err;
982    }).finally(() => { btn.disabled = false; });
983  };
984}
985
986function render() {
987  document.documentElement.lang = L;
988  
989  if (!$(".noise")) {
990    const n = document.createElement("div");
991    n.className = "noise";
992    n.setAttribute("aria-hidden", "true");
993    document.body.appendChild(n);
994  }
995  $("#hdr").innerHTML = headerHTML();
996  $("#ftr").innerHTML = footerHTML();
997  const nota = $("#notanenny p"); if (nota) nota.textContent = S.nota_nenny;
998  
999  const pf = $("#posfestival");
1000  if (pf) { pf.innerHTML = pfBlockHTML(); bindCarousel(); }
1001  
1002  const mapa = $("#mapa"); if (mapa) mapa.innerHTML = mapBlockHTML();
1003  const infomapa = $("#infomap"); if (infomapa) infomapa.innerHTML = mapBlockHTML("onblue");
1004  const band = $("#tenband"); if (band) band.innerHTML = tenBandHTML();
1005  document.querySelectorAll(".langsw button").forEach(b => b.onclick = () => setLang(b.dataset.lang));
1006  const fsb = $("#fsbtn");
1007  if (fsb) fsb.textContent = S.fsbtn;
1008  bindNewsletter();
1009  syncHeaderHeight();
1010  bindShrink();
1011  if (PAGE === "home" && DATA) renderHome();
1012  if (PAGE === "home" && ED) renderTeaser();
1013  if (PAGE === "artistas" && DATA) renderArtistas();
1014  if (PAGE === "informacoes") renderInfos();
1015  if (PAGE === "ficha") renderFicha();
1016  if (PAGE === "press") renderPress();
1017  if (PAGE === "edicoes" && ED) renderEdicoes();
1018  if (PAGE === "legal") renderLegal();
1019}
1020
1021render();
1022if (PAGE === "home") { initHome(); loadEditions().then(renderTeaser).catch(() => { }); }
1023else if (PAGE === "artistas") initArtistas();
1024else if (PAGE === "press") initPress();
1025else if (PAGE === "edicoes") loadEditions().then(renderEdicoes).catch(() => {
1026  $("#edlist").innerHTML = `<p style="padding:20px" class="small">${S.load_fail}</p>`;
1027});
1028else if (PAGE === "ficha") loadFicha().then(renderFicha).catch(() => {
1029  $("#ftlist").innerHTML = `<p style="padding:20px" class="small">${S.load_fail}</p>`;
1030});
1031else if (PAGE === "informacoes") loadInfos().then(renderInfos).catch(() => {
1032  $("#infolist").innerHTML = `<p style="padding:20px" class="small">${S.load_fail}</p>`;
1033});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.