feat(S25): player próprio da Rádio 1313 na home — exclusividade, share e fail-soft (Closes #1239)

This commit is contained in:
Francisco Solla 2026-09-21 13:03:15 -03:00
parent 3e632df4d0
commit bb550cebff
17 changed files with 1065 additions and 133 deletions

View file

@ -0,0 +1 @@
**S25 — Player próprio da Rádio 1313 na home (2026-09-21):** o iframe do zeno.fm que o S24 montou na home sai de cena e a Rádio Jorge Solla 1313 passa a tocar num **player próprio** — `<audio preload="none">` apontando direto para o stream (`stream.zeno.fm/hys86kx6k16tv`), com a cara da campanha e **zero request a terceiros antes do gesto de play** (o ganho colateral do S24 revertido para melhor: o pageview da home não fala mais com GA/rede de anúncio do widget). Sucessora explícita do S24 (#1235), que reverse de propósito os anti-goals dele ("não vira player custom") com nova evidência de produto registrada em `docs/plans/radio-player-proprio.md` (plano do item `done` não é reaberto). O contrato da rádio vive em `src/lib/radio.ts` (stream, página pública, título fixo "Rádio Jorge Solla 1313" = `icy-name`, mensagem literal `Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/`, timeout de conexão 12s e os eventos de exclusividade); o `RadioPlayer` (novo, client) porta classe-a-classe o gate `radio-player-proprio-ui-design.html` (Cenas 01–05: grid `112px/1fr/auto` com arte da estrela oficial sobre disco no desktop, linha compacta 76px no mobile, badge "Ao vivo", play amarelo 56/48px, share em linha inferior no compacto/mobile) com os estados `idle → connecting → playing | error` e fail-soft honesto: `play()` recusado, evento de erro ou conexão que aceita e não entrega áudio viram erro com "Tentar novamente" + "Ouvir no Zeno" + compartilhar — nunca spinner infinito (token de tentativa invalida promises superseded; retry re-dispara `load()`+`play()` porque o stream responde 302 com token por request). `RadioEmbed` é deletado (dono único, sem twin) e `JingleHomeSection` troca o filho; a **exclusividade rádio↔jingle** é broadcast `window` CustomEvent (`radio:play`/`jingle:play`, precedente `signature:created`): a rádio pausa o jingle no gesto do play e o `JingleCards` pausa só o próprio áudio ativo no `radio:play` — inerte em `/jingles`, que fica comportamentalmente intocado, assim como a grade, "Ver todos", o kill switch e a variante compact. Compartilhar reusa `Popover` + `useCopyFeedback` + `buildWhatsAppTextShareUrl` (mensagem literal no texto do WhatsApp e ao copiar; caixa exibe o literal). Home segue estática (leitura única `getPublishedJingleItems()`, tag `jingles`, MetaPixel intocado), sem PII/Consent/migration. Correção de design no fechamento: o `PopoverContent` portaled recebe `data-theme="campaign-site"` (mesmo padrão do `CardsStudio`) — sem o atributo os tokens da campanha somem no `document.body` e o CTA primário do WhatsApp renderizava invisível; crítica final do `designer` **CERTIFICADO** (tier `openai/gpt-5.6-sol`) contra o app renderizado em 390/1280 (cheia e compacta, estados idle/connecting/playing/erro/popover). Testes: unit novo/reescrito (`radio` 5, `radioPlayer` 11 — estados, timeout com fake timers, retry, `jingle:play`, superseded, `onError`, popover com tema, compact; `jinglePlayer` +3 casos de broadcast; `jingleHomeSection` sem iframe) e e2e adaptado (`frontend` + `frontendJingles`: iframe 0, `audio preload="none"`, 0 requests no load, stream só após o play, exclusividade nos dois sentidos, 48/56px, overflow 390) com o stub do fixture trocado do player zeno para `stream.zeno.fm` (MP3 local; request residual de `zeno.fm/player` agora falha como regressão). `pnpm gate:fast` verde (4226 unit), e2e local verde (36 passed), knip/cycles/format limpos; simplify com 2 revisores (9 achados: 7 resolvidos no lugar, 1 evento/tipo morto removido, 1 defer com gatilho no `*-impl.md` — unificar SVGs de play/pause na 3ª superfície), 0 Issues novas.

View file

@ -0,0 +1,152 @@
# Impl: Player próprio da Rádio 1313 na home
Status: aprovado (modo `--auto`)
Atualizado em: 2026-09-21
Issue: #1239
Intenção: docs/plans/radio-player-proprio.md
Appetite restante: herdado (~1 dia eng) — sem corte; a entrega é um componente novo + reescrita de contratos de teste, cabe em uma sessão.
## Leitura da intenção
- **Outcome:** o iframe do zeno.fm sai da home e a seção de som passa a mostrar um player próprio `<audio preload="none">` sobre `https://stream.zeno.fm/hys86kx6k16tv`, discreto, com a cara da campanha: título fixo "Rádio Jorge Solla 1313", indicador "Ao vivo", CTA de compartilhar (WhatsApp com a mensagem literal + copiar com feedback), estados `idle/connecting/playing/error` fail-soft (erro → "Tentar novamente" + "Ouvir no Zeno" + compartilhar; nunca spinner infinito) e exclusividade rádio↔jingle na seção. `preload="none"` garante zero request ao stream ou a terceiros antes do gesto de play; kill switch preservado (0 jingles → rádio sozinha compacta; com jingles → grade + "Ver todos" abaixo); `/jingles` e o player dos jingles comportamentalmente intocados além do listener/dispatch de exclusividade; home estática (cache tag `jingles`) intocada; sem migration/schema/Consent/PII; MetaPixel da home intocado.
- **O que NÃO negociar:** (1) player próprio é a **única** superfície de rádio da seção — nenhum iframe, nenhuma marca zeno visível; (2) `preload="none"` e play só por gesto (nada de autoplay com som); (3) um áudio por vez na seção (rádio pausa jingle e vice-versa); (4) mensagem de compartilhamento literal `Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/`; (5) fail-soft com timeout — spinner nunca infinito; (6) kill switch e variante compact preservados; (7) `/jingles` fica intocado além da exclusividade; (8) sem Consent/CSP/analytics/migration; (9) port classe-a-classe do design gate (o designer é dono da estrutura visual), com a estrela oficial do kit como arte assumida (NEEDS ASSET).
- **O que reavaliar (hipóteses do explorador):** (a) o embed server do S24 morre inteiro — o player é client (estado de mídia, evento, popover); (b) `RadioEmbed.tsx` não é editado, é **deletado** (o iframe era 100% do arquivo) e o spec do iframe morre com ele; (c) a mensagem literal não encaixa em `ContentShareKind` (`article|video|instagram`) — não criar `kind` novo, a mensagem é fixa em `src/lib/radio.ts`; (d) `JingleCards` é compartilhado com `/jingles`, então o listener de `radio:play` tem de ser inócuo fora da home (pausa só o próprio áudio ativo, sem estado global); (e) o repo não tem `onError` de mídia em lugar nenhum — a detecção de erro precisa de promise + evento + timeout manual; (f) o stub `zeno.fm/player/**` do fixture fica morto e o stub novo é do **stream** (`stream.zeno.fm/**`), só requisitado após o gesto.
## Abordagem recomendada
```mermaid
flowchart LR
HOME["(home)/page.tsx (RSC, intocado)<br/>getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server)<br/>header/copy/kill switch intactos; só troca o filho"]
SEC --> RP["RadioPlayer (client · novo)<br/>article[data-radio][data-state] · audio preload=none<br/>idle → connecting → playing | error"]
RP --> LIB["src/lib/radio.ts (puro, client-safe)<br/>stream · página · título · mensagem · eventos · timeout"]
RP --> SHARE["RadioShareControl (privado do arquivo)<br/>Popover + useCopyFeedback + wa.me"]
RP -. "radio:play / radio:pause" .-> JC["JingleCards (client, também em /jingles)<br/>pausa só o próprio áudio ativo"]
JC -. "jingle:play {id}" .-> RP
RP --> STREAM["stream.zeno.fm (302 CDN, token por request)<br/>requisitado só depois do gesto"]
FIX["fixtures/e2eTest.ts<br/>stream.zeno.fm → MP3 stub"] -.-> STREAM
OLD["RadioEmbed.tsx (iframe zeno) — deletado"] -.-> RP
```
**Opções consideradas:** A) `RadioPlayer.tsx` novo ao lado da seção + `src/lib/radio.ts` puro, deletando `RadioEmbed.tsx`; B) estender `RadioEmbed.tsx` in-place; C) mover para um diretório novo `src/components/radio/`.
**Recomendação:** A — o concern "rádio" ganha um dono só (o arquivo novo), o iframe sai sem deixar código morto para o knip, o contrato puro (URLs/literais/eventos/timeout) fica testável fora do React, e a seção muda uma linha (`<RadioEmbed compact/>` → `<RadioPlayer compact/>`). D2 detalha.
**Rejeitadas:** B porque o arquivo do iframe não tem uma linha aproveitável (o nome "Embed" mentiria e o contrato antigo ficaria ao lado do novo); C porque cria topologia nova para um componente que pertence à seção de jingles já existente (o manifest cobre `src/components/jingles`), sem ganho.
### Componentes / mudanças
- **`src/lib/radio.ts` (novo; puro, client-safe):** dono do contrato da rádio — `RADIO_STREAM_URL = 'https://stream.zeno.fm/hys86kx6k16tv'`, `RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/'`, `RADIO_TITLE = 'Rádio Jorge Solla 1313'`, `RADIO_SHARE_MESSAGE = 'Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/'`, `RADIO_CONNECT_TIMEOUT_MS = 12_000`, `buildRadioShareWhatsAppUrl()` (reusa `buildWhatsAppTextShareUrl` de `src/lib/phone.ts`) e as constantes de broadcast `RADIO_PLAY_EVENT='radio:play'`, `RADIO_PAUSE_EVENT='radio:pause'`, `JINGLE_PLAY_EVENT='jingle:play'` + `type AudioExclusivityDetail = { source: 'radio' } | { source: 'jingle'; id: number }`. Nenhum import de React/DOM (testável em unit puro).
- **`src/components/jingles/RadioPlayer.tsx` (novo; `'use client'`):** props `{ compact?: boolean }` (mesmo contrato do `RadioEmbed`, a seção não reaprende nada). Dono do estado (`idle|connecting|playing|error`), do `<audio preload="none" src={RADIO_STREAM_URL}>`, do timeout de conexão, do listener de `jingle:play` e do dispatch de `radio:play`/`radio:pause`; renderiza o port do design (D6) com `data-radio`, `data-state={status}` e `aria-label="Player da Rádio Jorge Solla 1313"`. `RadioShareControl` é um subcomponente **privado do mesmo arquivo** (3 usos internos: ações desktop, linha inferior mobile, linha compact) — nada exportado.
- **`src/components/jingles/JingleCards.tsx`:** adiciona (1) o dispatch de `JINGLE_PLAY_EVENT` com `{ source: 'jingle', id }` no `toggle` ao iniciar um card e (2) um efeito com listener de `RADIO_PLAY_EVENT` que pausa **somente** o próprio áudio ativo (ref do `activeId`, sem estado global) e limpa o `activeId`. Fora da home (em `/jingles`) o listener é inócuo: nenhum `radio:play` acontece na página e o `jingle:play` emitido não tem consumidor — comportamento idêntico ao de hoje. Reusa os refs/mocks já existentes.
- **`src/components/jingles/JingleHomeSection.tsx`:** troca import/uso (`<RadioEmbed compact={!hasJingles}/>` → `<RadioPlayer compact={!hasJingles}/>`) e atualiza o comentário S22→S25. Header/copy condicional por `hasJingles`, `data-home-section="sound"`, `aria-labelledby`, grid, "Ver todos" e `compact` intactos.
- **`src/components/jingles/RadioEmbed.tsx`:** deletado (iframe inteiro sai; sem código morto para o knip).
- **`src/app/(frontend)/styles.css`:** só o comentário de `:1158-1161` (hoje cita RadioEmbed/zeno) passa a descrever o `RadioPlayer`; `.campaign-sound-title`, tokens `--campaign-line/--campaign-band/--pt-red/--pt-yellow/--campaign-muted` e utilitários existentes bastam — nenhuma regra nova.
- **`src/app/(frontend)/(home)/page.tsx`:** intocado (leitura cacheada, `slice(0,3)`, cache tag `jingles`, MetaPixel).
- **Testes:** `tests/unit/radio.unit.spec.ts` + `tests/unit/radioPlayer.unit.spec.tsx` (novos), `tests/unit/radioEmbed.unit.spec.tsx` (deletado), ajustes em `jinglePlayer.unit.spec.tsx`/`jingleHomeSection.unit.spec.tsx`, reescrita dos dois e2e, fixture `tests/e2e/fixtures/e2eTest.ts` e `scripts/lib/e2e-affected-manifest.mjs` (D5).
- **Migration:** sem migration — nada em `src/collections`/`src/globals`; `push: false`; sem `generate:types`/`generate:importmap`.
- **Access / Consent:** n/a — superfície pública read-only, sem PII nova, sem formulário, sem write path, sem chave de `Consent`.
- **UI:** Impeccable C — port classe-a-classe do design gate (`docs/plans/radio-player-proprio-ui-design.html`, Cenas 01–05), traduzido para utilitários/tokens do app (as classes do protótipo `.radio-player/.radio-art/.play-button/.share-button/.live-dot/.spinner` não existem no app); `JINGLE_FOCUS_RING` como anel de foco; nenhuma estrutura visual inventada (D6 registra as combinações não desenhadas e a regra de reuso).
### Dados → forma (se aplicável) — N/A
A intenção declara "Vou apresentar dados? Não": sem agregação, sem PII, sem contador/analytics/"tocando agora". A superfície é um player de áudio e um atalho de compartilhamento.
## Decisões de engenharia
**D1 — Contrato de exclusividade rádio↔jingle (caro: acoplamento entre componentes clientes irmãos + fronteira com `/jingles`)**
**Opções:** A) broadcast em `window` via `CustomEvent` com constantes exportadas (padrão do repo: `SIGNATURE_CREATED_EVENT` em `SignatureCounter.tsx:10,98`; `SPEECH_EXCERPT_REQUEST_EVENT` em `speechExcerptSelection.ts:15`); B) contexto React; C) elevar o estado da rádio/jingles para um client wrapper da seção; D) ref global de módulo.
**Recomendação:** A. `src/lib/radio.ts` exporta `RADIO_PLAY_EVENT`, `RADIO_PAUSE_EVENT`, `JINGLE_PLAY_EVENT` e `AudioExclusivityDetail`. A rádio dispara `radio:play` no clique do play (antes de conectar — o jingle para no gesto, garantindo "um áudio por vez" mesmo se o stream falhar) e `radio:pause` em toda saída de `playing|connecting` (toggle, jingle, erro); escuta `jingle:play` e, se estiver `playing|connecting`, pausa/cancela o timeout e volta a `idle`. `JingleCards` dispara `jingle:play` com `{ source: 'jingle', id }` ao iniciar um card e escuta `radio:play` num efeito com `activeIdRef` (pausa apenas o áudio ativo do próprio componente e limpa o `activeId`); sem estado global, sem storage, sem subscription cruzada. Em `/jingles` o listener nunca recebe `radio:play` (não há rádio na página) e o `jingle:play` emitido não tem consumidor: zero mudança de comportamento. O detail é ignorado pelos listeners (mínimo), mas o `id` fica assertável nos testes.
**Rejeitadas:** B porque `JingleHomeSection` é server e os dois players são clientes irmãos — um provider exigiria um wrapper client novo por cima da seção (toca o kill switch) e moveria a barreira de hidratação; C pela mesma razão mais o custo de trocar o dono do estado de `JingleCards` (afeta `/jingles`); D por colidir entre bundles/SSR (estado compartilhado por request no servidor) e não ter precedente no repo.
**D2 — Onde vive o player (caro: fronteira de módulo + delete)**
**Opções:** A) `src/components/jingles/RadioPlayer.tsx` novo + `src/lib/radio.ts` puro, deletando `RadioEmbed.tsx`; B) editar `RadioEmbed.tsx` in-place; C) mover para `src/components/radio/`.
**Recomendação:** A — dono único do concern "rádio" (como `JinglePlayer` é o da página de jingles, sem colisão de nome), contrato puro testável fora do React, `JingleHomeSection` troca uma linha. `RadioEmbed.tsx` é deletado (não é rename: o iframe era o arquivo inteiro) e `tests/unit/radioEmbed.unit.spec.tsx` é substituído por `radioPlayer.unit.spec.tsx` — sem referência órfã para o knip. `focusRing.ts` continua o dono do anel.
**Rejeitadas:** B porque perpetua um nome que descreve o que saiu e manteria dois contratos no mesmo arquivo (iframe vs. player), sem uma linha aproveitável; C porque duplica a topologia e o manifest (`src/components/jingles` já mapeia `frontendJingles`) sem ganho.
**D3 — Estados e detecção de erro (caro-ish: aceite "nunca spinner infinito")**
**Opções:** A) `idle|connecting|playing|error` com `play()` promise + evento `error` + **timeout de conexão** nomeado; B) só promise/`error`, sem timeout; C) `waiting/stalled` como sinal de erro.
**Recomendação:** A. `play()` resolve → `playing` (junto do evento `playing`, que é o sinal audível); rejeição do `play()` ou evento `error` → `error`; `RADIO_CONNECT_TIMEOUT_MS = 12_000` armado por efeito enquanto `status === 'connecting'` e limpo em qualquer saída (pausa do usuário, `playing`, erro, unmount) — se estourar, `pause()` + `error`. Um token de tentativa (`attemptRef`) invalida promises superseded (pausar durante `connecting` ou pedir retry não pode deixar o estado antigo vencer) — mesma preocupação que `JingleCards.tsx:136-139` resolve com o check de `id`. Retry a partir de `error` chama `audio.load()` e `play()` de novo: como o stream responde 302 com token por request, o retry precisa de uma requisição nova (não dá para reaproveitar a URL/tentativa falha).
**Rejeitadas:** B porque o modo de falha real é a conexão que "aceita" e nunca entrega áudio — sem timeout o spinner fica infinito, violando o aceite; C porque `waiting`/`stalled` disparam em re-buffer normal de stream ao vivo e acenderiam o erro em reprodução saudável. `ended` também é ignorado neste player (rádio é ao vivo; diferente do jingle finito, que usa `onEnded`).
**D4 — Compartilhar (barato: reuso)**
**Opções:** A) popover próprio dentro do `RadioPlayer` (privado `RadioShareControl`) reusando `Popover`, `useCopyFeedback` e `buildWhatsAppTextShareUrl`, com o literal de `radio.ts`; B) reusar `ContentShareButton` inteiro; C) reusar `SpeechExcerptShare` como está.
**Recomendação:** A. `buildRadioShareWhatsAppUrl()` = `buildWhatsAppTextShareUrl(RADIO_SHARE_MESSAGE)` (abre no WhatsApp do próprio remetente, sem destinatário, `target="_blank" rel="noopener noreferrer"`). Conteúdo do popover = Cena 05: título "Compartilhar a rádio", caixa com a mensagem literal, botão WhatsApp (primário) e "Copiar link" com o feedback de `useCopyFeedback` (+ live region `sr-only`), alinhado `align="end"`. **Copiar copia a mensagem literal** (com o link dentro), mantendo o rótulo do gate via `copyFeedbackLabels` — mesmo precedente do `SpeechExcerptShare` (copia a mensagem, rotula "Copiar link"); trigger com `JINGLE_FOCUS_RING` em duas variantes do design: `outline` (Cena 01, ações inline) e `link` (Cena 02, linha inferior). Sem `Sheet` de ponteiro coarse: o gate desenha popover e o precedente `ContentShareButton` usa popover no mobile (Radix portaliza, não é clipado).
**Rejeitadas:** B porque o trigger circular absoluto/labels fixos/posição de canto não servem ao layout do player e o `kind` de `contentShare.ts` (prefixos pinados em `tests/unit/contentShare.unit.spec.ts:27-32`) não modela mensagem literal — criar um `kind` novo mudaria um contrato pinado por nada; C porque as props/shape de fala (`videoId/offsetSeconds/startSeconds/...`) e a mensagem derivada de `speechShare.ts` não têm tradução para a rádio — adaptar seria reescrever o componente, e o gate pede outro conteúdo (título, caixa do literal, dois botões).
**Alternativa avaliada (copiar só a URL):** rejeitada — o literal é o que circula e é o texto exibido na caixa do gate; a paridade com o WhatsApp é o ponto.
**D5 — Testes (caro: contrato pinado em unit + e2e + fixture + manifest)**
**Opções:** A) reescrever os contratos (deletar o spec do iframe, novos specs da lib/player, adaptar os compartilhados, reescrever os dois e2e, trocar o stub do fixture e mapear `src/lib/radio`); B) manter o spec do iframe como histórico e só adicionar novos; C) não tocar a fixture (deixar o stub do player, não stubbear o stream).
**Recomendação:** A, com o contrato novo exatamente assim:
- `tests/unit/radioEmbed.unit.spec.tsx` → **deletado**. `tests/unit/radio.unit.spec.ts` (novo): literais exatos (`RADIO_STREAM_URL`, `RADIO_PAGE_URL`, `RADIO_TITLE`, `RADIO_SHARE_MESSAGE`, `RADIO_CONNECT_TIMEOUT_MS`), `buildRadioShareWhatsAppUrl()` conferido contra `buildWhatsAppTextShareUrl` (oráculo) e nomes das três constantes de evento.
- `tests/unit/radioPlayer.unit.spec.tsx` (novo; mock de `HTMLMediaElement.prototype.play/pause/load` como `jinglePlayer.unit.spec.tsx:38-53` + `vi.mock('next/image')` como `jingleHomeSection.unit.spec.tsx:12-17`): idle (`audio[preload="none"]`, `src` do stream, título, "Ao vivo", rótulos acessíveis de play/share); connecting→playing (evento `playing` e resolução da promise); toggle pausa + `radio:pause`; erro por rejeição do `play()`; erro por timeout com fake timers (avançar 12s sem resolver → "A rádio não conectou" + "Tentar novamente" + link "Ouvir no Zeno" com `RADIO_PAGE_URL` + compartilhar); retry chama `load()`+`play()` e volta a connecting; `jingle:play` pausa a rádio; `aria-pressed`/`aria-busy`/`aria-label` por estado; `data-state`.
- `tests/unit/jinglePlayer.unit.spec.tsx` (adaptar): novos casos "emite `jingle:play` com o id ao tocar" e "`radio:play` pausa o card ativo"; e "sem card ativo, `radio:play` não chama `pause`".
- `tests/unit/jingleHomeSection.unit.spec.tsx` (adaptar): onde hoje lê `[data-radio] iframe` passa a exigir `[data-radio]` com `audio[preload="none"]` e **nenhum** `iframe`; `max-w-[880px]` só no caso 0 jingles; asserções fail-closed ("Jingles oficiais"/"Ver todos"/cards) inalteradas.
- `tests/e2e/fixtures/e2eTest.ts`: **remover** `https://zeno.fm/player/**` (o iframe morreu — request sobrante passa a falhar o guard, virando rede de regressão) e **adicionar** `https://stream.zeno.fm/**` com `route.fulfill({ path: tests/fixtures/jingle-tone.mp3, contentType: 'audio/mpeg' })` (o stream só é requisitado depois do gesto; o stub mantém a suíte hermética e o evento `request` continua disparando para a asserção "só depois do play"; o MP3 curto já existe e é usado pelo upload de jingles); ignorar `ended` no player (D3) mantém o estado "Em reprodução" estável no stub, como num stream ao vivo.
- `tests/e2e/frontend.e2e.spec.ts:560-616` (reescrever; manter ordem `story < sound < cards` e overflow 390): `[data-radio]` visível; `iframe` count **0**; `audio` count 1 com `preload="none"` e `src === RADIO_STREAM_URL`; "Rádio Jorge Solla 1313" e "Ao vivo" visíveis; `zenoRequests` (matcher `zeno.fm`) **0** no load; play pelo nome acessível (`Ouvir Rádio Jorge Solla 1313`) → poll `zenoRequests.length > 0`, `aria-pressed="true"` e "Em reprodução"; a 390 sem overflow; o pin de altura 168/150px **sai** (não há frame reservado) e entra o tamanho do play (56px desktop / 48px no 390).
- `tests/e2e/frontendJingles.e2e.spec.ts:202-207` (kill switch): `[data-radio]` visível, `iframe` 0, `audio` 1 (`preload="none"`), 0 cards, sem "Ver todos" e heading "Sintonize com a Rádio 1313".
- `tests/e2e/frontendJingles.e2e.spec.ts:248-291` (com jingles): 3 cards, `iframe` 0, `zenoRequests === 0` e `audioRequests === 0` antes de gesto; play na rádio → stream request > 0 + "Em reprodução"; play no 1º card → rádio `aria-pressed="false"`/"Pronta para tocar" e card `data-state="playing"`; play na rádio de novo → card `data-state="stopped"` (exclusividade nos dois sentidos); overflow 390.
- `scripts/lib/e2e-affected-manifest.mjs:99-108`: adicionar `src/lib/radio` ao entry do S21 (junto de `src/lib/jingle`), para o diff da lib acordar `frontendJingles`; o CSS comentado já acorda `frontend` via `src/app/(frontend)`.
- Greps de fechamento: `RadioEmbed`, `zeno.fm/player`, `iframe` em `src/components/jingles` e `src/lib` = 0; menções a `data-radio-state` = 0.
**Rejeitadas:** B porque spec morto de iframe vira ruído e o gate rodaria um contrato inexistente; C porque o stub do player sem consumidor é peso morto e, sem stub do stream, o clique de play no e2e bateria na rede real/CI (guard de console externo quebra) ou ficaria flaky.
**D6 — Composição responsiva do port (barato-ish: fidelidade ao gate sem inventar)** _(decisão extra porque o gate não desenha todas as combinações)_
**Opções:** A) `compact` = Cena 02 em qualquer largura (cartão centralizado em `max-w-[880px]`); `full` = Cena 01 no `md+` e o arranjo de linha da Cena 02 no mobile; B) `compact` = só geometria do S24 e layout por viewport; C) desenhar uma terceira composição para desktop compacto.
**Recomendação:** A — é a leitura que usa só as duas composições aprovadas: a Cena 02 é rotulada "0 jingles" (o estado compacto) e o mobile do `full` herda o arranjo mobile da Cena 02. Mapeamento bloco a bloco:
- **Cena 01 (full, md+):** `article` `overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_10px_28px_rgb(71_19_14/7%)] mt-9 p-3 md:p-4`; grid `md:grid md:grid-cols-[112px_1fr_auto] md:items-center md:gap-5`; `radio-art` `size-[76px] md:size-28` com disco = `<span aria-hidden>` `absolute inset-[13%] rounded-full border border-black/8 bg-white shadow-[0_7px_18px_rgb(71_19_14/10%)]` + `<Image src="/campaign-kit/estrela.png" ... className="relative z-[1] h-[64%] w-[64%] object-contain" />`; badge "Ao vivo" `text-[10px] md:rounded-full md:bg-(--pt-red)/8 md:px-3 md:py-1 md:text-[11px]` com live-dot `size-2 ... shadow-[0_0_0_4px_rgb(162_28_28/11%)]`; "Rádio online" `hidden text-xs text-(--campaign-muted) md:inline`; título `font-[family-name:var(--font-exo2)] text-lg leading-tight font-black tracking-[-0.025em] md:text-[28px] md:leading-none`; status `text-sm` (muted idle / `font-bold text-(--pt-red)` com dot em reprodução; spinner em connecting); ações `flex items-center gap-4 pr-2` com share `outline` + play `size-12 md:size-14` (`bg-(--pt-yellow)`, `text-(--pt-yellow-ink)`, `shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)]`, ícone 21/25px, `motion-reduce` como `JingleCards`).
- **Cena 02 (compact):** `mx-auto mt-8 max-w-[880px] p-3`; linha `flex items-center gap-3` (art 76, título `text-lg`, play `size-12` com pause e `aria-pressed`); status `text-xs`; share `link` na linha inferior `mt-3 flex justify-end border-t border-(--campaign-line) pt-2`. No `full` mobile valem os mesmos valores mobile (o share outline inline só aparece em `md+`).
- **Cena 03 (connecting):** mesmo footprint; spinner `size-5 rounded-full border-2 border-(--pt-red)/18 border-t-(--pt-red) animate-spin [animation-duration:0.8s] motion-reduce:animate-none` + "Conectando à rádio…" (`role="status"`); play `opacity-60` `aria-busy="true"` `aria-label="Conectando"`.
- **Cena 04 (error):** branch próprio sem arte/badge — ícone `grid size-10 place-items-center rounded-full bg-(--pt-red)/10 text-(--pt-red)` (lucide `InfoIcon`), título "A rádio não conectou", copy "Tente de novo ou ouça na página da rádio.", ações `flex flex-wrap items-center gap-2`: "Tentar novamente" (`bg-(--pt-red) text-white min-h-11 rounded-lg`), "Ouvir no Zeno" (link sublinhado `RADIO_PAGE_URL`) e share `outline ml-auto`.
- **Cena 05 (popover):** descrito em D4.
**Rejeitadas:** B porque a Cena 02 difere estruturalmente (flex, tamanhos, share na linha inferior) — tratá-la como pura geometria não portaria o gate; C porque inventa composição que o designer não aprovou.
## Fases verificáveis
1. **Tracer — lib + player + troca (≈3–4 h):** criar `src/lib/radio.ts`; criar `RadioPlayer.tsx` com estados, áudio `preload="none"`, timeout (`RADIO_CONNECT_TIMEOUT_MS`), dispatch/listener de eventos e o port das Cenas 01–05 (D6); trocar o filho em `JingleHomeSection.tsx`; deletar `RadioEmbed.tsx` e `tests/unit/radioEmbed.unit.spec.tsx`; escrever `tests/unit/radio.unit.spec.ts` e `tests/unit/radioPlayer.unit.spec.tsx`; adaptar `tests/unit/jingleHomeSection.unit.spec.tsx`; reescrever o comentário de `styles.css`. Checkpoint: `pnpm test:unit tests/unit/radio.unit.spec.ts tests/unit/radioPlayer.unit.spec.tsx tests/unit/jingleHomeSection.unit.spec.tsx tests/unit/campaignHome.unit.spec.tsx` verde + `pnpm typecheck`; conferência visual no dev server do worktree (1280 e 390) contra as cenas.
2. **Coordenação rádio↔jingle (≈1–2 h):** listener/dispatch em `JingleCards.tsx` (D1) e os dois casos novos em `tests/unit/jinglePlayer.unit.spec.tsx`. Checkpoint: `pnpm test:unit tests/unit/jinglePlayer.unit.spec.tsx tests/unit/radioPlayer.unit.spec.tsx` verde; conferir no dev server que tocar um jingle para a rádio e vice-versa (e que `/jingles` segue igual).
3. **Testes e2e + gates (≈2–3 h):** trocar o stub no fixture (D5), reescrever os dois e2e, adicionar `src/lib/radio` ao manifest, greps de fechamento; rodar `pnpm test:e2e -- frontend frontendJingles` (ou `pnpm test:e2e:affected`, que passa a selecionar os dois specs por `src/components/jingles`/`src/app/(frontend)`/`src/lib/radio`). Fechamento: `pnpm gate:fast`; entrada `docs/changelog/2026-09-21-s25.md` (uma entrada curta, registrando a remoção do embed S24 e o ganho de zero terceiros antes do gesto); push via `pnpm push` (a CI roda o gate:ci; o PR arma auto-merge só com `checks` verde).
## Rabbit holes / Não escopo (engenharia)
- Refatorar `ContentShareButton`/`SpeechExcerptShare`/`copyFeedback`/`contentShare` para "unificar": o dono do compartilhamento da rádio é o `RadioPlayer` (via `radio.ts` + `buildWhatsAppTextShareUrl`); os existentes ficam como estão, o `kind` de `contentShare.ts` não ganha valor novo e as labels pinadas seguem intactas.
- Proxy/stream próprio, CDN, cache de áudio, troca de provedor: o `<audio>` aponta direto para `stream.zeno.fm`; nada de infra de streaming.
- Web Audio/`AnalyserNode`/visualizador: o stream não manda CORS — impossível, fora de escopo.
- "Tocando agora", API do Zeno, polling de metadados, duração/timeline, barra de progresso, volume: rádio ao vivo não promete programação nem duração; nome fixo + "Ao vivo".
- Analytics de reprodução/contador de ouvintes, GA, pixel novo: nenhum dado nosso é coletado; MetaPixel da home intocado.
- Consent/CSP/banner de terceiro: nenhum request de terceiro antes do gesto; nada a consentir.
- Redesenhar a seção de som, header/copy, grade ou `/jingles`; tocar `JingleIntro`/`JinglePageHeader`/`JingleEmptyState`/`JinglePlayer`; mexer em `(home)/page.tsx`, `getPublishedJingleItems` ou na tag `jingles`.
- Abstrair estilos compartilhados (módulo de "play control", wrapper de share genérico): <3 call sites; `RadioPlayer` usa constantes locais + `JINGLE_FOCUS_RING`, como `JingleCards` faz com `PLAY_CONTROL`/`DOWNLOAD_CONTROL`.
- Reintroduzir iframe, fallback embed ou link "abrir o player do Zeno" fora do estado de erro (o link "Ouvir no Zeno" existe **só** no erro, decisão de produto).
- Feature flag/A-B embed↔player, rota nova, query param: produto decidiu; não fica caminho latente.
- Tocar no manifest além de adicionar `src/lib/radio` ao entry do S21; novos specs e2e, novos specs unit além dos listados, ou teste int (não há access/write path).
- Renomear `JinglePlayer`/mover para `src/components/radio/`: churn de topologia e de manifest sem ganho.
## Riscos e mitigação
- **Interceptação do áudio no e2e:** `route.fulfill` de mídia no Chromium do Playwright é o ponto frágil do stub. Primário: `stream.zeno.fm/**` → `tests/fixtures/jingle-tone.mp3` (`audio/mpeg`); se a versão pinada não interceptar mídia, o fallback documentado é reduzir a asserção e2e a "connecting + request > 0 após o gesto" (o `playing`/`erro`/retry já estão cobertos no unit com mocks) — nunca deixar o spec flaky.
- **Timeout falso positivo:** `RADIO_CONNECT_TIMEOUT_MS = 12_000` é folgado para 302 + CDN (normal < 2 s); estouro vira erro fail-soft com "Tentar novamente", não beco sem saída. A constante exportada dá o dial sem mexer no componente.
- **Promise superseded:** token de tentativa (`attemptRef`) impede que um `play()`/timeout antigo sobrescreva o estado após pausa/retry — mesmo cuidado que `JingleCards.tsx:136-139`; coberto por unit.
- **Dois triggers de share no layout responsivo:** só um fica visível por breakpoint (`hidden` = `display:none`, fora da árvore de acessibilidade); o estado do popover é local a cada instância — sem conflito. Teste de teclado no unit (Tab alcança um trigger por viewport lógico).
- **Safari/iOS `play()`:** rejeição (gesto, codec ou política) cai no estado de erro com alternativas; sem autoplay, sem `autoPlay`, sem som antes do clique.
- **302 com token por request:** o retry sempre passa por `audio.load()` + `play()` (requisição nova); nada de reaproveitar tentativa falha nem cachear URL assinada.
- **`ended` ignorado:** a rádio é ao vivo e não termina; o stub curto do e2e depende disso para manter "Em reprodução" estável. Se um dia o comportamento importar, é um evento defensivo novo — débito consciente, documentado no comentário do player.
- **Stub antigo removido como rede de regressão:** qualquer request residual a `zeno.fm/player/**` deixa de ser stubbado e derruba o guard de console/erro externo do fixture — prova ativa de que o iframe morreu.
- **`JingleCards` compartilhado com `/jingles`:** listener sempre ativo, mas pausa apenas o áudio ativo do próprio componente e não cria estado global; unit cobre "sem card ativo, `radio:play` não chama `pause`" e o e2e de `/jingles`/kill switch segue afirmando a página intocada.
- **Regressão de a11y/estado:** `data-state`, `aria-pressed`, `aria-busy`, `aria-label` e `role="status"` pinados em `radioPlayer.unit.spec.tsx`; `JINGLE_FOCUS_RING` em todos os controles (play, share, retry, link).
- **Arte assumida:** a estrela oficial (`public/campaign-kit/estrela.png`) é o candidato registrado (`NEEDS ASSET`); o ponto de troca é uma única `<Image src>`/`alt` no `RadioPlayer` — se o humano tiver capa própria, troca-se só a img, sem tocar em layout.
- **CSS/comentário órfão:** a varredura de fechamento (`RadioEmbed|zeno.fm/player|iframe` em `src/` = 0) mais o comentário de `styles.css:1158-1161` reescrito evitam documentação mentirosa; knip/ciclos cobrem o delete no gate.
- **Cache/ISR:** inalterado; os specs navegam `/?e2e=<ts>` como hoje e a home continua lendo uma única vez `getPublishedJingleItems()`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto — player próprio único (e2e: `[data-radio]` + `audio preload="none"`, `iframe` 0; unit: `src`/título/rótulos), zero request antes do gesto (e2e: `zenoRequests === 0` no load e > 0 após o play), exclusividade rádio↔jingle nos dois sentidos (unit `jinglePlayer`/`radioPlayer` + e2e `frontendJingles`), mensagem literal no WhatsApp e ao copiar (unit `radio` + `radioPlayer`), fail-soft com "Tentar novamente"/"Ouvir no Zeno" e timeout de 12 s (unit com fake timers), kill switch/compact preservados (unit `jingleHomeSection` + e2e kill switch), 390 sem overflow (e2e nos dois specs), `/jingles` e MetaPixel intocados (nenhum arquivo seu tocado).
- [ ] Invariantes AGENTS/engineering-standards — home estática via `getPublishedJingleItems()`/tag `jingles` sem leitura ou cache novo; sem PII/Consent/`Consent` novo; sem schema/migration (`push: false` intocado); sem mudança de URL pública (o link do Zeno é conteúdo, não rota); dono editado sem twin (`RadioEmbed` deletado, `RadioPlayer` único) e sem abstração <3 call sites; identificadores em inglês, copy pt-BR preservada; `preload="none"` mantém a postura de não carregar terceiros antes do gesto.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum access/write path; a entrega é apresentação). Cobertura prevista: unit `radio`/`radioPlayer`/`jinglePlayer`/`jingleHomeSection` (reescritos/novos, `radioEmbed` deletado) e e2e `frontend`/`frontendJingles` reescritos com o stub de stream no fixture e `src/lib/radio` no manifest; `campaignHome.unit.spec.tsx` segue como está; `pnpm gate:fast` + `pnpm test:e2e -- frontend frontendJingles` são o gate da entrega.
## Self-score decision-quality (0–5, gate ≥4)
**4/5.** As seis decisões (exclusividade, fronteira de módulo, estados/timeout, compartilhar, testes, port responsivo) têm opções, recomendação com critério verificável e rejeitadas nomeadas; os rabbit holes estão cortados com o dono certo e o depth check reusa `buildWhatsAppTextShareUrl`, `useCopyFeedback`, `Popover`, `JINGLE_FOCUS_RING`, tokens e o precedente de CustomEvent. Perde 1 ponto por duas incertezas reais: (a) o gate não desenha as combinações "full no mobile" e "compact no desktop" — D6 resolve por reuso explícito das duas composições aprovadas, mas é inferência, não desenho; (b) o stub de áudio do e2e depende da interceptação de mídia do Chromium do Playwright, mitigada com fallback documentado, porém não eliminada. Nenhuma das duas muda o outcome de produto.
## Simplify + débitos (triage)
- **Já resolvido no simplify (não reabrir):** `RADIO_PAUSE_EVENT` (broadcast sem consumidor) e `AudioExclusivityDetail` (detail nunca lido) removidos; lógica de parada do card extraída em `stopActiveCard` e reusada pelo listener de `radio:play` e pelo `toggle`; `ended` ignorado documentado no comentário do `<audio>` (rádio ao vivo); unit do branch `onError` e da variante `compact` adicionados; asserção espelhada do `radio.unit.spec.ts` removida.
- **Explicitamente fora (defer com gatilho):** unificar os SVGs de play/pause do `RadioPlayer` com os do `JingleCards` — DRY prematuro (2 call sites) e port literal do design gate; revisitar quando surgir a **3ª superfície com controle de play** (ou se o projeto adotar os ícones lucide).

View file

@ -96,9 +96,12 @@ export const E2E_AFFECTED_MANIFEST = [
// S21 — the public jingles page: ordered published cards, the lazy
// in-page player (one at a time), the slug-based download name and the
// fail-closed publish contract of the collection.
// S25 — the same spec owns the home sound section, now with the own radio
// player (`lib/radio` holds the stream/share contract).
prefixes: [
'src/app/(frontend)/jingles',
'src/lib/jingle',
'src/lib/radio',
'src/utilities/jingleReads.ts',
// The tag/revalidation vocabulary owner for the jingles listing.
'src/utilities/documents.ts',

View file

@ -1157,8 +1157,8 @@ body,
/* S22 — home "A trilha da nossa caminhada": the section title scales above the
neighbours' (Cenas 01/02: 40px desktop, 34px mobile) without touching the
shared token. The radio frame and the direct zeno.fm embed (S24) live as
Tailwind utilities in RadioEmbed — no painted art, wave or spinner here. */
shared token. The radio player (S25, own `<audio preload="none">`) lives as
Tailwind utilities in RadioPlayer — no painted art, wave or spinner here. */
.campaign-sound-title {
font-size: clamp(34px, calc(1.7vw + 18px), 40px);
}

View file

@ -1,9 +1,10 @@
'use client'
import Image from 'next/image'
import { useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { splitJingleTitle, type JingleViewModel } from '@/lib/jingle'
import { JINGLE_PLAY_EVENT, RADIO_PLAY_EVENT } from '@/lib/radio'
import { formatSpeechClock } from '@/lib/speechClock'
import { cn } from '@/lib/utils'
@ -92,10 +93,32 @@ export const JingleCards = ({
const [activeId, setActiveId] = useState<JingleViewModel['id'] | null>(null)
const [playback, setPlayback] = useState<Record<number, Playback>>({})
const audioRefs = useRef(new Map<number, HTMLAudioElement>())
// S25 — the radio:play listener reads the live active id without
// re-subscribing on every card change.
const activeIdRef = useRef<JingleViewModel['id'] | null>(null)
// One stable ref callback per id: an inline arrow would be detached and
// reattached on every render (each `timeupdate` re-renders the list).
const audioRefCallbacks = useRef(new Map<number, (node: HTMLAudioElement | null) => void>())
useEffect(() => {
activeIdRef.current = activeId
}, [activeId])
// S25 — one audio at a time with the home radio: a `radio:play` pauses only
// this component's active card. Inert on `/jingles` (no radio broadcasts
// there), so the exclusivity never leaks into the page's own behaviour.
const stopActiveCard = useCallback(() => {
const current = activeIdRef.current
if (current === null) return
audioRefs.current.get(current)?.pause()
setActiveId(null)
}, [])
useEffect(() => {
window.addEventListener(RADIO_PLAY_EVENT, stopActiveCard)
return () => window.removeEventListener(RADIO_PLAY_EVENT, stopActiveCard)
}, [stopActiveCard])
const audioRef = (id: number) => {
const cached = audioRefCallbacks.current.get(id)
if (cached) return cached
@ -124,8 +147,7 @@ export const JingleCards = ({
if (!audio) return
if (activeId === id) {
audio.pause()
setActiveId(null)
stopActiveCard()
return
}
@ -134,6 +156,7 @@ export const JingleCards = ({
}
setActiveId(id)
window.dispatchEvent(new CustomEvent(JINGLE_PLAY_EVENT))
// A pending play() of another card rejects when we pause it; the catch
// must only clear the state of THIS card, never the one now playing.
void audio.play().catch(() => setActiveId((current) => (current === id ? null : current)))

View file

@ -4,7 +4,7 @@ import type { JingleViewModel } from '@/lib/jingle'
import { JINGLE_FOCUS_RING } from './focusRing'
import { JingleCards } from './JingleCards'
import { RadioEmbed } from './RadioEmbed'
import { RadioPlayer } from './RadioPlayer'
type JingleHomeSectionProps = {
jingles: readonly JingleViewModel[]
@ -14,12 +14,12 @@ type JingleHomeSectionProps = {
const SEE_ALL_CONTROL = `inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 font-bold text-(--pt-red) underline-offset-4 hover:underline ${JINGLE_FOCUS_RING}`
/**
* S22 — the home sound section (artefato: cenas 01/02/06/07 do S22; a moldura
* da rádio agora é a do S24, cenas 01/02/03): the Rádio 1313 embed (direto
* desde o S24) followed by up to three published jingles and the "Ver todos"
* handoff to `/jingles` when more exist. With zero published jingles the grid
* and the handoff are gone and only the radio stays (fail-closed, same kill
* switch as S21); the page passes the already-cached
* S22/S25 — the home sound section (artefato: cenas 01/02/06/07 do S22; o
* player da rádio é o do S25, cenas 01–05): the Rádio 1313 player (próprio,
* `preload="none"`, desde o S25) followed by up to three published jingles and
* the "Ver todos" handoff to `/jingles` when more exist. With zero published
* jingles the grid and the handoff are gone and only the radio stays
* (fail-closed, same kill switch as S21); the page passes the already-cached
* `getPublishedJingleItems()` listing in, so the section never re-reads.
*/
export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps) => {
@ -60,7 +60,7 @@ export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps)
</p>
</div>
<RadioEmbed compact={!hasJingles} />
<RadioPlayer compact={!hasJingles} />
{hasJingles ? (
<>

View file

@ -1,31 +0,0 @@
import { cn } from '@/lib/utils'
const RADIO_PLAYER_URL = 'https://zeno.fm/player/jorge-solla-1313/'
/**
* S24 — the Rádio Jorge Solla 1313 embed (artefato: cenas 01/02/03), replacing
* the S22 click-to-load facade: the official zeno.fm player is server-rendered
* and starts loading with the page — no button, state machine or request gated
* behind a click. The frame keeps the reserved 150/168px footprint from the
* first paint (no jump) and stays a bare third-party widget (no transparency
* line, chip or shield). `compact` is the zero-jingles variant: the frame
* narrows and centers; the section header/copy and the jingles grid are owned
* by `JingleHomeSection` and unchanged.
*/
export const RadioEmbed = ({ compact = false }: { compact?: boolean }) => (
<article
data-radio
aria-label="Player da Rádio Jorge Solla 1313"
className={cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-3 shadow-[0_10px_28px_rgb(71_19_14/7%)] md:p-4',
compact ? 'mx-auto mt-8 max-w-[880px]' : 'mt-9',
)}
>
<iframe
src={RADIO_PLAYER_URL}
title="Player da Rádio Jorge Solla 1313 no zeno.fm"
allow="autoplay"
className="h-[150px] w-full rounded-xl border-0 bg-(--campaign-band) md:h-[168px]"
/>
</article>
)

View file

@ -0,0 +1,410 @@
'use client'
import { CheckIcon, CopyIcon, InfoIcon } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import {
buildRadioShareWhatsAppUrl,
JINGLE_PLAY_EVENT,
RADIO_CONNECT_TIMEOUT_MS,
RADIO_PAGE_URL,
RADIO_PLAY_EVENT,
RADIO_SHARE_MESSAGE,
RADIO_STREAM_URL,
RADIO_TITLE,
} from '@/lib/radio'
import { cn } from '@/lib/utils'
import { JINGLE_FOCUS_RING } from './focusRing'
type RadioStatus = 'idle' | 'connecting' | 'playing' | 'error'
const PLAY_CONTROL = cn(
'inline-flex size-12 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) md:size-14',
'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter,opacity] duration-150 ease-out',
'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
JINGLE_FOCUS_RING,
)
const SHARE_OUTLINE = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/32%)] bg-transparent px-3.5',
'font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red) no-underline',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const SHARE_LINK = cn(
'inline-flex min-h-11 items-center gap-2 rounded-lg px-2 text-sm font-bold text-(--pt-red)',
JINGLE_FOCUS_RING,
)
const SHARE_PRIMARY = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-(--pt-red) px-4 text-sm font-black text-white no-underline',
'font-[family-name:var(--font-exo2)] transition-[filter] duration-150 ease-out hover:brightness-110 motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const RETRY_CONTROL = cn(
'inline-flex min-h-11 items-center rounded-lg bg-(--pt-red) px-4 text-sm font-black text-white',
'font-[family-name:var(--font-exo2)] transition-[filter] duration-150 ease-out hover:brightness-110 motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const ZENO_LINK = cn(
'inline-flex min-h-11 items-center rounded-lg px-2 text-sm font-bold text-(--pt-red) underline underline-offset-4',
JINGLE_FOCUS_RING,
)
const PlayIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 fill-current stroke-current md:h-[25px] md:w-[25px]"
>
<path d="m8 5 11 7-11 7z" />
</svg>
)
const PauseIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 fill-current stroke-current md:h-[25px] md:w-[25px]"
>
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
</svg>
)
const ShareIcon = ({ className }: { className: string }) => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
)
/**
* S25 (Cena 05) — the radio share control: one discreet trigger with the two
* approved exits (the literal message into the sender's own WhatsApp, or the
* message copied to the clipboard with the shared feedback). The copy writes
* the literal, link included — what circulates is the message, matching the
* gate's box. Private to this file: the radio owns its share surface.
*/
const RadioShareControl = ({ variant }: { variant: 'outline' | 'link' }) => {
const [open, setOpen] = useState(false)
const { feedback, copy } = useCopyFeedback()
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={variant === 'outline' ? SHARE_OUTLINE : SHARE_LINK}
aria-label={`Compartilhar ${RADIO_TITLE}`}
>
<ShareIcon className={variant === 'outline' ? 'size-[18px]' : 'size-4'} />
Compartilhar
</button>
</PopoverTrigger>
<PopoverContent
align="end"
// The portal lives under `document.body`, outside the home's
// `[data-theme='campaign-site']` wrapper: without the attribute the
// campaign tokens (--pt-red, --campaign-band, …) are undefined and the
// primary WhatsApp CTA renders invisible (same fix as CardsStudio).
data-theme="campaign-site"
className="w-80 rounded-xl border border-(--campaign-line) bg-white p-4 shadow-xl"
>
<p className="m-0 font-[family-name:var(--font-exo2)] text-base font-black">
Compartilhar a rádio
</p>
<p className="mt-2 rounded-lg bg-(--campaign-band) p-3 text-xs leading-5 break-words text-(--campaign-muted)">
{RADIO_SHARE_MESSAGE}
</p>
<div className="mt-3 grid gap-2">
<a
href={buildRadioShareWhatsAppUrl()}
target="_blank"
rel="noopener noreferrer"
className={SHARE_PRIMARY}
onClick={() => setOpen(false)}
>
<WhatsAppIcon className="size-4" />
Compartilhar no WhatsApp
</a>
<button
type="button"
className={cn(SHARE_OUTLINE, 'w-full')}
onClick={() => void copy(RADIO_SHARE_MESSAGE)}
>
{feedback === 'copied' ? (
<CheckIcon className="size-4" aria-hidden="true" />
) : (
<CopyIcon className="size-4" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</button>
</div>
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</PopoverContent>
</Popover>
)
}
/**
* S25 — the home radio player (artefato: cenas 01–05). The zeno.fm iframe of
* S24 is gone: this client component owns a bare `<audio preload="none">`
* pointed at the stream, so no request to the stream (or any third party) leaves
* before the visitor's play. The state machine (`idle → connecting → playing |
* error`) turns a refused `play()` or a connection that never delivers audio
* into the honest error state with retry and "Ouvir no Zeno" — never an
* infinite spinner. `compact` is the zero-jingles variant (centered, share on
* the bottom row); with jingles the section grid follows the gate's Cena 01 on
* `md+` and the Cena 02 row on mobile. Exclusivity with the jingles is the
* `radio:play`/`radio:pause`/`jingle:play` broadcast from `lib/radio`.
*/
export const RadioPlayer = ({ compact = false }: { compact?: boolean }) => {
const [status, setStatus] = useState<RadioStatus>('idle')
const audioRef = useRef<HTMLAudioElement>(null)
// Invalidates the pending play()/timeout of a superseded attempt: pausing or
// retrying during `connecting` must not let the old attempt win later.
const attemptRef = useRef(0)
// Event handlers read the live status without re-subscribing window listeners.
const statusRef = useRef<RadioStatus>('idle')
useEffect(() => {
statusRef.current = status
}, [status])
const stopPlayback = useCallback((next: 'idle' | 'error') => {
attemptRef.current += 1
audioRef.current?.pause()
setStatus(next)
}, [])
const start = useCallback(() => {
const audio = audioRef.current
if (!audio) return
// The jingle stops on the gesture itself, even if the stream then fails.
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT))
const attempt = (attemptRef.current += 1)
setStatus('connecting')
// The stream answers 302 with a per-request signed URL: a retry needs a new
// request, and `load()` is what discards the failed one.
audio.load()
void audio
.play()
.then(() => {
if (attemptRef.current === attempt) setStatus('playing')
})
.catch(() => {
if (attemptRef.current === attempt) stopPlayback('error')
})
}, [stopPlayback])
const toggle = useCallback(() => {
if (statusRef.current === 'playing' || statusRef.current === 'connecting') {
stopPlayback('idle')
return
}
start()
}, [start, stopPlayback])
// Never an infinite spinner: a stream that accepts the connection and never
// delivers audio becomes the honest error state after the timeout.
useEffect(() => {
if (status !== 'connecting') return
const timeout = setTimeout(() => stopPlayback('error'), RADIO_CONNECT_TIMEOUT_MS)
return () => clearTimeout(timeout)
}, [status, stopPlayback])
// One audio at a time: a jingle started anywhere pauses the radio.
useEffect(() => {
const onJinglePlay = () => {
if (statusRef.current !== 'playing' && statusRef.current !== 'connecting') return
stopPlayback('idle')
}
window.addEventListener(JINGLE_PLAY_EVENT, onJinglePlay)
return () => window.removeEventListener(JINGLE_PLAY_EVENT, onJinglePlay)
}, [stopPlayback])
return (
<article
data-radio
data-state={status}
aria-label={`Player da ${RADIO_TITLE}`}
className={cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_10px_28px_rgb(71_19_14/7%)]',
compact ? 'mx-auto mt-8 max-w-[880px] p-3' : 'mt-9 p-3 md:p-4',
)}
>
{status === 'error' ? (
<div>
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="grid size-10 flex-none place-items-center rounded-full bg-[rgb(162_28_28/10%)] text-(--pt-red)"
>
<InfoIcon className="size-5" />
</span>
<div>
<h3 className="m-0 border-0 p-0 font-[family-name:var(--font-exo2)] text-lg font-black">
A rádio não conectou
</h3>
<p className="m-0 mt-1 text-sm leading-5 text-(--campaign-muted)">
Tente de novo ou ouça na página da rádio.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button type="button" onClick={start} className={RETRY_CONTROL}>
Tentar novamente
</button>
<a
href={RADIO_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
className={ZENO_LINK}
>
Ouvir no Zeno
</a>
<div className="ml-auto">
<RadioShareControl variant="outline" />
</div>
</div>
</div>
) : (
<>
<div
className={cn(
'flex items-center gap-3',
compact ? undefined : 'md:grid md:grid-cols-[112px_1fr_auto] md:gap-5',
)}
>
<div className="relative grid size-[76px] flex-none place-items-center overflow-hidden rounded-[10px] bg-(--campaign-band) md:size-28">
<span
aria-hidden="true"
className="absolute inset-[13%] rounded-full border border-[rgb(0_0_0/8%)] bg-white shadow-[0_7px_18px_rgb(71_19_14/10%)]"
/>
<Image
src="/campaign-kit/estrela.png"
alt="Estrela oficial da campanha Jorge Solla 1313"
width={72}
height={72}
className="relative z-[1] h-[64%] w-[64%] object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-2 text-[10px] font-black tracking-[0.08em] text-(--pt-red) uppercase md:rounded-full md:bg-[rgb(162_28_28/8%)] md:px-3 md:py-1 md:text-[11px]">
<span
aria-hidden="true"
className="size-2 rounded-full bg-(--pt-red) shadow-[0_0_0_4px_rgb(162_28_28/11%)]"
/>
Ao vivo
</span>
<span className="hidden text-xs text-(--campaign-muted) md:inline">
Rádio online
</span>
</div>
<h3 className="m-0 mt-1 border-0 p-0 font-[family-name:var(--font-exo2)] text-lg leading-tight font-black tracking-[-0.025em] md:mt-3 md:text-[28px] md:leading-none">
{RADIO_TITLE}
</h3>
{status === 'connecting' ? (
<p
role="status"
className="m-0 mt-1 flex items-center gap-2 text-xs font-bold md:mt-2 md:text-sm"
>
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-[rgb(162_28_28/18%)] border-t-(--pt-red) [animation-duration:0.8s] motion-reduce:animate-none"
/>
Conectando à rádio…
</p>
) : status === 'playing' ? (
<p className="m-0 mt-1 flex items-center gap-2 text-xs font-bold text-(--pt-red) md:mt-2 md:text-sm">
<span aria-hidden="true" className="size-2 rounded-full bg-(--pt-red)" />
Em reprodução
</p>
) : (
<p className="m-0 mt-1 text-xs text-(--campaign-muted) md:mt-2 md:text-sm">
Pronta para tocar
</p>
)}
</div>
<div className="flex items-center gap-4 md:pr-2">
{compact ? null : (
<span className="hidden md:inline-flex">
<RadioShareControl variant="outline" />
</span>
)}
<button
type="button"
className={cn(PLAY_CONTROL, status === 'connecting' && 'opacity-60')}
aria-label={
status === 'connecting'
? 'Conectando'
: status === 'playing'
? `Pausar ${RADIO_TITLE}`
: `Ouvir ${RADIO_TITLE}`
}
aria-pressed={status === 'playing'}
aria-busy={status === 'connecting'}
onClick={toggle}
>
{status === 'playing' ? <PauseIcon /> : <PlayIcon />}
</button>
</div>
</div>
<div
className={cn(
'mt-3 flex justify-end border-t border-(--campaign-line) pt-2',
compact ? undefined : 'md:hidden',
)}
>
<RadioShareControl variant="link" />
</div>
</>
)}
{/* A live stream never ends: `ended` is intentionally ignored (a stub
short media file must not flip the player back to idle). */}
<audio
ref={audioRef}
src={RADIO_STREAM_URL}
preload="none"
onPlaying={() => {
if (statusRef.current === 'connecting' || statusRef.current === 'playing') {
setStatus('playing')
}
}}
onError={() => {
if (statusRef.current === 'connecting' || statusRef.current === 'playing') {
stopPlayback('error')
}
}}
/>
</article>
)
}

36
src/lib/radio.ts Normal file
View file

@ -0,0 +1,36 @@
import { buildWhatsAppTextShareUrl } from './phone'
/**
* S25 — the single owner of the Rádio 1313 contract: the zeno.fm stream the
* player points at, the public page the share exits open, the fixed display
* title (the stream's own `icy-name`) and the literal share message. Pure and
* client-safe, so the player state machine and the unit tests pin the same
* values without React.
*/
export const RADIO_STREAM_URL = 'https://stream.zeno.fm/hys86kx6k16tv'
export const RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/'
export const RADIO_TITLE = 'Rádio Jorge Solla 1313'
export const RADIO_SHARE_MESSAGE = `Ouça a ${RADIO_TITLE} — ${RADIO_PAGE_URL}`
/**
* How long `connecting` may last before the player admits the failure. The
* stream answers a 302 with a per-request signed CDN URL (usually under 2s), so
* a connection that accepts and never delivers audio must not spin forever —
* it becomes the honest `error` state with the retry and "Ouvir no Zeno" exits.
*/
export const RADIO_CONNECT_TIMEOUT_MS = 12_000
/**
* S25 — one audio at a time in the home sound section. The radio and the
* jingles are sibling client components (the section shell is a server
* component), so the exclusivity is a `window` CustomEvent broadcast — the repo
* precedent (`signature:created`, the speech excerpt request). Each listener
* only ever touches its own media element, which keeps the events inert outside
* the home (`/jingles` has no radio, so `jingle:play` has no consumer there).
*/
export const RADIO_PLAY_EVENT = 'radio:play'
export const JINGLE_PLAY_EVENT = 'jingle:play'
/** `wa.me` with the literal message and no recipient — the sender's own WhatsApp picks the chat. */
export const buildRadioShareWhatsAppUrl = (): string =>
buildWhatsAppTextShareUrl(RADIO_SHARE_MESSAGE)

View file

@ -1,3 +1,5 @@
import path from 'node:path'
import {
test as base,
expect,
@ -115,16 +117,15 @@ export const test = base.extend<E2EFailureGuardFixtures>({
route.fulfill({ body: '', contentType: 'application/javascript' }),
)
// S24 — the home radio is a direct zeno.fm iframe that loads eagerly with
// the pageview, so every spec rendering the home requests it. Fulfill the
// player locally (an aborted frame logs an external-origin console error
// and the guard above would fail); the request event still fires, which is
// how the sound-section specs prove the eager mount. The glob is scoped to
// the player path: any other zeno.fm request stays a real failure.
await context.route('https://zeno.fm/player/**', (route) =>
// S25 — the home radio is an own `<audio preload="none">` pointed at the
// stream, so nothing is requested with the pageview (the S24 iframe stub
// is gone: a residual zeno.fm/player request would now be a real failure).
// The stub serves the short MP3 only when a spec presses play; the player
// ignores `ended` (a live stream never ends), which keeps "playing" stable.
await context.route('https://stream.zeno.fm/**', (route) =>
route.fulfill({
body: '<!doctype html><title>zeno stub</title>',
contentType: 'text/html',
path: path.resolve(process.cwd(), 'tests/fixtures/jingle-tone.mp3'),
contentType: 'audio/mpeg',
}),
)

View file

@ -557,7 +557,7 @@ test.describe('Frontend', () => {
expect(overflow).toBeLessThanOrEqual(1)
})
test('shows the sound section between the story and the cards with the direct radio embed', async ({
test('shows the sound section between the story and the cards with the own radio player', async ({
page,
}) => {
const zenoRequests: string[] = []
@ -589,25 +589,38 @@ test.describe('Frontend', () => {
expect(tops.sound).toBeGreaterThan(tops.story)
expect(tops.cards).toBeGreaterThan(tops.sound)
// S24 — the official player is mounted from the first paint (the fixture
// stubs the zeno request): no facade, no button, no click gate, and the
// frame was actually requested before any gesture.
await expect(sound.locator('[data-radio]')).toBeVisible()
const radioFrame = sound.locator('[data-radio] iframe')
await expect(radioFrame).toHaveCount(1)
await expect(radioFrame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
await expect(radioFrame).toHaveAttribute('title', 'Player da Rádio Jorge Solla 1313 no zeno.fm')
await expect(sound.locator('[data-radio] button')).toHaveCount(0)
await expect(sound.locator('[data-radio] a')).toHaveCount(0)
// S25 — the own player replaces the S24 iframe: one lazy `<audio>` and no
// request at all before the visitor's gesture.
const radio = sound.locator('[data-radio]')
await expect(radio).toBeVisible()
await expect(radio.locator('iframe')).toHaveCount(0)
const radioAudio = radio.locator('audio')
await expect(radioAudio).toHaveCount(1)
await expect(radioAudio).toHaveAttribute('preload', 'none')
await expect(radioAudio).toHaveAttribute('src', 'https://stream.zeno.fm/hys86kx6k16tv')
await expect(radio.getByRole('heading', { name: 'Rádio Jorge Solla 1313' })).toBeVisible()
await expect(radio.getByText('Ao vivo')).toBeVisible()
expect(zenoRequests).toHaveLength(0)
// The play is the only exit to the stream: the request leaves after the
// gesture (the fixture stubs it) and the state says so.
await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click()
await expect.poll(() => zenoRequests.length).toBeGreaterThan(0)
await expect(radio).toHaveAttribute('data-state', 'playing')
await expect(radio.getByText('Em reprodução')).toBeVisible()
// The reserved footprint comes from the frame itself: 168px desktop.
await expect(radioFrame).toHaveCSS('height', '168px')
// Desktop 1280: the gate's 56px play hit.
await expect(radio.getByRole('button', { name: 'Pausar Rádio Jorge Solla 1313' })).toHaveCSS(
'width',
'56px',
)
// Mobile 390: the footprint narrows to 150px and the section never
// overflows horizontally.
// Mobile 390: the hit narrows to 48px and the section never overflows.
await page.setViewportSize({ width: 390, height: 844 })
await expect(radioFrame).toHaveCSS('height', '150px')
await expect(radio.getByRole('button', { name: 'Pausar Rádio Jorge Solla 1313' })).toHaveCSS(
'width',
'48px',
)
const overflow = await page.evaluate(() => {
const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0

View file

@ -199,11 +199,12 @@ test.describe('Frontend jingles (S21)', () => {
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
await page.goto('/')
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
// S24 — the home sound section keeps the radio (direct embed) and drops the empty grid.
// S25 — the home sound section keeps the own radio player and drops the empty grid.
const sound = page.locator('[data-home-section="sound"]')
await expect(sound.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeVisible()
await expect(sound.locator('[data-radio]')).toBeVisible()
await expect(sound.locator('[data-radio] iframe')).toHaveCount(1)
await expect(sound.locator('[data-radio] iframe')).toHaveCount(0)
await expect(sound.locator('[data-radio] audio[preload="none"]')).toHaveCount(1)
await expect(sound.locator('article[data-jingle]')).toHaveCount(0)
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
expect((await request.get(`${BASE_URL}/jingles`)).status()).toBe(200)
@ -245,7 +246,7 @@ test.describe('Frontend jingles (S21)', () => {
await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible()
})
test('shows the sound section on the home and mounts the radio directly', async ({
test('shows the sound section on the home with the own radio and one audio at a time', async ({
page,
request,
}) => {
@ -270,17 +271,34 @@ test.describe('Frontend jingles (S21)', () => {
// Three of three: the handoff only exists when more are published.
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
// S24 — the direct embed mounts with the pageview, no gesture (the fixture
// stubs the zeno request): the frame is there and the stub was requested
// before any click.
const frame = sound.locator('[data-radio] iframe')
await expect(frame).toHaveCount(1)
await expect(frame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
await expect(frame).toHaveAttribute('title', 'Player da Rádio Jorge Solla 1313 no zeno.fm')
await expect.poll(() => zenoRequests.length).toBeGreaterThan(0)
// The cards still fetch nothing until a play (`preload="none"` on every card).
// S25 — the own player mounts with `preload="none"`: neither the stream nor
// the card audios are requested before a gesture.
const radio = sound.locator('[data-radio]')
await expect(radio.locator('iframe')).toHaveCount(0)
const radioAudio = radio.locator('audio')
await expect(radioAudio).toHaveCount(1)
await expect(radioAudio).toHaveAttribute('preload', 'none')
await expect(radioAudio).toHaveAttribute('src', 'https://stream.zeno.fm/hys86kx6k16tv')
expect(zenoRequests).toHaveLength(0)
expect(audioRequests).toHaveLength(0)
// Playing the radio is what requests the stream (the fixture stubs it).
await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click()
await expect.poll(() => zenoRequests.length).toBeGreaterThan(0)
await expect(radio).toHaveAttribute('data-state', 'playing')
await expect(radio.getByText('Em reprodução')).toBeVisible()
// Starting a jingle pauses the radio (one audio at a time).
await sound.getByRole('button', { name: 'Tocar jingle Axé na home' }).click()
await expect(radio).toHaveAttribute('data-state', 'idle')
await expect(radio.getByText('Pronta para tocar')).toBeVisible()
await expect(sound.locator('article[data-jingle][data-state="playing"]')).toHaveCount(1)
// And playing the radio pauses the jingle back.
await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click()
await expect(radio).toHaveAttribute('data-state', 'playing')
await expect(sound.locator('article[data-jingle][data-state="playing"]')).toHaveCount(0)
// Mobile 390: the section never overflows horizontally.
await page.setViewportSize({ width: 390, height: 844 })
const overflow = await page.evaluate(() => {

View file

@ -43,8 +43,10 @@ describe('JingleHomeSection', () => {
render(<JingleHomeSection jingles={[]} showAll={false} />)
expect(document.querySelector('[data-home-section="sound"]')).not.toBeNull()
expect(document.querySelector('[data-radio] iframe')).not.toBeNull()
// Cena 01/02 (S24): zero jingles keeps the compact, centered frame.
// S25 — the own player: one lazy audio, no iframe anywhere.
expect(document.querySelector('[data-radio] audio[preload="none"]')).not.toBeNull()
expect(document.querySelector('[data-radio] iframe')).toBeNull()
// Cena 02 (S25): zero jingles keeps the compact, centered player.
expect(document.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
expect(cards()).toHaveLength(0)
expect(screen.queryByText('Jingles oficiais')).toBeNull()
@ -56,7 +58,7 @@ describe('JingleHomeSection', () => {
render(<JingleHomeSection jingles={[jingle(1, 'Axé')]} showAll={false} />)
expect(cards()).toHaveLength(1)
// Cena 03: with jingles the frame takes the full column width.
// Cena 01: with jingles the player takes the full column width.
expect(document.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]')
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
expect(screen.getByText('Jingles oficiais')).toBeDefined()

View file

@ -3,6 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import type { JingleViewModel } from '@/lib/jingle'
import { JINGLE_PLAY_EVENT, RADIO_PLAY_EVENT } from '@/lib/radio'
const JINGLES: readonly JingleViewModel[] = [
{
@ -124,6 +125,45 @@ describe('JinglePlayer', () => {
expect(playMock).toHaveBeenCalledTimes(2)
})
it('broadcasts jingle:play when a card starts', () => {
let broadcasts = 0
const listener = () => {
broadcasts += 1
}
window.addEventListener(JINGLE_PLAY_EVENT, listener)
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
window.removeEventListener(JINGLE_PLAY_EVENT, listener)
expect(broadcasts).toBe(1)
})
it('pauses the active card when the radio starts', () => {
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
pauseMock.mockClear()
act(() => {
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } }))
})
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(cardState(0)).toBe('stopped')
expect(playButton(0).getAttribute('aria-pressed')).toBe('false')
})
it('ignores radio:play with no active card', () => {
render(<JinglePlayer jingles={JINGLES} />)
act(() => {
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } }))
})
expect(pauseMock).not.toHaveBeenCalled()
})
it('reverts the card when the browser refuses to play', async () => {
playMock.mockImplementationOnce(() => Promise.reject(new Error('blocked')))
render(<JinglePlayer jingles={JINGLES} />)

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
buildRadioShareWhatsAppUrl,
JINGLE_PLAY_EVENT,
RADIO_CONNECT_TIMEOUT_MS,
RADIO_PAGE_URL,
RADIO_PLAY_EVENT,
RADIO_SHARE_MESSAGE,
RADIO_STREAM_URL,
RADIO_TITLE,
} from '@/lib/radio'
describe('radio contract', () => {
it('pins the stream, the public page and the fixed display title', () => {
expect(RADIO_STREAM_URL).toBe('https://stream.zeno.fm/hys86kx6k16tv')
expect(RADIO_PAGE_URL).toBe('https://zeno.fm/radio/jorge-solla-1313/')
expect(RADIO_TITLE).toBe('Rádio Jorge Solla 1313')
})
it('builds the literal share message of the gate', () => {
expect(RADIO_SHARE_MESSAGE).toBe(
'Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/',
)
})
it('opens the sender WhatsApp with the literal message inside the URL', () => {
const url = buildRadioShareWhatsAppUrl()
expect(new URL(url).searchParams.get('text')).toBe(RADIO_SHARE_MESSAGE)
})
it('arms a finite connect timeout', () => {
expect(RADIO_CONNECT_TIMEOUT_MS).toBe(12_000)
})
it('names the exclusivity broadcast events of the sound section', () => {
expect(RADIO_PLAY_EVENT).toBe('radio:play')
expect(JINGLE_PLAY_EVENT).toBe('jingle:play')
})
})

View file

@ -1,49 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { RadioEmbed } from '@/components/jingles/RadioEmbed'
const frame = () => document.querySelector<HTMLIFrameElement>('iframe')
afterEach(cleanup)
describe('RadioEmbed', () => {
it('mounts the official player on the first render, without a click', () => {
render(<RadioEmbed />)
const mountedFrame = frame()
expect(mountedFrame).not.toBeNull()
expect(mountedFrame?.getAttribute('src')).toBe('https://zeno.fm/player/jorge-solla-1313/')
expect(mountedFrame?.getAttribute('title')).toContain('Rádio Jorge Solla 1313')
expect(mountedFrame?.getAttribute('allow')).toBe('autoplay')
})
it('does not defer the frame to the viewport', () => {
render(<RadioEmbed />)
expect(frame()?.hasAttribute('loading')).toBe(false)
})
it('labels the frame region and drops every facade affordance', () => {
render(<RadioEmbed />)
expect(screen.getByRole('article', { name: 'Player da Rádio Jorge Solla 1313' })).toBeDefined()
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('link')).toBeNull()
expect(screen.queryByText(/Ouvir a rádio/)).toBeNull()
expect(screen.queryByText(/Abrir no Zeno/)).toBeNull()
expect(screen.queryByText(/só é carregado depois do seu clique/)).toBeNull()
})
it('narrows and centers the frame only in the compact variant', () => {
const { container, unmount } = render(<RadioEmbed compact />)
expect(container.querySelector('[data-radio]')?.className).toContain('mx-auto')
expect(container.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
unmount()
const full = render(<RadioEmbed />)
expect(full.container.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]')
})
})

View file

@ -0,0 +1,272 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { RadioPlayer } from '@/components/jingles/RadioPlayer'
import {
JINGLE_PLAY_EVENT,
RADIO_CONNECT_TIMEOUT_MS,
RADIO_PAGE_URL,
RADIO_PLAY_EVENT,
RADIO_SHARE_MESSAGE,
RADIO_STREAM_URL,
} from '@/lib/radio'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} />
),
}))
const playMock = vi.fn<() => Promise<void>>()
const pauseMock = vi.fn()
const loadMock = vi.fn()
const player = () => document.querySelector<HTMLElement>('[data-radio]')
const dataState = () => player()?.getAttribute('data-state')
const audioOf = () => document.querySelector<HTMLAudioElement>('[data-radio] audio')
const playButton = () =>
document.querySelector<HTMLButtonElement>(
'[data-radio] button[aria-pressed]',
) as HTMLButtonElement
const captureEvents = (eventName: string) => {
let count = 0
const listener = () => {
count += 1
}
window.addEventListener(eventName, listener)
return {
count: () => count,
stop: () => window.removeEventListener(eventName, listener),
}
}
beforeAll(() => {
// jsdom does not implement media playback; the state machine is the contract.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: () => playMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
configurable: true,
value: () => pauseMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'load', {
configurable: true,
value: () => loadMock(),
})
})
beforeEach(() => {
playMock.mockReset()
playMock.mockResolvedValue(undefined)
pauseMock.mockReset()
loadMock.mockReset()
// The share popover (radix) measures its content on open.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('RadioPlayer', () => {
it('renders the own player with lazy audio and no iframe', () => {
render(<RadioPlayer />)
expect(screen.getByRole('article', { name: 'Player da Rádio Jorge Solla 1313' })).toBeDefined()
expect(dataState()).toBe('idle')
expect(document.querySelector('[data-radio] iframe')).toBeNull()
expect(audioOf()?.getAttribute('preload')).toBe('none')
expect(audioOf()?.getAttribute('src')).toBe(RADIO_STREAM_URL)
expect(screen.getByText('Rádio Jorge Solla 1313')).toBeDefined()
expect(screen.getByText('Ao vivo')).toBeDefined()
expect(screen.getByText('Pronta para tocar')).toBeDefined()
expect(playButton().getAttribute('aria-label')).toBe('Ouvir Rádio Jorge Solla 1313')
expect(playButton().getAttribute('aria-pressed')).toBe('false')
// One trigger per breakpoint (outline inline on md+, link on the bottom
// row) — jsdom applies no CSS, so both mounts are in the tree here.
expect(
screen.getAllByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }),
).toHaveLength(2)
expect(playMock).not.toHaveBeenCalled()
expect(loadMock).not.toHaveBeenCalled()
})
it('connects on the play and reaches playing with the exclusivity broadcast', async () => {
const playEvents = captureEvents(RADIO_PLAY_EVENT)
render(<RadioPlayer />)
fireEvent.click(playButton())
expect(loadMock).toHaveBeenCalledTimes(1)
expect(playMock).toHaveBeenCalledTimes(1)
expect(dataState()).toBe('connecting')
expect(playButton().getAttribute('aria-busy')).toBe('true')
expect(playButton().getAttribute('aria-label')).toBe('Conectando')
expect(screen.getByText('Conectando à rádio…')).toBeDefined()
expect(playEvents.count()).toBe(1)
await waitFor(() => expect(dataState()).toBe('playing'))
expect(screen.getByText('Em reprodução')).toBeDefined()
expect(playButton().getAttribute('aria-pressed')).toBe('true')
expect(playButton().getAttribute('aria-label')).toBe('Pausar Rádio Jorge Solla 1313')
playEvents.stop()
})
it('toggles off and pauses the audio', async () => {
render(<RadioPlayer />)
fireEvent.click(playButton())
await waitFor(() => expect(dataState()).toBe('playing'))
fireEvent.click(playButton())
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(dataState()).toBe('idle')
expect(screen.getByText('Pronta para tocar')).toBeDefined()
})
it('turns a refused play into the honest error state with the exits', async () => {
playMock.mockRejectedValueOnce(new Error('blocked'))
render(<RadioPlayer />)
fireEvent.click(playButton())
await waitFor(() => expect(dataState()).toBe('error'))
expect(screen.getByText('A rádio não conectou')).toBeDefined()
expect(screen.getByText('Tente de novo ou ouça na página da rádio.')).toBeDefined()
expect(screen.getByRole('button', { name: 'Tentar novamente' })).toBeDefined()
const zeno = screen.getByRole('link', { name: 'Ouvir no Zeno' })
expect(zeno.getAttribute('href')).toBe(RADIO_PAGE_URL)
expect(zeno.getAttribute('target')).toBe('_blank')
expect(zeno.getAttribute('rel')).toBe('noopener noreferrer')
expect(
screen.getByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }),
).toBeDefined()
})
it('turns a media error event into the error state', () => {
render(<RadioPlayer />)
fireEvent.click(playButton())
fireEvent.error(audioOf()!)
expect(dataState()).toBe('error')
expect(screen.getByText('A rádio não conectou')).toBeDefined()
})
it('never spins forever: the connect timeout becomes the error state', () => {
vi.useFakeTimers()
playMock.mockImplementationOnce(() => new Promise<void>(() => {}))
render(<RadioPlayer />)
fireEvent.click(playButton())
expect(dataState()).toBe('connecting')
act(() => {
vi.advanceTimersByTime(RADIO_CONNECT_TIMEOUT_MS)
})
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(dataState()).toBe('error')
expect(screen.getByText('A rádio não conectou')).toBeDefined()
})
it('retries with a fresh request from the error state', async () => {
playMock.mockRejectedValueOnce(new Error('blocked'))
render(<RadioPlayer />)
fireEvent.click(playButton())
await waitFor(() => expect(dataState()).toBe('error'))
fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' }))
expect(loadMock).toHaveBeenCalledTimes(2)
await waitFor(() => expect(dataState()).toBe('playing'))
})
it('pauses the radio when a jingle starts', async () => {
render(<RadioPlayer />)
fireEvent.click(playButton())
await waitFor(() => expect(dataState()).toBe('playing'))
act(() => {
window.dispatchEvent(
new CustomEvent(JINGLE_PLAY_EVENT, { detail: { source: 'jingle', id: 7 } }),
)
})
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(dataState()).toBe('idle')
expect(screen.getByText('Pronta para tocar')).toBeDefined()
})
it('ignores a superseded play() resolution after the user pauses', async () => {
let resolveFirst: (() => void) | undefined
playMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve
}),
)
render(<RadioPlayer />)
fireEvent.click(playButton())
expect(dataState()).toBe('connecting')
fireEvent.click(playButton())
expect(dataState()).toBe('idle')
await act(async () => {
resolveFirst?.()
})
expect(dataState()).toBe('idle')
})
it('keeps a single share trigger in the compact variant', () => {
const { container } = render(<RadioPlayer compact />)
expect(container.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
expect(
screen.getAllByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }),
).toHaveLength(1)
expect(container.querySelector('[data-radio] iframe')).toBeNull()
})
it('shows the literal message and the two exits in the share popover', async () => {
render(<RadioPlayer />)
const [trigger] = screen.getAllByRole('button', {
name: 'Compartilhar Rádio Jorge Solla 1313',
})
fireEvent.click(trigger!)
expect(await screen.findByText(RADIO_SHARE_MESSAGE)).toBeDefined()
// The portal escapes the home's theme wrapper: the content has to carry
// `data-theme` or the campaign tokens render the CTA invisible.
expect(
document.querySelector('[data-slot="popover-content"]')?.getAttribute('data-theme'),
).toBe('campaign-site')
const whatsApp = screen.getByRole('link', { name: 'Compartilhar no WhatsApp' })
expect(whatsApp.getAttribute('href')).toMatch(/^https:\/\/wa\.me\/\?text=/)
expect(new URL(whatsApp.getAttribute('href')!).searchParams.get('text')).toBe(
RADIO_SHARE_MESSAGE,
)
expect(whatsApp.getAttribute('target')).toBe('_blank')
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
})
})