mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S25): player próprio da Rádio 1313 na home — exclusividade, share e fail-soft (Closes #1239)
This commit is contained in:
parent
3e632df4d0
commit
bb550cebff
17 changed files with 1065 additions and 133 deletions
1
docs/changelog/2026-09-21-s25.md
Normal file
1
docs/changelog/2026-09-21-s25.md
Normal 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.
|
||||
152
docs/plans/radio-player-proprio-impl.md
Normal file
152
docs/plans/radio-player-proprio-impl.md
Normal 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).
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
410
src/components/jingles/RadioPlayer.tsx
Normal file
410
src/components/jingles/RadioPlayer.tsx
Normal 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
36
src/lib/radio.ts
Normal 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)
|
||||
|
|
@ -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',
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
41
tests/unit/radio.unit.spec.ts
Normal file
41
tests/unit/radio.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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]')
|
||||
})
|
||||
})
|
||||
272
tests/unit/radioPlayer.unit.spec.tsx
Normal file
272
tests/unit/radioPlayer.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue