fix(C181): player do acervo volta a ter o YouTube in-page e espera a Câmara gerar o trecho

- restaura o embed do YouTube como superfície opt-in no SpeechDetailPlayer
  (a Câmara segue a entrada padrão; o embed nunca é a porta automática, guardrail C178)
- controle simétrico "Assistir no YouTube"/"Assistir na Câmara" no bloco de saída,
  com o ponto da fala preservado na troca (pendingSeekRef/youtubeStart)
- SPEECH_VOD_PLAYER_POLICY passa a pollAttempts: 2 / pollDelayMs: 3000 — espera
  bounded dentro do clique em vez de tratar o primeiro GERANDO como falha
- unit do resolver (pin + GERANDO→PRONTO) e do player (ida-e-volta + ponto);
  pin SSR do e2e mantendo o embed fora do HTML inicial

Closes #1103
This commit is contained in:
Francisco Solla 2026-09-16 20:39:22 -03:00
parent dd791f6016
commit 13bfa4b016
7 changed files with 339 additions and 119 deletions

View file

@ -0,0 +1 @@
**C181 — Acervo: o player volta a ter o YouTube in-page e espera a Câmara gerar o trecho (2026-09-16):** o aviso do dono era um defeito de duas cabeças na mesma fala. Primeira: depois do C178 a Câmara virou o caminho padrão, mas quem preferia o YouTube ficava sem volta na própria página (só o link externo "Abrir no YouTube", que sai da página). Segunda: o clique do player usava `SPEECH_VOD_PLAYER_POLICY` com `pollAttempts: 0`, então o primeiro `GERANDO` da Câmara já virava o estado "gerando" com "Tentar novamente" — a geração era curta e o retry "funcionava", provando que o app desistia cedo. A correção é pequena e simétrica. No `SpeechDetailPlayer`, o `<iframe>` do YouTube (`buildYoutubeSrc`, `playsinline=1&rel=0&start=<s>`), o estado `surface: 'camara' | 'youtube'`, `youtubeStart` e `pendingSeekRef` voltam **com a entrada padrão invertida para a Câmara** (guardrail do C178: o embed nunca é a porta de entrada automática, só a superfície de retorno sob clique); o bloco de saída ganha o controle simétrico "Assistir no YouTube" (Câmara→embed, no ponto da frase tocada) e "Assistir na Câmara" (embed→Câmara, preservando o ponto via `pendingSeekRef`), mantendo o link externo "Abrir no YouTube" e a `YoutubeFacade` do C178 para falas só-YouTube (evita recriar a parede de signin do P1/#1081). `seekTo` volta a mover a superfície YouTube e `inlineNotice` volta a mostrar o estado da Câmara enquanto o embed está ativo. No `speechVodResolver`, `SPEECH_VOD_PLAYER_POLICY` passa a `pollAttempts: 2`, `pollDelayMs: 3_000` — um orçamento próprio do clique (~6 s, entre o imediato antigo e os 45 s do corte), reusando o loop `resolveFromVodApi` já testado; estourou o orçamento, o estado honesto e o retry permanecem. Sem `Consent`, collection, migration, URL pública nova ou escrita; gate do acervo e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados. Testes: unit do resolver (pin da política + `GERANDO`→`PRONTO` sob a política do player + `GERANDO` bounded sem probe) e unit RTL do player (troca Câmara↔embed e ponto da transcrição preservado na volta); e2e browserless do acervo ganha o pin SSR do controle de volta (`data-slot="speech-youtube-exit-embed"`) mantendo a asserção de que o embed nunca é server-rendered.

View file

@ -1,6 +1,6 @@
# Impl: Acervo: o player sempre deixa voltar ao YouTube e espera a Câmara gerar o trecho
Status: rascunho
Status: em execução (GATE aprovado em 2026-09-16)
Atualizado em: 2026-09-16
Issue: #1103
Intenção: docs/plans/acervo-player-volta-ao-youtube-e-vod-com-espera.md
@ -8,124 +8,134 @@ Appetite restante: ~0,5–1 dia eng (herdado da intenção); nada mais entra.
## Leitura da intenção (outcome, não negociar, reavaliar)
- **Outcome:** trocar de superfície nos dois sentidos (Câmara ↔ YouTube) na mesma página, com ponto da fala preservado; esperar geração da Câmara dentro do clique do player (bounded) em vez de devolver "gerando" na primeira resposta.
- **O que NÃO negociar:** nada de autenticar no YouTube nem embed novo; não inventar estado; superfície que o app controla segue preferida (C178); transcrição/posicionamento e seleção/corte intactos; sem Consent/collection/migration; URLs públicas do acervo intocadas; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido.
- **O que reavaliar:** hipóteses de "Direção no codebase" que podem estar erradas.
- **Outcome:** trocar de superfície nos dois sentidos na própria página (Câmara ↔ YouTube), com o ponto da fala preservado ao voltar; esperar a geração da Câmara dentro do clique do player (bounded) em vez de devolver "gerando" na primeira resposta.
- **O que NÃO negociar:** não inventar estado; **a superfície que o app controla segue preferida/entrada padrão** (guardrail C178); transcrição/posicionamento e seleção/corte intactos; sem `Consent`/collection/migration; URLs públicas do acervo intocadas; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido.
- **Decisão de produto (GATE, 2026-09-16):** o humano decidiu **restaurar o embed do YouTube**. A remoção do embed pelo C178 não era a intenção do dono. Esta entrega passa a ser **dona** da reversão da parte "embed removido" do C178 — reabre explicitamente a decisão anterior (precedente do AGENTS: a entrega que muda o contrato o possui).
- **O que preservar do C178:** a **entrada padrão continua sendo a Câmara** quando há trecho (`vodResolvable`); o embed volta como **superfície navegável** (a "volta"), nunca como porta de entrada automática — é isso que evita recriar a parede de signin que originou o C178 (P1/#1081). Falas só-YouTube seguem na capa do C178.
## Abordagem recomendada
```mermaid
flowchart LR
U[clica em "Assistir na Câmara"] --> R[resolveSpeechVod com política do player<br/>pollAttempts: 2, pollDelayMs: 3000]
U[clica em "Assistir o trecho"] --> R[resolveSpeechVod com política do player<br/>pollAttempts: 2, pollDelayMs: 3000]
R --> G{resposta da Câmara?}
G -- PRONTO --> P[<video> toca o trecho]
G -- GERANDO --> W[espera bounded<br/>polls curtos]
G -- PRONTO --> P[<video> toca o trecho<br/>superfície Câmara]
G -- GERANDO --> W[espera bounded<br/>2 polls de 3s]
W --> G
G -- INDISPONIVEL/estourou --> F[estado honesto + "Tentar novamente"]
P --> E[bloco de saída "Assistir no YouTube" sempre visível]
F --> E
P --> E[bloco de saída]
E -- "Assistir no YouTube" --> Y[superfície YouTube: iframe embed no ponto]
Y -- "Assistir na Câmara" --> P
Y -.-> X["Abrir no YouTube" externo, no ponto]
```
**Opções consideradas:**
**Opções consideradas (cabeça 2 — espera da Câmara):**
- **A — Trocar `pollAttempts: 0` por `pollAttempts: 2` com `pollDelayMs: 3000`** no `SPEECH_VOD_PLAYER_POLICY` (reusar a lógica de polling já existente no `resolveFromVodApi`).
- **B — Criar uma política nova `SPEECH_VOD_PLAYER_POLLING_POLICY`** com valores próprios.
- **C — Manter `pollAttempts: 0` e só melhorar a copy.**
- **A —** ajustar `SPEECH_VOD_PLAYER_POLICY` (`pollAttempts: 0 → 2`, `pollDelayMs: 1_000 → 3_000`), reusando o loop já existente em `resolveFromVodApi`.
- **B —** política nova só para o clique.
- **C —** manter `pollAttempts: 0` e só melhorar a copy.
**Recomendação: A** — a lógica de polling já existe e é testada; basta ajustar os parâmetros da política do player. **Rejeitadas:** B (complexidade desnecessária; uma política por caller é suficiente), C (não resolve o defeito).
**Recomendação: A** — o loop de polling já existe, é testado e é o dono da espera bounded; só os parâmetros mudam. **Rejeitadas:** B (uma política por caller já basta), C (não resolve — é o defeito).
**Opções consideradas (cabeça 1 — volta ao YouTube):** A) bloco externo já entregue | B) capa do C178 in-page | C) **restaurar o embed do YouTube in-page**.
**Recomendação: C (decidida pelo humano)** — o dono quer o embed de volta; a superfície YouTube é o iframe `https://www.youtube.com/embed/<id>?playsinline=1&rel=0&start=<s>` (o contrato literal que a intenção manda preservar), restaurado do pré-C178 (`git show b378e1a9^:src/components/campaign/speech/SpeechDetailPlayer.tsx`) com a entrada padrão invertida para a Câmara. **Rejeitadas:** A/B (não devolvem o embed).
### Componentes / mudanças
- **`src/utilities/speech/speechVodResolver.ts`** (editar o dono):
- Trocar `SPEECH_VOD_PLAYER_POLICY.pollAttempts` de `0` para `2`.
- Trocar `SPEECH_VOD_PLAYER_POLICY.pollDelayMs` de `1_000` para `3_000`.
- Justificativa: 2 polls de 3s = até 6s de espera adicional (total ~9s com o primeiro fetch); é um orçamento curto e próprio do clique, entre o imediato de hoje e o do corte (45s/2 polls de 5s).
- **`src/utilities/speech/speechVodResolver.ts`** (dono da política):
- `SPEECH_VOD_PLAYER_POLICY.pollAttempts: 0 → 2`, `pollDelayMs: 1_000 → 3_000`. Orçamento ~6s adicionais (1 fetch + 2 polls), bounded; `statusTimeoutMs: 15s` por leitura.
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (editar o dono):
- O bloco `renderYoutubeExit()` já existe e é sempre visível (C178); não precisa de mudança.
- O estado `generating` já mostra "A Câmara está gerando o trecho" e "Tentar novamente"; com o polling ativo, o player vai transitar automaticamente de `generating` para `resolved` (ou `failed` se estourar o orçamento).
- **Nenhuma mudança de UI necessária** — o defeito é na política de polling, não no componente.
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (dono da superfície) — restaurar do pré-C178 com a entrada padrão da Câmara:
- Voltar `buildYoutubeSrc(videoId, startSeconds)` (iframe `playsinline=1&rel=0[&start=N]`).
- Voltar o estado `surface: 'camara' | 'youtube'` com **default `'camara'` quando `vodResolvable`** (e `'youtube'` só quando não há trecho), invertendo o default pré-C178.
- `renderMedia`: quando `surface === 'youtube'` e há id, renderizar o `<iframe>` (título "Vídeo da sessão no YouTube", `loading="lazy"`, allow list do pré-C178); caso contrário o comportamento Câmara atual.
- `youtubeStart` (offset da sessão + ponto) e `pendingSeekRef` (C171-F1) restaurados: o ponto clicado na transcrição sobrevive à troca de superfície até o `<video>` montar e seekar; `seekTo` volta a tratar a superfície YouTube.
- Bloco de saída (`renderYoutubeExit`): manter o layout C178 e o link externo "Abrir no YouTube" (no ponto); adicionar o controle simétrico **"Assistir no YouTube"** (troca para a superfície embed) quando a superfície ativa é a Câmara e há id; **"Assistir na Câmara"** quando a superfície é o embed e `vodResolvable` (restaura o `<video>`/painel no ponto). Copiar o padrão de botão já usado no bloco.
- `inlineNotice` do pré-C178 restaurado: estado `generating`/`failed`/download-não-verificado visível enquanto o embed está ativo.
- Falas só-YouTube (`!vodResolvable`) seguem a `YoutubeFacade` do C178 (capa externa) — **não** entram em embed automático (evita a P1 de signin).
- `seekable` volta a considerar a superfície YouTube (offset conhecido).
- **`src/app/(campaign)/campanha/actions/speech.ts`** (não mexer):
- `resolveSpeechVodForActor` já usa `resolveSpeechVod(coordinates)` que por padrão usa `SPEECH_VOD_PLAYER_POLICY`; a mudança na política surte efeito automaticamente.
- **`src/app/(campaign)/campanha/actions/speech.ts`** (não mexer): herda a política do player.
- **`src/lib/speechVod.ts`** (não mexer): `resolveFromVodApi` já suporta `pollAttempts > 0`.
- **`src/lib/speechShare.ts`** (não mexer): `buildSpeechExcerptYoutubeUrl` já entrega o ponto.
- **Migration:** **sem migration** — constantes e UI, não schema.
- **Access / Consent:** sem mudança (`canReadSpeechCatalog` + `overrideAccess: false` nas leituras).
- **UI:** Impeccable A — o embed e o bloco de saída já foram desenhados (pré-C178/C178); a composição nova é o switch simétrico dentro do bloco já existente. Sem novo arquivo de design; reusa o markup removido. Se a crítica final (trigger c) reprovar a composição, escalar ao `designer`.
- **`src/lib/speechVod.ts`** (não mexer):
- A função `resolveFromVodApi` já suporta `pollAttempts > 0`; não precisa de mudança.
### Dados → forma
- **Migration:** **sem migration** — mudança de constante em código, não de schema.
- **Access / Consent:** sem mudança.
- **UI:** **Impeccable A — N/A** (sem UI nova; o bloco de saída já existe e é sempre visível).
- **Testes:**
- `tests/unit/speechVodResolver.unit.spec.ts`: atualizar o pin de `SPEECH_VOD_PLAYER_POLICY` (linha 164-171) para refletir os novos valores.
- `tests/unit/speechDetailPlayer.unit.spec.tsx`: adicionar teste para o estado `generating` com polling ativo (mockar fetch para retornar `GERANDO` na primeira chamada e `PRONTO` na segunda; verificar que o player transita para `resolved`).
- `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`: o teste existente "search keeps the download off the card; the both-sources detail defaults to the Câmara excerpt" já cobre o estado idle; não precisa de mudança.
- **Changelog:** `docs/changelog/2026-09-16-c181.md`.
### Dados → forma (a resposta é a superfície)
- **Forma escolhida:** a mudança é transparente para o usuário; o player agora espera a geração da Câmara antes de anunciar "gerando/falhou".
- **Rejeitadas:** UI nova (anti-goal), embed do YouTube (anti-goal), autenticação (anti-goal).
- **Forma:** feedback de ação no player; a superfície ativa é `useState`, nunca persistido. Nenhum KPI/gráfico.
- **Rejeitadas:** UI nova fora do bloco de saída; persistir a superfície; autenticar no YouTube.
## Decisões de engenharia
### D1 — Política de polling do player: reusar a lógica existente com parâmetros próprios
### D1 — Política de polling do player (cabeça 2)
- **Opções:** A) ajustar `SPEECH_VOD_PLAYER_POLICY` (pollAttempts: 2, pollDelayMs: 3000) | B) criar política nova | C) manter pollAttempts: 0.
- **Recomendação: A** — reusa código testado, cumpre o orçamento curto do clique, não inventa estado.
- **Rejeitadas:** B (complexidade desnecessária), C (não resolve o defeito).
- **Opções:** A) ajustar `SPEECH_VOD_PLAYER_POLICY` (2 polls de 3s) | B) política nova | C) manter `pollAttempts: 0`.
- **Recomendação: A** — reusa `resolveFromVodApi` testado, orçamento curto e bounded, sem inventar estado.
- **Rejeitadas:** B (twin para <3 call sites), C (não resolve).
### D2 — Visibilidade do bloco de saída "Assistir no YouTube"
### D2 — Restauração do embed e default da superfície (cabeça 1)
- **Opções:** A) manter como está (sempre visível quando youtubeWatchUrl existe) | B) mostrar só quando a superfície for `vod` e a mídia estiver resolvida.
- **Recomendação: A** — a saída é justamente o que falta hoje; nunca deixar o usuário sem caminho.
- **Rejeitadas:** B (restringe a saída no momento em que mais se precisa dela).
- **Opções:** A) bloco externo já entregue | B) capa C178 in-page | C) restaurar o embed (default Câmara).
- **Recomendação: C** — decisão do humano no GATE; restaura o contrato de embed que a intenção manda preservar e mantém a Câmara como entrada padrão (guardrail C178).
- **Rejeitadas:** A/B (não devolvem o embed). O embed **não** vira default em fala só-YouTube (recriaria a parede de signin do P1).
### D3 — Localização do botão "Assistir no YouTube"
### D3 — Localização do controle simétrico
- **Opções:** A) no bloco de saída já existente (`renderYoutubeExit`) | B) junto das ações do player.
- **Recomendação: A** — simétrico a "Assistir na Câmara", mesmo bloco, mesmo desenho.
- **Rejeitadas:** B (desconecta a saída do contexto de "outro caminho").
- **Recomendação: A** — simétrico ao bloco "assista por outro caminho", mesmo desenho; foi onde o pré-C178 colocou "Assistir na Câmara".
- **Rejeitadas:** B (desconecta a saída do contexto).
## Renegociação de pins (obrigatória — mesma entrega)
- **`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`** (SSR, sem interação):
- `not.toContain('youtube.com/embed')` e `not.toContain('<iframe')` **seguem válidos** no default Câmara (o embed só monta após o clique) — manter como regressão de que a entrada não é o embed.
- Ajustar a asserção "old surface-switch button is gone": o bloco de saída passa a oferecer "Assistir no YouTube" (superfície Câmara). Adicionar interação (ou teste client/unit) cobrindo a ida Câmara→embed→Câmara com o ponto preservado.
- Falas só-YouTube: `not.toContain('youtube.com/embed')`/`<iframe`/`Assistir na Câmara` seguem válidos (facade).
- **`tests/unit/speechDetailPlayer.unit.spec.tsx`**: restaurar os testes de troca de superfície do pré-C178 (`git show b378e1a9^:tests/unit/speechDetailPlayer.unit.spec.tsx`) adaptados ao novo default Câmara; manter os testes de quadrantes do C178.
- **`tests/unit/speechVodResolver.unit.spec.ts`**: atualizar o pin de `SPEECH_VOD_PLAYER_POLICY` (linhas 163–171) para os novos valores; adicionar caso `GERANDO → PRONTO` sob a política do player.
## Fases verificáveis
1. **Ajuste da política (~0,1 dia):** editar `SPEECH_VOD_PLAYER_POLICY` em `src/utilities/speech/speechVodResolver.ts` (pollAttempts: 2, pollDelayMs: 3000). Prova: `pnpm test:unit` verde (pin atualizado no spec).
2. **Teste do polling no player (~0,25 dia):** adicionar teste em `tests/unit/speechDetailPlayer.unit.spec.tsx` que mockia fetch para retornar GERANDO → PRONTO e verifica a transição. Prova: spec novo verde.
3. **Verificação e2e (~0,1 dia):** rodar `pnpm test:e2e --grep "acervo"` para garantir que nenhum quadrante quebrou. Prova: e2e verde.
4. **Changelog e lint (~0,05 dia):** criar `docs/changelog/2026-09-16-c181.md`, rodar `pnpm lint` e `pnpm format:check`.
1. **Política de polling (~0,1 dia):** `SPEECH_VOD_PLAYER_POLICY` + pin/spec. Prova: `pnpm test:unit` verde.
2. **Restauração do embed + switch (~0,5 dia):** markup pré-C178, default Câmara, controle simétrico no bloco de saída, `pendingSeekRef`. Prova: unit do player (ida-e-volta com ponto preservado) e e2e renegociado.
3. **Teste do polling no player (~0,2 dia):** unit GERANDO → PRONTO resolve sozinho.
4. **Gates (~0,1 dia):** `pnpm gate:fast`; changelog `docs/changelog/2026-09-16-c181.md`; `pnpm push`.
5. **Fechamento:** crítica visual do `designer` (trigger c) no diff que muda UI antes do push.
**Quota total:** ~0,5 dia (dentro do appetite herdado).
**Quota:** ~0,8–1,0 dia (no topo do appetite herdado).
## Rabbit holes / Não escopo (engenharia)
- **Trocar a superfície do YouTube para embed.** Corte: a volta é um botão na nossa própria página; o YouTube continua embed anônimo.
- **Espera infinita / fila de render.** Corte: orçamento curto dentro do clique; estourou → estado honesto + retry.
- **"Retry resolve, então é só mandar tentar".** Corte: isso é o defeito, não a solução — o app espera antes de acusar falha.
- **Redesenhar o player inteiro.** Corte: encaixe cirúrgico; transcrição, seleção e corte intactos.
- **Autenticar no YouTube ou usar embed novo.** Corte: anti-goal explícito na intenção.
- **Inventar estado ("sucesso" sem verificação de mídia).** Corte: a política de probing já verifica a mídia antes de devolver `pronto`.
- **Mudar o bloco de saída "Assistir no YouTube".** Corte: já existe e é sempre visível (C178); não precisa de mudança.
- **Tornar o embed a entrada padrão de novo.** Corte: default segue Câmara (guardrail C178); o embed é opt-in.
- **Fazer o YouTube tocar quando ele bloqueia / OAuth / player próprio.** Corte: fora de escopo (intenção).
- **Embed automático em falas só-YouTube.** Corte: mantém a facade do C178 (P1 de signin).
- **Espera infinita / fila de render.** Corte: orçamento bounded; estourou → estado honesto + retry.
- **Redesenhar o player inteiro.** Corte: restauração cirúrgica; transcrição, seleção e corte intactos.
- **Inventar estado ("sucesso" sem verificação).** Corte: o probing de mídia decide o `pronto`.
- **UI fora do bloco de saída.** Corte: simetria no bloco já desenhado.
## Riscos e mitigação
- **A Câmara demora mais que 9s para gerar o trecho:** o player entra no estado `generating` e mostra "Tentar novamente" (estado honesto); o usuário pode clicar retry manualmente. **Mitigação:** o orçamento de 9s cobre a maioria dos casos; se a Câmara ficar lenta frequentemente, reavaliar o `pollAttempts`.
- **Regressão nos testes existentes:** o pin de `SPEECH_VOD_PLAYER_POLICY` no spec precisa ser atualizado. **Mitigação:** atualizar o pin antes de rodar os testes.
- **Mudança de comportamento para usuários existentes:** o player agora espera 9s em vez de falhar imediatamente; isso é uma melhoria, não um defeito. **Mitigação:** nenhum usuário vai reclamar de "esperou demais" quando antes falhava de primeira.
- **Regressão do P1 (parede de signin):** o embed não volta como entrada padrão; só após clique. Mitigação: manter o default Câmara e os pins SSR `not.toContain('youtube.com/embed')`/`<iframe`.
- **E2E pina o oposto:** renegociar os pins na mesma entrega, sem reintroduzir embed server-rendered. Mitigação: teste de interação para a troca.
- **Ponto perdido na troca de superfície:** restaurar `pendingSeekRef` (C171-F1) e o `youtubeStart`. Mitigação: unit cobrindo o seek após voltar.
- **Estouro do orçamento da Câmara:** estado `generating` honesto + retry. Mitigação: reavaliar `pollAttempts` se crônico.
- **Pin de `SPEECH_VOD_PLAYER_POLICY`:** atualizar antes do push.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: o player sempre deixa voltar ao YouTube (bloco de saída sempre visível) e espera a geração da Câmara dentro do clique (pollAttempts: 2, pollDelayMs: 3000).
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` em toda leitura; gate `canReadSpeechCatalog`; sem migration/Consent; sem UI nova; identificadores em inglês, chaves/copy em pt-BR.
- [ ] Testes previstos: unit `speechVodResolver` (pin atualizado) + `speechDetailPlayer` (transição generating→resolved) + e2e `campaignSpeechAcervo` (quadrantes intactos); `pnpm lint` e `pnpm format:check` verdes.
- [ ] Volta ao YouTube **embutida** na própria página, preservando o ponto, com a Câmara como entrada padrão.
- [ ] Espera bounded da Câmara dentro do clique (2 polls de 3s); estourou → estado honesto + retry.
- [ ] Invariantes AGENTS: `overrideAccess: false` + `user`; gate `canReadSpeechCatalog`; sem migration/`Consent`; identificadores em inglês, copy em pt-BR.
- [ ] Testes: unit resolver (pin + GERANDO→PRONTO), unit player (ida-e-volta + polling), e2e acervo renegociado.
- [ ] Changelog: `docs/changelog/2026-09-16-c181.md`.
Self-score decision-quality: 5/5 — (1) fatia = um outcome verificável (volta simétrica ao YouTube + espera bounded na geração); (2) appetite ~0,5 dia comporta o ajuste de política e os testes, sem schema; (3) persona/job/aceite em linguagem de produto; (4) direção no codebase é precisa (arquivos nomeados, política existente, bloco de saída já implementado); (5) zero decisão dura de engenharia (política e UI já existem; só ajuste de parâmetros).
Self-score decision-quality: 4/5 — decisões caras com rejeitadas (D1/D2/D3); cabe no appetite; rabbit holes nomeados; depth check reusa `resolveFromVodApi` + `YoutubeFacade` + bloco de saída + markup pré-C178; a renegociação de pins do C178 e o escopo do embed (só com trecho, opt-in) são o que impede o 5/5 sem confirmação explícita no GATE.
- Decisões caras com rejeitadas: 3/3 — D1 (política), D2 (visibilidade da saída), D3 (localização do botão) no formato Opções|Recomendação|Rejeitadas.
- Cabe no appetite? 5/5 — ~0,5 dia (dentro do herdado); sem migration, sem UI, sem dependência nova.
- Rabbit holes nomeados? 5/5 — embed do YouTube, espera infinita, retry como solução, redesign do player, autenticação, inventar estado, mudança de UI.
- Depth check reusa? 5/5 — `resolveFromVodApi` já suporta polling, `renderYoutubeExit` já existe e é sempre visível, `speechVodResolver` já exporta a política, `speechDetailPlayer` já tem o estado `generating`.
- Intenção permanece satisfeita? 5/5 — o outcome não foi reescrito: a volta ao YouTube é simétrica (bloco de saída sempre visível) e a espera é bounded (pollAttempts: 2, pollDelayMs: 3000).
## Registro de execução
- **GATE (humano):** aprovado em 2026-09-16; decisão de produto: restaurar o embed (opção 1-C), com a Câmara como entrada padrão.
- **Simplify (2 revisores):** sem blockers. Aplicados: `seekTo` da Câmara grava `activeStart` (ponto preservado na troca), `inlineNotice` corrige a condição para `!playbackUrl` e ganha `mt-3`, `YoutubeIcon` sem props mortas, comentários obsoletos (componente/C178, resolver/C162, e2e) atualizados. **Débitos:** os achados restantes são `cheap_polish` (score ≤2) — `buildYoutubeSrc` no componente vs. `speechShare`, extração de `renderSurfaceSwitch`, duplicação de strings do `inlineNotice`, duplicação de testes entre políticas — nenhum `expensive_lock`; não geram Issue.
- **Design (trigger c):** crítica do `designer` (`openai/gpt-5.6-sol`) retornou **`DEGRADED`** — sem screenshots do app renderizado no worktree, não certifica. Observações não-certificadas: (2) a copy "o YouTube abre no ponto da fala" ficou incompleta com o embed ativo; (3) espaçamento do `inlineNotice` (corrigido com `mt-3`); (4) dois controles longos no bloco de saída podem comprimir a copy entre 640–1280px. Exige sign-off humano (ou nova rodada com evidência renderizada) antes do push.

View file

@ -9,6 +9,7 @@ import { SpeechCutResultCard } from '@/components/campaign/speech/SpeechCutResul
import { SpeechExcerptControls } from '@/components/campaign/speech/SpeechExcerptControls'
import { SpeechExcerptShare } from '@/components/campaign/speech/SpeechExcerptShare'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { YoutubeIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
@ -30,6 +31,15 @@ import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechView
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
const GENERATING_TITLE = 'A Câmara está gerando o trecho deste vídeo.'
/** C181 — the pre-C178 embed contract, preserved for the in-page YouTube surface. */
const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string => {
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
if (startSeconds !== null && startSeconds > 0) {
params.set('start', String(Math.floor(startSeconds)))
}
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`
}
type ResolutionState =
| { kind: 'idle' }
| { kind: 'resolving' }
@ -147,8 +157,10 @@ const YoutubeFacade = ({ videoId, href }: { videoId: string; href: string }) =>
* default surface when the speech has one (resolved on click: `video-sob-demanda`,
* exact `excerptTMs`, links probed before use), a clickable YouTube cover when it
* only has a session link, and honest states when neither path has a playable
* file. The YouTube embed is gone — it is the external exit, never the entry.
* Transcript clicks seek the Câmara surface (native seconds on the MP4).
* file. C181 — the YouTube embed returns as the opt-in in-page surface behind
* "Assistir no YouTube" (never the automatic entry, so the C178 signin wall is
* not re-created); transcript clicks seek the active surface (native seconds on
* the MP4, session-offset seconds on the embed).
*/
export const SpeechDetailPlayer = ({
speechId,
@ -164,6 +176,9 @@ export const SpeechDetailPlayer = ({
speechSummary,
}: SpeechDetailPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
// C181 (C171-F1) — a transcript point clicked while the embed is active has to
// survive until the Câmara `<video>` mounts and takes the first seek.
const pendingSeekRef = useRef<number | null>(null)
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
const [activeStart, setActiveStart] = useState<number | null>(null)
// C166: one state for the explicit selection mode — a non-null range means
@ -173,9 +188,20 @@ export const SpeechDetailPlayer = ({
// in session so the share kit stays visible after the dialog closes.
const [cutDialogOpen, setCutDialogOpen] = useState(false)
const [publishedCut, setPublishedCut] = useState<SpeechCutViewModel | null>(null)
// C181 — the embed's seek point survives the surface switch (session offset +
// the excerpt start), so "Assistir no YouTube" opens at the right second.
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
youtubeVideoId && youtubeOffsetSeconds !== null
? youtubeOffsetSeconds + (initialSeconds ?? 0)
: null,
)
// C181 — one player surface at a time, the Câmara kept as the entry (C178):
// the YouTube embed is the way back, switched on click, never the default.
const [surface, setSurface] = useState<'youtube' | 'vod'>('vod')
const resolving = resolution.kind === 'resolving'
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
const youtubeSurface = surface === 'youtube' && Boolean(youtubeVideoId)
const youtubeWatchUrl = youtubeVideoId
? buildSpeechExcerptYoutubeUrl(
youtubeVideoId,
@ -196,15 +222,18 @@ export const SpeechDetailPlayer = ({
officialTextUrl ? 'abrir o Diário Oficial' : null,
].filter((exit): exit is string => exit !== null)
// The deep link (`?t=`) seeks the already verified file on mount.
// C181 — the pending transcript point wins over the ?t deep link; the surface
// switch is a dep so the already verified file seeks when it mounts.
useEffect(() => {
const video = videoRef.current
if (!video) return
if (initialSeconds === null || initialSeconds <= 0) return
const target = pendingSeekRef.current ?? initialSeconds
if (target === null || target <= 0) return
const seek = () => {
video.currentTime = initialSeconds
video.currentTime = target
pendingSeekRef.current = null
}
if (video.readyState >= 1) {
seek()
@ -212,7 +241,7 @@ export const SpeechDetailPlayer = ({
}
video.addEventListener('loadedmetadata', seek, { once: true })
return () => video.removeEventListener('loadedmetadata', seek)
}, [initialSeconds, playbackUrl])
}, [initialSeconds, playbackUrl, surface])
const requestResolution = async (deliverDownload: boolean) => {
// Opened synchronously inside the click gesture so the popup blocker sees
@ -249,10 +278,37 @@ export const SpeechDetailPlayer = ({
}
}
// C181 — the symmetric way back: "Assistir na Câmara" switches the surface and
// asks the Câmara only when there is nothing verified yet and no request in flight.
const watchVod = () => {
setSurface('vod')
if (playbackUrl || resolving || resolution.kind === 'generating') return
void requestResolution(false)
}
// C181 — "Assistir no YouTube" switches back to the embed, at the phrase being
// played (or the deep link). It never touches the Câmara request.
const watchYoutube = () => {
if (youtubeOffsetSeconds !== null) {
setYoutubeStart(youtubeOffsetSeconds + (activeStart ?? initialSeconds ?? 0))
}
setSurface('youtube')
}
const seekTo = (seconds: number) => {
if (youtubeSurface) {
if (youtubeOffsetSeconds === null) return
pendingSeekRef.current = seconds
setYoutubeStart(youtubeOffsetSeconds + seconds)
setActiveStart(seconds)
return
}
const video = videoRef.current
if (!video) return
video.currentTime = seconds
// Track the point here too: switching to the embed reads `activeStart`, and
// playback can be blocked before a `timeupdate` lands.
setActiveStart(seconds)
void video.play().catch(() => {})
}
@ -300,9 +356,21 @@ export const SpeechDetailPlayer = ({
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
}
const seekable = Boolean(playbackUrl)
const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const renderMedia = (): ReactNode => {
if (youtubeSurface && youtubeVideoId) {
return (
<iframe
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
title="Vídeo da sessão no YouTube"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="aspect-video w-full rounded-lg border bg-black"
/>
)
}
if (playbackUrl) {
return (
<video
@ -386,6 +454,25 @@ export const SpeechDetailPlayer = ({
)
}
// C181 — while the embed is the active surface the Câmara state stays visible
// (the app never sees the iframe's inner state, so the honest notice is ours).
const inlineNotice = youtubeSurface ? (
resolution.kind === 'generating' ? (
<p className="mt-3 text-xs text-muted-foreground" role="status" aria-live="polite">
{GENERATING_TITLE} Tente novamente em um momento.
</p>
) : resolution.kind === 'failed' ? (
<p className="mt-3 text-xs text-destructive" role="status" aria-live="polite">
{resolution.message ??
'Não foi possível resolver o arquivo deste trecho na Câmara. Tente novamente.'}
</p>
) : resolution.kind === 'resolved' && !resolution.playbackUrl ? (
<p className="mt-3 text-xs text-muted-foreground" role="status" aria-live="polite">
O arquivo deste trecho não pôde ser verificado na Câmara agora.
</p>
) : null
) : null
// C178 — the exit block stays reachable in every state: the app never sees the
// reason the video cannot play, so the way out cannot depend on detecting it.
// The Câmara panel owns the primary CTA while it is idle ("Assistir o trecho");
@ -416,17 +503,44 @@ export const SpeechDetailPlayer = ({
</p>
)}
</div>
<Button asChild variant={exitVariant} className="min-h-10 w-full sm:w-auto">
<a
href={youtubeWatchUrl}
target="_blank"
rel="noreferrer"
data-slot="speech-youtube-exit-link"
>
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir no YouTube
</a>
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
{vodResolvable && youtubeVideoId ? (
youtubeSurface ? (
<Button
type="button"
variant="outline"
className="min-h-10 w-full sm:w-auto"
data-slot="speech-youtube-exit-camera"
onClick={watchVod}
>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Assistir na Câmara
</Button>
) : (
<Button
type="button"
variant="outline"
className="min-h-10 w-full sm:w-auto"
data-slot="speech-youtube-exit-embed"
onClick={watchYoutube}
>
<YoutubeIcon />
Assistir no YouTube
</Button>
)
) : null}
<Button asChild variant={exitVariant} className="min-h-10 w-full sm:w-auto">
<a
href={youtubeWatchUrl}
target="_blank"
rel="noreferrer"
data-slot="speech-youtube-exit-link"
>
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir no YouTube
</a>
</Button>
</div>
</div>
</div>
)
@ -517,6 +631,8 @@ export const SpeechDetailPlayer = ({
) : null}
</div>
{inlineNotice}
{publishedCut ? <SpeechCutResultCard cut={publishedCut} /> : null}
{selection ? (

View file

@ -15,10 +15,11 @@ import {
* owns the wait budget through a `SpeechVodPolicy`, and the media verification
* (not the policy) decides what may reach the browser.
*
* The player keeps the bounded click of C162 (`SPEECH_VOD_PLAYER_POLICY`); the
* cut job (C169) needs the measured first-request window (~30s, then short
* polls) plus the stored links as a last verified candidate, and passes
* `SPEECH_VOD_CUT_POLICY` + `cachedUrls`.
* The player keeps a short click budget of its own (`SPEECH_VOD_PLAYER_POLICY`;
* C181 widened it to poll a transcode in progress) and stops before the cut
* job's window; the cut job (C169) needs the measured first-request window
* (~30s, then short polls) plus the stored links as a last verified candidate,
* and passes `SPEECH_VOD_CUT_POLICY` + `cachedUrls`.
*/
export type SpeechVodPolicy = {
@ -34,13 +35,19 @@ export type SpeechVodPolicy = {
pollDelayMs: number
}
/** C162's player click: short, no poll, immediate answer. */
/**
* C181's player click: a short, own budget that waits out the Câmara's
* transcoding instead of treating the first `GERANDO` as a failure — 1 read +
* 2 polls of 3 s (~6 s extra), between the old immediate answer and the cut
* job's budget (a 45 s read timeout plus 2 polls of 5 s). The wait stays
* bounded; past it the honest "gerando" state and the retry remain.
*/
export const SPEECH_VOD_PLAYER_POLICY: SpeechVodPolicy = {
statusTimeoutMs: 15_000,
statusRetries: 1,
retryDelayMs: 1_000,
pollAttempts: 0,
pollDelayMs: 1_000,
pollAttempts: 2,
pollDelayMs: 3_000,
}
/** C169's cut job: covers the measured ~30s first request and 1–2 short polls. */

View file

@ -11,11 +11,13 @@ import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campa
* the filter URL contract and the detail's player/transcript/actions. The
* player picks the surface from the row coordinates — the Câmara excerpt as the
* default when the speech has one (stored-VOD capa resolved on click), a
* clickable YouTube cover when it only has a session link (C178: YouTube is the
* external exit, never an embedded entry) — and honest states when neither
* exists — and the stored VOD link itself is never rendered (C162). Playback is
* browser territory and is not asserted here; the HTML carries the contract
* (`<video src>` only after a click, `data-start-seconds`, the cover href).
* clickable YouTube cover when it only has a session link (C178: YouTube is
* never an embedded entry; C181 restores the embed as the opt-in in-page surface
* behind "Assistir no YouTube", keeping the Câmara as the default entry) — and
* honest states when neither exists — and the stored VOD link itself is never
* rendered (C162). Playback is browser territory and is not asserted here; the
* HTML carries the contract (`<video src>` only after a click, `data-start-seconds`,
* the cover href; the embed is never server-rendered).
*/
const YOUTUBE_VIDEO_ID = 'lLhRDkSPw0A'
@ -176,11 +178,15 @@ test.describe('communication vertical (C154/C162)', () => {
expect(detailHtml).toContain('O trecho deste vídeo é gerado pela Câmara dos Deputados.')
expect(detailHtml).toContain('Assistir o trecho')
// C178 — the exit block is server-rendered with the Câmara panel: the
// watch URL opens at the deep-linked point (never a signin dead end) and
// the old surface-switch button is gone.
// watch URL opens at the deep-linked point (never a signin dead end), and
// the Câmara surface owns the switch button (C181).
expect(detailHtml).toContain('Se o vídeo não abrir aqui, assista por outro caminho:')
expect(detailHtml).toContain('Abrir no YouTube')
expect(detailHtml).not.toContain('Assistir na Câmara')
// C181 — the way back to the YouTube surface is server-rendered with the
// Câmara default (the embed itself only mounts after the click).
expect(detailHtml).toContain('Assistir no YouTube')
expect(detailHtml).toContain('data-slot="speech-youtube-exit-embed"')
expect(detailHtml).toContain(`watch?v=${YOUTUBE_VIDEO_ID}&amp;t=${EXCERPT_OFFSET_SECONDS + 43}`)
expect(detailHtml).toContain('data-slot="speech-youtube-exit-link"')
expect(detailHtml).toContain('data-start-seconds="0"')

View file

@ -350,6 +350,48 @@ describe('SpeechDetailPlayer — C178 the exit is always one click away', () =>
})
})
describe('SpeechDetailPlayer — C181 the YouTube embed is one click away', () => {
const embedSwitch = () => screen.getByRole('button', { name: /assistir no youtube/i })
const cameraSwitch = () => screen.getByRole('button', { name: /assistir na câmara/i })
it('switches to the embed on click and back to the Câmara excerpt', async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: 100 })
// C178 stays the entry: no iframe until the assessor asks for the YouTube.
expect(iframeElement()).toBeNull()
expect(embedSwitch()).toBeDefined()
fireEvent.click(embedSwitch())
expect(iframeElement()?.getAttribute('src')).toBe(
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2734`,
)
expect(videoElement()).toBeNull()
fireEvent.click(cameraSwitch())
await waitFor(() => expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL))
expect(iframeElement()).toBeNull()
})
it('carries the transcript point back to the Câmara video', async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, initialSeconds: 100 })
fireEvent.click(embedSwitch())
fireEvent.click(segmentButton(43))
expect(iframeElement()?.getAttribute('src')).toBe(
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2677`,
)
fireEvent.click(cameraSwitch())
await waitFor(() => expect(videoElement()).not.toBeNull())
await waitFor(() => {
fireEvent.loadedMetadata(videoElement()!)
expect(videoElement()?.currentTime).toBe(43)
})
})
})
describe('SpeechDetailPlayer — C178 the deep link seeks the Câmara surface', () => {
// The seek effect attaches its `loadedmetadata` listener on the passive
// effect pass, which can land after the video is in the DOM under load;

View file

@ -125,12 +125,21 @@ describe('resolveSpeechVod', () => {
})
})
it('maps GERANDO and INDISPONIVEL without probing anything', async () => {
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'gerando',
})
expect(calls).toHaveLength(1)
it('maps GERANDO across the bounded polls and INDISPONIVEL without probing media', async () => {
vi.useFakeTimers()
try {
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
const assertion = resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })
await vi.advanceTimersByTimeAsync(
SPEECH_VOD_PLAYER_POLICY.pollDelayMs * SPEECH_VOD_PLAYER_POLICY.pollAttempts + 100,
)
await expect(assertion).resolves.toEqual({ state: 'gerando' })
expect(calls).toHaveLength(1 + SPEECH_VOD_PLAYER_POLICY.pollAttempts)
expect(calls.every((call) => call.url.includes('video-sob-demanda'))).toBe(true)
} finally {
vi.useRealTimers()
}
vi.unstubAllGlobals()
const unavailableCalls = stubFetch(async () =>
@ -142,6 +151,35 @@ describe('resolveSpeechVod', () => {
expect(unavailableCalls).toHaveLength(1)
})
it('polls a GERANDO answer until PRONTO instead of failing on the first read', async () => {
vi.useFakeTimers()
try {
let statusReads = 0
const calls = stubFetch(async (url) => {
if (url.includes('video-sob-demanda')) {
statusReads += 1
return statusReads === 1
? jsonResponse({ estado: 'GERANDO', video: null })
: jsonResponse(readyBody)
}
return mediaResponse(206, 'video/mp4')
})
const assertion = resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })
await vi.advanceTimersByTimeAsync(SPEECH_VOD_PLAYER_POLICY.pollDelayMs + 100)
await expect(assertion).resolves.toEqual({
state: 'pronto',
playbackUrl: VOD_PLAYBACK,
downloadUrl: VOD_DOWNLOAD,
})
expect(statusReads).toBe(2)
expect(calls[0]?.url).toContain('trecho=3')
} finally {
vi.useRealTimers()
}
})
it('throws after the bounded retries when the Câmara is unreachable', async () => {
vi.useFakeTimers()
try {
@ -160,13 +198,13 @@ describe('resolveSpeechVod', () => {
}
})
it('keeps the C162 player click pinned to the short policy', () => {
it('keeps the C162 player click pinned to the short policy with its own poll budget', () => {
expect(SPEECH_VOD_PLAYER_POLICY).toEqual({
statusTimeoutMs: 15_000,
statusRetries: 1,
retryDelayMs: 1_000,
pollAttempts: 0,
pollDelayMs: 1_000,
pollAttempts: 2,
pollDelayMs: 3_000,
})
})
})