fix(C171): player do acervo toca ou oferece a saída em um clique — nunca beco de signin (Closes #1047)

This commit is contained in:
Francisco Solla 2026-09-15 22:07:55 -03:00
parent 2e389365a3
commit cb0e902eee
6 changed files with 474 additions and 9 deletions

View file

@ -0,0 +1 @@
**C171 — Acervo: o player do YouTube nunca termina num beco de signin (2026-09-15):** o detalhe de uma fala com vídeo no YouTube parava no "faça login para confirmar que você não é um robô" — clicar em signin abria outra janela (onde a pessoa já estava logada) e nada mudava na página. Agora o embed padrão passa a usar `www.youtube.com/embed/<id>` (mesmos `playsinline=1&rel=0&start=`, atacando o bot-check de embeds anônimos do `youtube-nocookie`; a área é interna e autenticada) e, **sempre visível** logo abaixo do player, há saída em um clique: **"Assistir na Câmara"** troca a superfície ativa e resolve o trecho sob demanda no próprio `SpeechDetailPlayer` (reuso integral do C162 — sem segundo player, sem novo POST quando o arquivo já foi verificado, com os estados honestos de resolvendo/gerando/falha) e **"Abrir no YouTube"** abre `watch?v=<id>&t=<segundos>` no ponto (reuso do `buildSpeechExcerptYoutubeUrl` do C166; `t=` omitido quando o offset é desconhecido). O bloco não depende de detectar o bloqueio — o app nunca vê o erro interno do iframe — então a saída está ao alcance também no caminho feliz (hierarquia secundária, apoio só quando a fala tem VOD). A transcrição passa a posicionar a **superfície ativa** (iframe ou `<video>`), o link de saída acompanha o último ponto clicado, e share/corte/download/seleção (C166/C167) seguem intactos nos quatro quadrantes; sem VOD sobra só o link (o apoio some para não prometer uma superfície inexistente). O embed do site público continua `youtube-nocookie` (LGPD); gate do acervo e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados; sem migration, `Consent`, schema ou escrita. Testes: unit RTL do player (saídas nos quadrantes, 1 POST no switch, href no ponto, transcrição na superfície da Câmara, falha mantendo a saída) e e2e browserless do acervo estendido (host novo, bloco de saída no HTML e ausência dele em só-VOD/sem-vídeo). Follow-up registrado: **C171-F1 (#1052)** — a troca para a Câmara perde o ponto da transcrição (clique sobrescrito e deep link ignorado no arquivo já verificado).

View file

@ -0,0 +1,89 @@
# C171-F1 — Player: a troca para a Câmara perde o ponto da transcrição
Status: rascunho
Atualizado em: 2026-09-15
Issue: (registrada na triage do simplify do C171)
Pai: #1047 (C171) — `depends: [1047]`
Priority: P2
Kind: defect
Appetite: ~0,25 dia eng (client-only; sem schema, sem `Consent`, sem escrita, sem rota)
Responsável: —
> Débito registrado na triage do simplify/impeccable do C171
> ([c171-player-youtube-sem-signin-impl.md](c171-player-youtube-sem-signin-impl.md)).
## Contexto
O C171 introduziu a superfície ativa (`surface: 'youtube' | 'vod'`) e o "Assistir na Câmara" no
`SpeechDetailPlayer`. O efeito de `initialSeconds` existe desde o C162 e posiciona o `<video>`
quando o `playbackUrl` passa a existir — dependência `[initialSeconds, playbackUrl]`.
Dois furos no mesmo dono (efeito keyed em `playbackUrl`, não na montagem do `<video>`):
1. **Ponto clicado sobrescrito.** Clique na frase com o embed ativo grava o ponto em `activeStart`
(e recarrega `start=` do iframe). Ao trocar para a Câmara, o `<video>` nasce e o efeito
posiciona em `initialSeconds` (deep link `?t=`) — ou, sem deep link, deixa em 0 — ignorando o
último clique; highlight e `t=` da saída ficam dessincronizados até o primeiro `timeupdate`.
2. **Deep link perdido quando o arquivo já estava verificado.** Se a pessoa baixa o MP4 primeiro
(o `playbackUrl` resolve com a superfície YouTube ativa), o efeito roda sem `<video>` montado
e retorna cedo; ao trocar para a Câmara as deps não mudam e o efeito **não** re-roda — o
`<video>` fica em 0 mesmo com `initialSeconds > 0` (regressão do contrato C162).
## Evidência
- `src/components/campaign/speech/SpeechDetailPlayer.tsx:164-177` — efeito de `initialSeconds`
com deps `[initialSeconds, playbackUrl]` e sem considerar `activeStart`/a montagem do `<video>`.
- `:222-233` — `seekTo` grava `activeStart` no ramo YouTube; `:216-220` — `watchVod` só troca a
superfície quando o arquivo já está verificado (sem novo POST).
- `tests/unit/speechDetailPlayer.unit.spec.tsx` — "plays the Câmara excerpt in place of the embed"
clica a transcrição **depois** de a Câmara resolver (`initialSeconds: 100`); "switches to the
already verified file" usa `initialSeconds: null` — nenhum dos dois cobre os casos acima.
## Objetivo e aceite
- Clique explícito na transcrição feito com a superfície YouTube ativa vence o `initialSeconds`
quando o `<video>` da Câmara monta: `video.currentTime` = último ponto clicado (excerpt-relative).
- Sem clique explícito, o contrato C162/C171 permanece: `initialSeconds` no arquivo recém-resolvido
**e** no já verificado (download primeiro); sem deep link, 0.
- Nenhum seek em loop: `timeupdate` não re-dispara o efeito; sem mudança de copy/layout/quadrantes
sem YouTube, do POST/rota da Câmara e do `t=` da saída (que segue o ponto efetivo).
## Decisão de engenharia
Opções: A) precedência de alvo (`pendingSeekRef` gravado por `seekTo` no ramo YouTube, consumido
quando o `<video>` monta) + deps do efeito incluindo a transição de superfície | B) incluir
`activeStart` direto nas deps do efeito | C) remontar o player na troca de superfície.
**Recomendação: A** — cobre os dois furos (clique vence o deep link; deep link vale mesmo no
caminho já verificado) sem re-disparo a cada `timeupdate`; o ref é do dono (clique), não do estado
de reprodução.
**Rejeitadas:** B porque `activeStart` muda a cada `timeupdate` e o efeito re-seekaria em loop;
C porque descarta a resolução já verificada e força novo fetch (regressão do "sem novo POST").
## Fases verificáveis
1. **Fix (~0,15 dia).** `seekTo` (ramo YouTube) grava o alvo no ref; efeito do `<video>` usa
`ref.current ?? initialSeconds` e passa a rodar na transição para a superfície da Câmara; ref
limpo após o seek.
- Testes (RTL, `tests/unit/speechDetailPlayer.unit.spec.tsx`): clique na frase 43 com embed
ativo → "Assistir na Câmara" → `video.currentTime === 43` (com `initialSeconds: 100` e sem
deep link); arquivo já verificado (download primeiro) + `initialSeconds > 0` sem clique →
`video.currentTime === initialSeconds`; pins C162 existentes sem clique inalterados.
- Verificação: `pnpm test:unit`, `pnpm typecheck`.
2. **Gates (~0,1 dia).** `pnpm gate:fast`; e2e browserless do acervo inalterado (a interação é
dona do jsdom); changelog no fechamento.
## Fora de escopo
- IFrame API/`postMessage` para retomar posição do embed do YouTube — segue cortado (C162/C171).
- Persistir o ponto entre reloads; tocar `speechVodResolver.ts`/rota `resolver-vod`/`actions/speech.ts`
(dono: C169/#1045).
- Mudanças de layout/copy do bloco de saída (o débito cosmético S3 do triage tem gatilho próprio
no plano-pai).
## Riscos
- **Ref obsoleto de um clique antigo:** o alvo só é gravado no ramo YouTube e só é lido quando o
`<video>` monta; teste cobre POST novo e arquivo já verificado.
- **Compat C162:** nenhum pin sem clique muda — rodar a suíte do player inteira.

View file

@ -0,0 +1,122 @@
# Impl: Acervo: o player do YouTube nunca termina num beco de signin — toca ou oferece a saída em um clique
Status: executado
Atualizado em: 2026-09-15
Issue: #1047
Intenção: docs/plans/c171-player-youtube-sem-signin.md
Appetite restante: herdado (~0,5–1 dia eng; sem corte — sem schema, sem `Consent`, sem server novo, sem arquivo novo de player)
## Leitura da intenção
- **Outcome:** abrir uma fala com YouTube nunca termina num beco de signin — o embed toca quando o YouTube deixa e, quando não deixa, a página oferece em um clique uma superfície que toca nesta página (o trecho da Câmara sob demanda, quando elegível) e/ou "Abrir no YouTube" no ponto exato; transcrição, seleção, share, corte e download seguem intactos.
- **O que NÃO negociar:** saída independente de detecção do bloqueio (o app não vê o erro do iframe) — sempre visível; `t=` só com offset conhecido (nunca inventar); transcrição posiciona a **superfície ativa**; sem segundo player/editor; sem `Consent`/collection/migration; gate do acervo (`communicator`/`coordinator`/`candidate`; `advisor`/`leader` negados) e crédito "Fonte: Câmara dos Deputados · CC BY 4.0" intocados; URLs públicas intocadas; `excerptTMs` verbatim (C169).
- **O que reavaliar:** a hipótese de que a troca de host (`youtube-nocookie.com` → `www.youtube.com`, mesmos params) é o remédio barato para o bot-check de embed anônimo — é a recomendação C da intenção, assumida e validada no gate; a intenção sugere `src/lib/speechShare.ts` como área provável — não muda: o dono do link `watch?v=&t=` já existe e é reusado; e a fixture e2e é reavaliada na decisão 7 (nenhum browser e2e visita o detalhe hoje).
## Abordagem recomendada
```mermaid
flowchart LR
D["Detalhe da fala (RSC)<br/>youtubeVideoId · offset · vodResolvable"] --> Q{"youtubeVideoId?"}
Q -- "não" --> V["Quadrante só VOD / nenhum<br/>ramos C162 inalterados"]
Q -- "sim" --> Y["superfície ativa youtube<br/>iframe www.youtube.com/embed<br/>start = offset + t"]
Y --> X["saídas em 1 clique, sempre visíveis<br/>'Se o vídeo não abrir aqui…'"]
X -- "Assistir na Câmara" --> S["surface = vod<br/>POST resolver-vod (C162)"]
S --> P["resolvendo / gerando / estado honesto"]
P -- "PRONTO verificado" --> M["video nativo no trecho exato<br/>na própria página"]
P -- "falha" --> E["estado honesto + Tentar novamente<br/>'Abrir no YouTube' permanece"]
X -- "Abrir no YouTube" --> W["watch?v=ID (t=…) em aba nova<br/>sem t= quando offset desconhecido"]
Y --> T["clique na transcrição<br/>posiciona a superfície ativa"]
M --> T
M --> Z["seleção · share · cortar · baixar<br/>C166/C167 intactos"]
Y --> Z
```
**Opções consideradas:** A) manter `youtube-nocookie.com` e entregar só as saídas | B) trocar o host para `https://www.youtube.com/embed/<id>` (mesmos params) **e** entregar as saídas + estado de superfície — recomendação C da intenção | C) trocar o host e transformar o YouTube numa superfície própria (página/modal do player).
**Recomendação:** B — a troca de host é uma constante e ataca de frente as hipóteses (a)/(b) de bot-check/embed anônimo ("toca sem login"), enquanto as saídas cobrem o que o YouTube bloquear de fato (idade/embed desabilitado); a área é interna e autenticada, então o custo LGPD é menor que no site público — **assumido — validar no gate**.
**Rejeitadas:** A porque mantém o defeito provável intocado quando o remédio custa uma linha, e aposta tudo na saída; C porque é o rabbit hole "segundo player/superfície" que a intenção corta.
### Decisões de engenharia
1. **Host do embed.** Opções: A) manter `youtube-nocookie.com`, só saídas | B) `www.youtube.com` (mesmos `playsinline=1`, `rel=0`, `start` só >0) **e** saídas | C) host novo + superfície própria. **Recomendação:** B. **Rejeitadas:** A (não ataca a hipótese mais provável); C (anti-goal). _(assumido — validar no gate)_
2. **Superfície ativa.** Opções: A) `surface: 'youtube' | 'vod'` no `SpeechDetailPlayer`; iframe só com `surface === 'youtube'`; ramos VOD existentes byte a byte | B) iframe fixo + `<video>` "embaixo" (dois players) | C) `?surface=vod` na URL com reload. **Recomendação:** A — um dono do vídeo, uma superfície por vez, resolução C162 reusada. **Rejeitadas:** B (dois players e transcrição ambígua); C (estado volátil em URL interna, reload perde a resolução já verificada).
3. **Saídas sempre visíveis.** Opções: A) sempre que há `youtubeVideoId`, bloco secundário abaixo do media | B) só no bloqueio (exige detecção) | C) escondidas no menu de ações. **Recomendação:** A — aceite explícito: o app não vê o erro do iframe; B é o beco de hoje. "Assistir na Câmara" só quando `surface === 'youtube' && vodResolvable`; "Abrir no YouTube" sempre com id. **Rejeitadas:** B (detecção fora de escopo); C (esconderia a saída). _(assumido)_
4. **"Abrir no YouTube".** Opções: A) aba nova com `buildSpeechExcerptYoutubeUrl(youtubeVideoId, youtubeOffsetSeconds, activeStart ?? initialSeconds ?? 0)` | B) trocar o embed na mesma página | C) link autenticado do acervo. **Recomendação:** A — reusa o dono do `watch?v=&t=` (C166), `t=` omitido com offset nulo, sem helper/twin novo (knip preservado); "no ponto" = último ponto da transcrição/deep link (`activeStart ?? initialSeconds`), senão o início da fala. **Rejeitadas:** B (já é efeito da troca de host, não é botão); C (vaza URL interna). _(assumido)_
5. **Transcrição na superfície ativa.** Opções: A) `seekTo`/`seekable`/label/`disabled` ramificando em `surface` | B) manter ramificando em `youtubeVideoId` | C) desabilitar no VOD. **Recomendação:** A — B faria o clique tentar o iframe desmontado (regressão do aceite "posiciona na superfície ativa"); C é regressão C162. **Rejeitadas:** B e C.
6. **Detecção do bloqueio.** Opções: A) nenhuma (sem IFrame API/`onError`/polling) | B) IFrame API + heurística. **Recomendação:** A — fora de escopo explícito; a saída não depende de detecção. **Rejeitada:** B.
7. **Fixture e2e.** Opções: A) não tocar `tests/e2e/fixtures/e2eTest.ts` | B) estender o abort para `www.youtube.com/embed/**`. **Recomendação:** A — as duas famílias do acervo são browserless e nenhum browser e2e visita o detalhe; tocar no harness classifica o PR como high-risk (curated e2e) sem cobertura nova. **Rejeitada:** B nesta fatia. **Gatilho de revisitação:** o primeiro browser e2e que carregar o detalhe adiciona o abort do host novo (ou a rota vira regex dos dois hosts).
### Componentes / mudanças
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (dono único, encaixe cirúrgico): `buildYoutubeSrc` (:29-35) troca só o host para `https://www.youtube.com/embed/<id>` (params/ordem intocados); `title`, `allow`, `allowFullScreen`, `loading="lazy"` do iframe (:250-257) **byte a byte** — nada de `sandbox` novo; estado `surface` iniciado em `'youtube'` quando há id, `'vod'` caso contrário; `renderMedia` (:247-258) devolve o iframe só com `surface === 'youtube' && youtubeVideoId` e mantém os ramos VOD/`StatusPanel` como estão; `watchVod()` = `setSurface('vod')` + `requestResolution(false)` apenas quando não há `playbackUrl` e não há request em voo (`resolving`/`generating`) — já resolvido, troca sem novo POST; `seekTo` (:201-212) ramifica em `surface === 'youtube'` (offset nulo → inerte), senão `video.currentTime` + play; `seekable` (:245) vira `surface === 'youtube' ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)` (label :471-476 e `disabled` :492 seguem de graça); `inlineNotice` (:340-355) gate em `surface === 'youtube' && youtubeVideoId` (no VOD o painel já conta o estado; o aviso do download em curso no caminho "ambos" segue aparecendo com o iframe ativo); bloco de saída local ao arquivo (mesmo padrão do `StatusPanel`/`renderMedia`, **sem arquivo/componente novo**), logo abaixo do media, `data-slot="speech-youtube-exit"`: título "Se o vídeo não abrir aqui, assista por outro caminho:", apoio "O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.", "Assistir na Câmara" (`Button variant="outline"`, `PlayIcon`) quando `surface === 'youtube' && vodResolvable`, e "Abrir no YouTube" (`Button asChild variant="outline"`, `<a target="_blank" rel="noreferrer">`, `YoutubeIcon` de `src/components/socialIcons.tsx:33-45`) sempre que `youtubeVideoId`, href = `buildSpeechExcerptYoutubeUrl(...)` (decisão 4). Intocados: download (:405-429), share/cut (:384-404), `SpeechExcerptControls`, `SpeechCutDialog`, `publishedCut`, `aria-busy`, aviso sem-YouTube do C166 (:457-467).
- **`src/lib/speechShare.ts`** (sem mudança): `buildSpeechExcerptYoutubeUrl` (:29-44) é o dono do `watch?v=&t=` e já é unit-testado/consumido por `SpeechExcerptShare.tsx:18,53-60` — reuso direto no player, sem alias/segundo helper.
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx` / `speechViewModels.ts` / rotas / actions**: nenhuma mudança — props atuais (`youtubeVideoId`, `youtubeOffsetSeconds`, `vodResolvable`) bastam; nada de schema/VM nova; **não tocar** `speechVodResolver.ts`, rota `resolver-vod`, `actions/speech.ts` (dono: C169/#1045).
- **Migration:** sem migration — nenhum campo/collection/`payload-types` novo (`push: false` intocado).
- **Access / Consent:** nenhum `Consent` novo; gate `canReadSpeechCatalog` do detalhe/rota intocado; nenhuma escrita nova; `advisor`/`leader` seguem negados.
- **UI:** Impeccable B — shape → craft → critique → polish; reusa `Button`, `YoutubeIcon`, o padrão de aviso `rounded-lg border bg-muted/40 px-4 py-3` (mesma família do aviso C166) e o tema `data-theme="campaign"`; sem shell novo, sem `--pt-red`; hierarquia secundária no caminho feliz (o draft decide a ênfase âmbar no gate); identificadores em inglês, copy pt-BR verbatim da intenção.
### Comportamento por quadrante
| Quadrante | Superfície inicial | Saídas visíveis | Transcrição | Ações |
| ----------------------------------- | --------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- | --------------------------------------- |
| YouTube + VOD | iframe `www.youtube.com/embed` com `start=offset+t` | "Assistir na Câmara" + "Abrir no YouTube" | posiciona o iframe (recarrega `start=`) | baixar MP4, share (C166), cortar (C167) |
| Só YouTube (`vodResolvable: false`) | iframe idem | só "Abrir no YouTube" (`t=` omitido se offset nulo) | posiciona com offset; inerte sem offset (C162) | share ok; sem baixar/cortar (C162/C167) |
| Só VOD (sem id) | capa "Assistir o trecho" → painel → `<video>` | nenhuma (nada a escapar) | posiciona o `<video>` | baixar, cortar, aviso sem-link C166 |
| Nenhum | "Vídeo indisponível neste momento." | nenhuma | inerte | "Abrir fonte" apenas |
**Caminho feliz (embed tocando):** as saídas seguem visíveis em hierarquia secundária — o app não vê o erro interno, então não há estado "bloqueado"; **após "Assistir na Câmara"**: o VOD vira a superfície, sobra "Abrir no YouTube" (a Câmara já é o player) e falha da Câmara mantém o link (nunca beco).
### Dados → forma
**Não se aplica como dado:** o estado do embed/saída é **feedback de ação** (o que fazer agora), não dado de acervo — mesma decisão do C166/C162. Formas rejeitadas: contador/telemetria de bloqueio, classificação da causa do signin, badge de status no card/detalhe, mostrar o erro interno do iframe (o app não o vê).
## Fases verificáveis
1. **Tracer (~0,25 dia):** host swap + `surface` + `watchVod` + bloco de saída no `SpeechDetailPlayer`; os 3 pins de host do unit atualizados (183, 201, 293) e casos novos verdes. Verificação: `pnpm test:unit`, `pnpm typecheck`.
2. **UI (~0,25–0,5 dia):** shape → craft → critique → polish do bloco (desktop/mobile ~390px, flex-wrap, `min-h-10`, a11y do `data-slot="speech-youtube-exit"`, copy verbatim); RTL completo dos quadrantes. Verificação: `pnpm test:unit`, `pnpm lint`, `pnpm format:check`.
3. **Gates (~0,25 dia):** e2e browserless do acervo; `pnpm gate:fast`; `pnpm push`; CI roda `campaignSpeechAcervo`+`campaignSpeechCut` pelo manifesto (`src/components/campaign/speech` → :300-314); changelog `docs/changelog/2026-09-15-c171-youtube-sem-beco.md` no fechamento.
## Plano de testes
- **Unit RTL** (`tests/unit/speechDetailPlayer.unit.spec.tsx`, estender): trocar **apenas o literal do host** nos pins :183, :201, :293 (params e comportamento intactos); YouTube+VOD mostra título + "Assistir na Câmara" + "Abrir no YouTube" com href `watch?v=<id>&t=<offset+initial>`; `vodResolvable: false` mostra só o link; offset `null` → href sem `&t=`; clique "Assistir na Câmara" → `fetch` 1× `{ speechId: 42 }`, painel "Resolvendo o trecho na Câmara…", iframe desmontado, depois `<video src>` e clique em `button[data-start-seconds="43"]` → `video.currentTime === 43` (e `seekable`/label voltam); resolução já pronta (via download) → troca sem novo POST; falha → painel honesto + "Tentar novamente" **com "Abrir no YouTube" presente**; share/cut/seleção seguem disponíveis na superfície da Câmara; os blocos C162/C166/C167 existentes continuam verdes.
- **E2E browserless** (`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`): pin :163 → `www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`; no detalhe "ambos" (deep link `?t=43`) somar `Se o vídeo não abrir aqui`, `Assistir na Câmara`, `Abrir no YouTube` e `t=2677` (2634+43, offset verbatim), mantendo `not.toContain('<video')` e `not.toContain('vod.camara.leg.br')`; só-VOD (:178-201) e nenhum (:249-266) negam `Abrir no YouTube` e `Se o vídeo não abrir aqui`; rota `resolver-vod` (:268-322) intocada. Sem browser e2e novo — a interação é dona do jsdom RTL.
## Rabbit holes / Não escopo (engenharia)
- IFrame API/`enablejsapi`/`postMessage`/`onError`/polling para detectar bloqueio — corte da intenção; a saída não depende de detecção.
- Proxy/oEmbed/cache server-side do embed, baixar/mirror do vídeo, login/SSO no YouTube, cookies de terceiros — fora.
- Segundo player/superfície própria, preview do trecho no embed, voltar o host para `nocookie` — fora.
- Mexer no embed do site público (`CampaignStorySection`/`frontend.e2e.spec.ts:508-514`) — permanece `youtube-nocookie`.
- Fixture e2e (decisão 7), CSP/middleware/`headers()`, `Consent`/collection/migration/escrita, redesenho do detalhe/lista além do encaixe — fora.
### Já resolvido no simplify (não reabrir)
- Extração de `renderYoutubeExit()`; testes de guard do `watchVod` (resolving/generating → 1 POST); pins negativos dos quadrantes só-VOD/nenhum; `youtubeSurface && youtubeVideoId` mantido só no `renderMedia` (narrowing); apoio "O trecho da Câmara toca nesta página…" condicional a `vodResolvable`. Tudo já no diff desta entrega.
### Adiado com gatilho / Explicitamente fora (triage pós-simplify, 2026-09-15)
- **Ponto da transcrição perdido na troca para a Câmara** (S2, estrutural): o efeito de `initialSeconds` (:164-177) sobrescreve o último clique (`activeStart`) e, no arquivo já verificado (download primeiro), nem roda quando o `<video>` monta — a fatia vira a Issue **[C171-F1 (#1052)](https://github.com/fsolla/teqo/issues/1052)** (`depends: [1047]`, plano `docs/plans/c171-f1-ponto-da-transcricao-na-troca-de-superficie.md`). Não implementar nesta entrega.
- **`watchVod` re-POSTA com `resolved` + `playbackUrl` nulo** (S1): intencional — o clique explícito em "Assistir na Câmara" é o retry do probe de playback; alinhar o guard a `resolved` só mostraria o painel honesto sem nova tentativa. Descartado (sem código).
- **`YoutubeIcon` sem `data-icon="inline-start"` no "Abrir no YouTube"** (S3, cosmético ~2px): gatilho = próximo toque no bloco `speech-youtube-exit` — usar `data-icon="inline-start"` (precedente `SpeechCutShareActions.tsx:47`).
- **`surface` sem união discriminada `{kind:'youtube';videoId:string} | {kind:'vod'}`** (S4): 1 consumidor; o `&& youtubeVideoId` do `renderMedia` é só narrowing. Gatilho = 3º consumidor do estado de superfície ou extração do iframe para componente próprio.
- **Renomes `surface`→`playerSurface`, `youtubeWatchUrl`→`youtubeExitUrl`, `watchVod`→`showVodSurface`** (S5): pureza de nomes sem consumidor externo — descartado (score 1).
## Riscos e mitigação
- **Pins do host trocado:** 3 no unit (`:183`, `:201`, `:293`) + 1 no e2e (`:163`) — trocar só o literal e rodar os pins a cada mudança; nenhuma asserção de comportamento muda.
- **A troca de host não resolver (idade/embed desabilitado):** as saídas cobrem o que o YouTube ainda bloquear; sem prometer aviso automático; `t=` nunca inventado.
- **Postura LGPD:** premissa "área interna autenticada" registrada como _assumido — validar no gate_; o site público fica `nocookie`; reversão é uma constante em `buildYoutubeSrc`.
- **iframe `sandbox`/`allow`:** não adicionar `sandbox` (quebraria o player); `allow`, `allowFullScreen`, `loading` e `title` intocados — fora do diff.
- **C169 concorrente no mesmo domínio:** não tocar resolver/rota/action; dependência só suave (resolver corrigido torna o botão "Assistir na Câmara" mais confiável).
- **Duplo POST/"Assistir na Câmara" repetido:** guard de `resolving`/`generating`/`playbackUrl` no `watchVod` + teste de 1 chamada.
- **Regressão C162/C166/C167:** ramos VOD, download, share, cut e seleção intocados; pins rodam a cada fase.
- **Hermeticidade e2e:** nenhum browser e2e carrega o detalhe (as duas famílias são HTTP); gatilho de revisitação na decisão 7 se um browser spec passar a carregar.
## Aceite de engenharia
- [x] Aceite de produto da intenção coberto: 4 quadrantes + caminho feliz; saída em 1 clique sempre visível; "Assistir na Câmara" toca o trecho na própria página e a transcrição posiciona a superfície ativa; `watch?v=&t=` no ponto e sem `t=` quando o offset é desconhecido; crédito CC BY e share/cut/download intactos.
- [x] Invariantes AGENTS/engineering-standards: sem migration/`Consent`/escrita; sem twin (`buildSpeechExcerptYoutubeUrl` reusado; nenhum helper novo); `src/lib` sem import novo; gate do acervo intocado; copy pt-BR / identificadores em inglês; nenhum comentário supérfluo.
- [x] Testes de domínio previstos (unit jsdom + e2e browserless) onde o contrato do player muda; `pnpm gate:fast` limpo e manifest de e2e sem entrada nova.
## Self-score decision-quality
5/5 — (1) todas as decisões caras (host, superfície, saídas, link, fixture) têm opções + rejeitadas explícitas; (2) cabe no appetite herdado porque corta detecção/IFrame API/superfície nova e não cria arquivo; (3) rabbit holes nomeados com corte; (4) depth check: reusa `SpeechDetailPlayer`, `StatusPanel`, `Button`, `YoutubeIcon` e `buildSpeechExcerptYoutubeUrl` — nada de shell/twin paralelo; (5) o outcome permanece intacto (toca ou saída em um clique, nunca beco de signin), com a troca de host sinalizada como assumida ao gate humano.

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'
@ -19,6 +20,7 @@ import {
MIN_EXCERPT_SECONDS,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import { buildSpeechExcerptYoutubeUrl } from '@/lib/speechShare'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { cn } from '@/lib/utils'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
@ -31,7 +33,7 @@ const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string =
if (startSeconds !== null && startSeconds > 0) {
params.set('start', String(Math.floor(startSeconds)))
}
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`
}
type ResolutionState =
@ -139,9 +141,20 @@ export const SpeechDetailPlayer = ({
? youtubeOffsetSeconds + (initialSeconds ?? 0)
: null,
)
// C171 — one player surface at a time: the YouTube embed until the assessor
// asks for the Câmara excerpt, which then plays in place.
const [surface, setSurface] = useState<'youtube' | 'vod'>(youtubeVideoId ? 'youtube' : '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,
youtubeOffsetSeconds,
activeStart ?? initialSeconds ?? 0,
)
: null
const selectionDuration = durationSeconds ?? segments.at(-1)?.endSeconds ?? null
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
const selecting = selection !== null
@ -198,8 +211,16 @@ export const SpeechDetailPlayer = ({
}
}
// C171 — "Assistir na Câmara": switches the player 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)
}
const seekTo = (seconds: number) => {
if (youtubeVideoId) {
if (youtubeSurface) {
if (youtubeOffsetSeconds === null) return
setYoutubeStart(youtubeOffsetSeconds + seconds)
setActiveStart(seconds)
@ -242,10 +263,10 @@ export const SpeechDetailPlayer = ({
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
}
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const renderMedia = (): ReactNode => {
if (youtubeVideoId) {
if (youtubeSurface && youtubeVideoId) {
return (
<iframe
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
@ -337,7 +358,7 @@ export const SpeechDetailPlayer = ({
)
}
const inlineNotice = youtubeVideoId ? (
const inlineNotice = youtubeSurface ? (
resolution.kind === 'generating' ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{GENERATING_TITLE} Tente novamente em um momento.
@ -354,10 +375,56 @@ export const SpeechDetailPlayer = ({
) : null
) : null
// C171 — the exit block stays reachable with the embed playing: the app
// never sees the iframe's internal error, so the way out cannot depend on
// detecting the block.
const renderYoutubeExit = (): ReactNode => {
if (!youtubeWatchUrl) return null
return (
<div data-slot="speech-youtube-exit" className="mt-3 rounded-lg border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium text-foreground/90">
Se o vídeo não abrir aqui, assista por outro caminho:
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{youtubeSurface && vodResolvable ? (
<Button
type="button"
variant="outline"
className="min-h-10"
data-slot="speech-youtube-exit-camera"
onClick={watchVod}
>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Assistir na Câmara
</Button>
) : null}
<Button asChild variant="outline" className="min-h-10">
<a
href={youtubeWatchUrl}
target="_blank"
rel="noreferrer"
data-slot="speech-youtube-exit-link"
>
<YoutubeIcon />
Abrir no YouTube
</a>
</Button>
</div>
{vodResolvable ? (
<p className="mt-2 text-xs text-muted-foreground">
O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.
</p>
) : null}
</div>
)
}
return (
<div data-slot="speech-player" aria-busy={resolving || undefined}>
{renderMedia()}
{renderYoutubeExit()}
{selection && selectionDuration !== null ? (
<SpeechExcerptControls
segments={segments}

View file

@ -160,8 +160,15 @@ test.describe('communication vertical (C154/C162)', () => {
expect(detailHtml).toContain('data-slot="speech-player"')
// Default surface is the YouTube embed, positioned by the session offset
// (2634) plus the deep-linked transcript second (43).
expect(detailHtml).toContain(`youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`)
expect(detailHtml).toContain(`www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`)
expect(detailHtml).toContain(`start=${EXCERPT_OFFSET_SECONDS + 43}`)
// C171 — the exit block is server-rendered with the embed: the Câmara
// surface is one click away and the watch URL opens at the deep-linked
// point (never a signin dead end).
expect(detailHtml).toContain('Se o vídeo não abrir aqui, assista por outro caminho:')
expect(detailHtml).toContain('Assistir na Câmara')
expect(detailHtml).toContain('Abrir no YouTube')
expect(detailHtml).toContain(`watch?v=${YOUTUBE_VIDEO_ID}&amp;t=${EXCERPT_OFFSET_SECONDS + 43}`)
expect(detailHtml).toContain('data-start-seconds="0"')
expect(detailHtml).toContain('Baixar vídeo (MP4)')
expect(detailHtml).toContain('Abrir fonte')
@ -194,6 +201,9 @@ test.describe('communication vertical (C154/C162)', () => {
// while the selection door stays offered.
expect(html).toContain('Selecionar trecho')
expect(html).toContain('Compartilhar por link exige o vídeo no YouTube')
// C171 — no YouTube id means no exit block: there is no embed to escape.
expect(html).not.toContain('Abrir no YouTube')
expect(html).not.toContain('Se o vídeo não abrir aqui')
expect(html).not.toContain('<video')
expect(html).not.toContain('<iframe')
// The stored link is an eligibility signal, never a rendered URL.
@ -263,6 +273,9 @@ test.describe('communication vertical (C154/C162)', () => {
// No stored VOD means nothing to resolve: no MP4 button and no retry.
expect(html).not.toContain('Baixar vídeo (MP4)')
expect(html).not.toContain('Tentar novamente')
// C171 — without a YouTube id there is no embed and no exit block.
expect(html).not.toContain('Abrir no YouTube')
expect(html).not.toContain('Se o vídeo não abrir aqui')
})
test.describe('POST /campanha/comunicacao/acervo/resolver-vod (C162)', () => {

View file

@ -105,6 +105,9 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
// C171 — without a YouTube id there is no embed to escape from.
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
// The invariant: rendering the detail never calls the Câmara.
expect(fetchMock).not.toHaveBeenCalled()
@ -180,7 +183,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
expect(videoElement()).toBeNull()
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
)
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
@ -198,7 +201,7 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
})
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
)
})
@ -256,6 +259,174 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
})
})
describe('SpeechDetailPlayer — C171 exits out of the YouTube embed', () => {
const cameraButton = () => screen.getByRole('button', { name: /assistir na câmara/i })
const youtubeLink = () => screen.getByRole('link', { name: /abrir no youtube/i })
it('offers both exits, visible with the embed playing, linking the watch URL at the point', () => {
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: 100,
})
expect(screen.getByText('Se o vídeo não abrir aqui, assista por outro caminho:')).toBeDefined()
expect(
screen.getByText('O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.'),
).toBeDefined()
expect(cameraButton()).toBeDefined()
expect(youtubeLink().getAttribute('href')).toBe(
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2734',
)
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
expect(youtubeLink().getAttribute('href')).toBe(
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
)
})
it('drops t from the exit link when the session offset is unknown', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
expect(youtubeLink().getAttribute('href')).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A')
// The Câmara surface does not depend on the session offset.
expect(cameraButton()).toBeDefined()
})
it('offers only the YouTube exit when the Câmara has no stored excerpt', () => {
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
vodResolvable: false,
})
expect(screen.queryByRole('button', { name: /assistir na câmara/i })).toBeNull()
expect(youtubeLink()).toBeDefined()
// The Câmara support line would promise a surface this speech does not have.
expect(
screen.queryByText('O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala.'),
).toBeNull()
})
it('plays the Câmara excerpt in place of the embed on a single click', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: 100,
})
fireEvent.click(cameraButton())
expect(iframeElement()).toBeNull()
expect(screen.getByText(/Resolvendo o trecho na Câmara/)).toBeDefined()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ speechId: 42 })
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => {
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
})
// The transcript now seeks the Câmara surface, and the YouTube exit stays.
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
expect(videoElement()?.currentTime).toBe(43)
expect(youtubeLink()).toBeDefined()
expect(screen.queryByRole('button', { name: /assistir na câmara/i })).toBeNull()
})
it('switches to the already verified file without posting again', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
const tab = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: null,
})
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => expect(tab.location.href).toBe(DOWNLOAD_URL))
fireEvent.click(cameraButton())
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('does not ask the Câmara again while a resolution is already in flight', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
const tab = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
fireEvent.click(cameraButton())
expect(fetchMock).toHaveBeenCalledTimes(1)
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL))
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('switches to the Câmara surface without a second post while the Câmara generates', async () => {
respondWith(gerando)
const tab = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
expect(await screen.findByText(/A Câmara está gerando o trecho deste vídeo/)).toBeDefined()
fireEvent.click(cameraButton())
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(iframeElement()).toBeNull()
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
})
it('keeps the YouTube exit and the honest state when the Câmara fails', async () => {
respondWith(indisponivel)
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(cameraButton())
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
expect(youtubeLink()).toBeDefined()
})
it('keeps share and cut offered while the Câmara surface is active', async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(cameraButton())
await waitFor(() => expect(videoElement()).not.toBeNull())
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
expect(screen.getByRole('button', { name: /^compartilhar$/i })).toBeDefined()
expect(screen.getByRole('button', { name: /cortar vídeo/i })).toBeDefined()
})
})
describe('SpeechDetailPlayer — no-video quadrant', () => {
it('renders the unavailable block without retry or download', () => {
renderPlayer({ vodResolvable: false, sourceUrl: 'https://imagem.camara.leg.br/diario.pdf' })
@ -264,6 +435,8 @@ describe('SpeechDetailPlayer — no-video quadrant', () => {
expect(screen.queryByRole('button', { name: /tentar novamente/i })).toBeNull()
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
expect(screen.queryByText(/Se o vídeo não abrir aqui/)).toBeNull()
expect(screen.queryByRole('link', { name: /abrir no youtube/i })).toBeNull()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
})
@ -290,7 +463,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
expect(endSlider().getAttribute('aria-valuemax')).toBe('180')
// C162 contract: the selection mode does not seek the embed.
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
'https://www.youtube.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
)
// Clicking a phrase inside the range re-selects it alone.