30 KiB
Impl: Acervo: pré-visualizar no player o trecho selecionado
Status: concluído (as-built abaixo)
Atualizado em: 2026-09-16
Issue: #1083
Intenção: docs/plans/acervo-player-preview-do-trecho.md
Appetite restante: herdado (~1 dia eng; sem corte — sem schema/Consent/escrita; uma função pura em src/lib + o dono do playback + o card de seleção com um slot + as duas suítes unit)
Leitura da intenção
- Outcome: com um trecho selecionado numa superfície que o app controla (vídeo da Câmara), apertar "Pré-visualizar trecho" posiciona no início, reproduz e para no fim — o mesmo player, com a janela selecionada; no YouTube a postura é honesta (reposiciona no início, avisa e oferece o caminho da Câmara via C171), nunca prometendo a parada que o embed não garante.
- O que NÃO negociar: um único player (sem segundo player/superfície); a parada no fim só existe onde o app para o vídeo (
surface === 'vod'com o<video>montado); mudar a seleção durante/após a pré-visualização cancela o preview e reflete o novo intervalo (nunca vaza para fora do trecho); seleção/compartilhamento/corte intactos; clique na transcrição segue posicionando fora do modo de seleção e estendendo dentro dele (C166); semConsent/collection/migration; sem render/export; guardrails do acervo (gatespeechCatalog, crédito CC BY,excerptTMsverbatim, URLs públicas) intocados. - O que reavaliar: (1) a hipótese de que o dono do playback decide onde o botão aparece — o dono é
SpeechDetailPlayer, mas a posição visual é do artefato: o botão mora no card de seleção (SpeechExcerptControls), por slot, e o player passa o nó renderizado (decisão 1); (2) a questão em aberto do VOD não resolvido — resolvida no GATE a favor da intenção (gesto único: resolve e toca); o plano original a cortava e foi corrigido, com estado visual novo a cargo dodesigner(trigger a); (3)SpeechExcerptControlspermanece só-geometria: a lógica de tocar/parar não entra nele (recebe só slot + estado de apresentação).
Abordagem recomendada
flowchart LR
S["trecho selecionado<br/>selection != null"] --> G{"estado da superfície?"}
G -- "YouTube" --> Y["reposiciona o embed no início<br/>reuso do ramo YouTube de seekTo<br/>+ aviso honesto + Assistir na Câmara (C171)"]
G -- "Câmara resolvida" --> P["video.currentTime = início<br/>play() · previewing = true"]
G -- "Câmara a resolver (vodResolvable)" --> R["marca previewIntent<br/>requestResolution(false)"]
R -- "resolved + playbackUrl" --> P
R -- "generating / failed" --> F["limpa previewIntent<br/>StatusPanel assume"]
P --> T{"onTimeUpdate<br/>currentTime >= fim?"}
T -- "não" --> P
T -- "sim" --> E["pause() · currentTime = fim<br/>fim visível · play pronto"]
P -. "mudou a seleção (alças/frase/toggle)<br/>applySelection" .-> X["pause() · previewing = false<br/>previewIntent = null · novo intervalo refletido"]
P -. "pause nativo (onPause)" .-> X
Y --> Z["Compartilhar (C166) · Cortar (C167)<br/>intactos"]
E --> Z
Opções consideradas: A) pré-visualização como estado do dono do playback (SpeechDetailPlayer), com o botão renderizado dentro do card de seleção (SpeechExcerptControls, por slot) e comportamento ramificado por superfície | B) lógica de mídia dentro de SpeechExcerptControls (o bloco de seleção vira dono do play) | C) componente/estado próprio do preview (terceiro irmão no arquivo).
Recomendação: A — o SpeechDetailPlayer já é o dono de videoRef, seekTo, surface, playbackUrl e onTimeUpdate; o preview é só um modo de reprodução sobre o que já existe. O botão é renderizado pelo player e injetado no card pelo slot previewArea (posição do artefato: dentro do bloco de seleção), reusando Button e o PlayIcon (:3); o estado novo (previewing) é um booleano ao lado de selection/surface.
Rejeitadas: B porque SpeechExcerptControls é geometria pura (só traduz ponteiro/tecla; onChange sobe a range) e passaria a carregar efeito de mídia — quebra a fronteira que o C166 estabeleceu e o que o explorer 7 confirma; C porque é o rabbit hole "segundo player/segunda superfície" que a intenção corta e duplicaria o dono do <video>.
Decisões de engenharia
- Onde o controle ancora. Opções: A) dentro do card de seleção (
SpeechExcerptControls), no rodapé após a dica, via slotpreviewArea?: ReactNode+ estado de apresentaçãopreviewState| B) na fileira de ações existente doSpeechDetailPlayer| C) barra de ações nova. Recomendação: A — o artefato de gate é explícito: o botão mora noselection-carde "A ação não migra para a fileira de compartilhar, cortar ou baixar" (docs/plans/acervo-player-preview-do-trecho-ui-design.html:340; cenas 1/2/3 desenham o botão dentro do card). O player continua dono do playback e passa oButtonjá renderizado comoReactNode;SpeechExcerptControlssegue sem saber de mídia (recebe só o slot e um estado de apresentação'idle' | 'playing' | 'ended') — a fronteira do C166 é preservada porque nenhum efeito de mídia entra no controle. Rejeitadas: B (contraria o artefato aprovado — a estrutura visual é dodesigner, o implementador porta classe-a-classe, não realoca); C (estrutura nova, anti-goal). Posição resolvida pelo artefato — não vai ao gate como escolha. - YouTube: o que acontece. Opções: A) só o aviso honesto + "Assistir na Câmara" (C171), sem botão de preview | B) botão com o mesmo rótulo "Pré-visualizar trecho" que apenas reposiciona o embed no início (reusa o ramo YouTube de
seekTo, :231-238), nunca vira "Parar pré-visualização", com o aviso honesto sempre visível | C) tentarend/IFrame API no embed. Recomendação: B — porta a cena 3 (o artefato desenha o botão no card + a legenda "Começa em 01:42. No YouTube, a parada no fim é manual.") e o gesto é honesto porque só reposiciona; a parada (toggle "Parar pré-visualização" e pausa no fim) fica estritamente gated asurface === 'vod'com o<video>montado. Rejeitadas: A (o artefato desenha o botão; e reposicionar é útil e custa zero mecanismo novo); C (rabbit hole declarado na intenção — bot-check/sem API). - Pré-visualizar com o VOD ainda não resolvido (produto: gesto único). Opções: A) gesto único — o controle aparece no quadrante "Assistir o trecho" (fala
vodResolvable, semplaybackUrl) e, ao clicar, resolve o VOD e toca assim que o arquivo verificado chega | B) o controle só aparece com o<video>já montado (o assessor precisa clicar "Assistir o trecho" antes) | C) pede "Assistir o trecho" explicitamente. Recomendação: A — decidido com o humano no GATE, seguindo a recomendação da intenção: o resolver é idempotente/verificado (C162/C171) e o gesto é único ("veja o trecho"). Estado novo:previewIntentRef+ effect que, quandoresolution.kind === 'resolved' && playbackUrle há intenção pendente, inicia o preview (seek ao início +play()) depois de o<video>montar;generating/failedlimpam a intenção e oStatusPanel/inlineNoticeassumem; o botão mostra o estado "resolvendo" enquanto a resolução corre. Rejeitadas: B (o plano original; contraria a recomendação da intenção e cria dois gestos); C (copy/estado redundante com o painel que já existe). Design: é um estado visual que o artefato não cobre → trigger (a): odesignerestende o artefato antes de o markup mudar. - Parada manual vs. fim. Opções: A) no fim, pausa e deixa o fim visível (play pronto); parar manualmente pausa onde está | B) pausa e rebobina ao início da seleção. Recomendação: A — replica a questão em aberto da intenção: o fim fica visível para conferência e o replay é o próximo clique; rebobinar automático é gesto de replay não pedido. Rejeitada: B.
- Onde mora a matemática "chegou ao fim". Opções: A) função pura em
src/lib/speechExcerptSelection.ts(excerptPreviewStopAt(range, currentSeconds): number | null) consumida peloonTimeUpdate| B)if (current >= selection.endSeconds)inline no componente | C) helper novo emsrc/utilities/speech. Recomendação: A — mantém oonTimeUpdatemagro, é testável sem DOM (a suíte pura já existe) e é o dono natural da range; devolve o alvo exato de parada (endSeconds) para clamp do overshoot dotimeupdate(~4 Hz). Rejeitadas: B (regra espalhada, sem teste puro; o brief pede a lib); C (client component importariautilitiespor nada —lib/é client-safe por definição). - Mudança de seleção durante o preview. Opções: A) um único
applySelection(next)no player que sempre para o preview e então aplica a nova range; todos os call-sites (onChange,onSegmentActivate,toggleSelection) passam por ele | B) parar o preview em cada call-site separadamente. Recomendação: A — um caminho só garante o invariante "nunca vaza"; B esquece um call-site a cada novo modo. Rejeitada: B. - Anti-leak em pausa/scrub nativos. Opções: A)
onPausefecha o estado de preview (o usuário pausou no controle nativo) + guard-effect limpapreviewingquandoplaybackUrl/surfacemudam | B) detectarseeking/onSeekingpara cancelar scrub. Recomendação: A —onPausecobre pausa manual e o própriopause()do fim; B corre risco de oseekingdo seek inicial dostartPreviewlimpar o estado recém-ligado (ordem de eventos do browser). Rejeitada: B nesta fatia (gatilho: relato de preview seguindo quando a pessoa arrasta a barra nativa).
Componentes / mudanças
src/lib/speechExcerptSelection.ts(editar o owner puro, sem React/DOM): exportarexcerptPreviewStopAt(range: ExcerptRange, currentSeconds: number): number | null— devolverange.endSecondsquandocurrentSecondsfinito e ≥ o fim (fim inclusivo;onTimeUpdatepode passar do fim e o retorno é o alvo para clampar de volta), senãonull. Sem tipo novo (reusaExcerptRange); sem tocar emMIN_EXCERPT_SECONDS/normalizeExcerptRange/moveRangeEdge/C170.src/components/campaign/speech/SpeechDetailPlayer.tsx(editar o owner do playback, encaixe cirúrgico):- imports:
PauseIcondelucide-reactjunto dePlayIcon(:3);excerptPreviewStopAtde@/lib/speechExcerptSelection(:17-22). - estado:
const [previewing, setPreviewing] = useState(false)ao lado deselection(:137) —previewingsó é verdadeiro com o<video>montado. stopPreview():videoRef.current?.pause()+setPreviewing(false)(idempotente).applySelection(next: ExcerptRange | null):stopPreview()e entãosetSelection(next). Substitui os três call-sites atuais:onChange={setSelection}(:443 →onChange={applySelection}),setSelection(initialExcerptRange(...))(:261) esetSelection(extendRangeToSegment(...))(:273).toggleSelection(:255-262) usaapplySelectionnos dois ramos. Esse é o dono único do invariante "mudou a seleção → parou".startPreview(): quando há<video>montado (surface === 'vod'complaybackUrl),video.currentTime = selection.startSeconds; void video.play().catch(() => {}); setPreviewing(true). EmyoutubeSurface, apenas reposiciona reusando o ramo YouTube deseekTo(selection.startSeconds)(:231-238) — semsetPreviewing.- gesto único no quadrante não resolvido (decisão 3):
previewIntentRef+requestPreview()— emvodResolvablesemplaybackUrl(e sem resolução em voo), marcapreviewIntentRef.current = truee chamarequestResolution(false); um effect em[resolution, playbackUrl, surface]inicia o preview quando o arquivo fica pronto (resolved+playbackUrl) epreviewIntentRef.current, reusandostartPreview()depois que o<video>monta (seek emloadedmetadata, como o effect :169-186); emgenerating/failed, limpapreviewIntentRef.stopPreview/applySelection/guard-effect também limpampreviewIntentRef. onTimeUpdate(:245-253, estendido): antes do mapeamento de segmento ativo, seselection && previewing, calcularconst stopAt = excerptPreviewStopAt(selection, current); quando não-nulo →video.pause(),video.currentTime = stopAt,setPreviewing(false)(fim visível, play pronto); o mapeamentoactiveStart(:249-252) permanece byte a byte.<video>(:291-303): acrescentaronPause={() => setPreviewing(false)}(pausa manual/resolução);controls,preload,src,onTimeUpdate,<track>intocados.- guard-effect:
useEffect(() => { if (surface !== 'vod' || !playbackUrl) setPreviewing(false) }, [surface, playbackUrl])— nada vaza quando a superfície troca ou o arquivo desmonta. - controle dentro do card de seleção (decisão 1): o player renderiza o
Buttone o passa ao card por slot —<SpeechExcerptControls ... previewState={previewState} previewArea={<Button …/>} />.data-slot="speech-excerpt-preview",aria-pressed={previewing}, mesmas classes do artefato (min-h-10 w-full sm:w-auto,data-icon="inline-start"):surface === 'vod' && playbackUrl→ rótulo "Pré-visualizar trecho" (PlayIcon); compreviewing, rótulo "Parar pré-visualização" (PauseIcon) eborder-primary text-primary(estado REPRODUZINDO do artefato), clique =stopPreview().vodResolvableainda não resolvido (quadrante "Assistir o trecho") → rótulo "Pré-visualizar trecho" (PlayIcon), clique =requestPreview(); enquanto a resolução corre, o botão mostra o estado "resolvendo" (ícone/spinner,disabled/aria-busy) — forma exata a cargo da extensão do artefato (trigger a). AStatusPanel"Resolvendo o trecho na Câmara…" já cobre o corpo.youtubeSurface→ rótulo "Pré-visualizar trecho" (PlayIcon), clique =seekTo(selection.startSeconds), nunca "Parar" (o app não para o iframe).- Nenhuma das duas superfícies → o player passa a legenda do artefato como
previewArea("A seleção permanece ajustável. Pré-visualização não oferecida nesta superfície.") e nenhum botão (cena 4).
- o card de seleção ganha
previewState('idle' | 'playing' | 'ended') para portar os estados do artefato:playing→ring-1 ring-primary/30no card + chip "REPRODUZINDO" no cabeçalho;ended→ chip "FIM VISÍVEL · {fim}";idle→ sem chip (composição primária da cena 1). Copy pt-BR de apresentação fica no controle (já tem "Trecho selecionado"/"Início do trecho"); nenhuma lógica de mídia. - aviso honesto do YouTube (literal da intenção): quando
youtubeSurface && selecting, o literal "No YouTube, a pré-visualização começa no início do trecho — parar no fim exige o vídeo da Câmara." é renderizado no rodapé do card de seleção, abaixo do botão (estrutura da cena 3); o caminho da Câmara é o bloco já existente do C171 (renderYoutubeExit, :391-430, botão "Assistir na Câmara" →watchVod) — nenhum botão novo. - linha honesta de "sem superfície" (cena 4): quando
selectinge não há nemplaybackUrl/vodResolvablenemyoutubeOffsetSeconds, opreviewAreaé a legenda "A seleção permanece ajustável. Pré-visualização não oferecida nesta superfície." (copy do artefato, sem lógica nova). - intocados: download (:482-506),
SpeechExcerptShare(:472-481),SpeechCutDialog(:521-532),publishedCut,inlineNotice(:371-386),renderYoutubeExit,aria-busy,renderMedia/StatusPanel, transcrição (:546-597), a fileira de ações (:447-515).
- imports:
src/components/campaign/speech/SpeechExcerptControls.tsx(dono do card; encaixe cirúrgico, sem lógica de mídia): ganhapreviewArea?: ReactNode(rodapé do card, depois da dica; layoutmt-3 flex flex-wrap items-end justify-between gap-3 border-t border-border pt-3como a cena 1, com a dica à esquerda) epreviewState?: 'idle' | 'playing' | 'ended'(default'idle'; aplica oringdo card e o chip do cabeçalho). A proponChangepassa a receberapplySelectiondo player. A geometria (track/alças/role="slider"/snap) fica byte a byte; nada devideoRef/play/pause/previewingaqui.- Migration: sem migration — nenhum campo/collection/
payload-typesnovo (push: falseintocado). - Access / Consent: nenhum
Consentnovo; gatecanReadSpeechCatalogdo detalhe intocado; nenhuma escrita nova;advisor/leaderseguem negados; nada persistido no servidor. - UI: Impeccable B — shape → craft → critique → polish; reusa
Button,PlayIcon/PauseIcon,data-icon="inline-start",min-h-10e o padrão tipográficotext-xs text-muted-foreground; portar do artefato os estados PARADO (sem chip) / REPRODUZINDO (ring + chip + botão primário) / FIM VISÍVEL (chip) no bloco de seleção, com a barra de seleção intacta; identificadores em inglês, copy pt-BR verbatim do artefato/intenção. A ação não migra para a fileira de compartilhar/cortar/baixar (artefato, linha 340).
Dados → forma
Não se aplica como dado: o preview é feedback de ação (tocar/parar o intervalo), não dado do acervo — mesma decisão do C162/C166. Formas rejeitadas: telemetria/KPI de pré-visualização (a intenção adia), barra de progresso própria do trecho, contador de reproduções, badge de status de dado do acervo no card/lista (o chip REPRODUZINDO/FIM VISÍVEL é estado de controle do artefato, não métrica).
Fases verificáveis
- Tracer — função pura + modo de playback (~0,3 dia).
excerptPreviewStopAtemspeechExcerptSelection.ts;previewing/stopPreview/applySelection/startPreview/requestPreview/onTimeUpdate/onPause/guard-effect no player; slotpreviewAreano card de seleção. Verificação:pnpm test:unit -- speechExcerptSelectionepnpm test:unit -- speechDetailPlayer;pnpm typecheck. - Design + UI + YouTube honesto (~0,4 dia). Trigger (a): o
designerestendedocs/plans/acervo-player-preview-do-trecho-ui-design.htmlcom o estado novo (preview no quadrante VOD-a-resolver: botão + estado resolvendo) antes de o markup mudar; depois porta classe-a-classe os estados do card/botão (rótulo/realce/ícone/chip), o aviso literal do YouTube no card, a legenda de "sem superfície", mobile ~390 px; casos jsdom restantes. Verificação:pnpm test:unit,pnpm lint,pnpm format:check. - Gates (~0,3 dia).
pnpm gate:fast(lint + typecheck + unit); e2e curado do acervo sem entrada nova no manifesto (src/components/campaign/speechjá está mapeado) e sem mudança de HTML SSR;pnpm push; changelogdocs/changelog/2026-09-16-c173-preview-do-trecho.mdno fechamento.
Plano de testes
- Unit puro (
tests/unit/speechExcerptSelection.unit.spec.ts, estender — casa natural da geometria pura):excerptPreviewStopAt— antes do fim →null; exatamente no fim →endSeconds; depois do fim (overshoot dotimeupdate) →endSeconds(clamp de volta);currentSecondsnão-finito →null; fim inclusivo comcurrentSeconds > endSeconds. Sem DOM, sem fake timers. - Unit jsdom (
tests/unit/speechDetailPlayer.unit.spec.tsx, estender; estender obeforeAllde mídia :71-82 com um mock depause/pausedespelhando o mock deplay):- toca no início e para no fim:
vodResolvable+ resolve o arquivo (pronto(PLAYBACK_URL, DOWNLOAD_URL)),toggleSelection(), clique em "Pré-visualizar trecho" →video.currentTime === selection.startSecondseplaychamado;video.currentTime = endSeconds(ou além) +fireEvent.timeUpdate(video)→pausechamado evideo.currentTime === endSeconds(fim visível) e o rótulo volta a "Pré-visualizar trecho". - para quando a seleção muda: durante o preview, clique em outra frase (
button[data-start-seconds]) ou arraste uma alça →pausechamado,previewingdesligado (rótulo de preview volta) e a nova range refletida nosaria-valuenow(nunca continua tocando o intervalo antigo). - gesto único (VOD a resolver):
vodResolvablesemplaybackUrl,toggleSelection(), clique em "Pré-visualizar trecho" → resolução disparada; quando chegapronto(PLAYBACK_URL, …), o<video>monta eplayé chamado comcurrentTimeno início do trecho; emgerando/falha, nenhum play e a intenção é limpa. - YouTube honesto:
youtubeVideoId+ offset,toggleSelection(), clique em "Pré-visualizar trecho" →iframe.srccontémstart = offset + selection.start(reuso deseekTo), sem<video>e rótulo nunca "Parar pré-visualização"; o aviso literal "No YouTube, a pré-visualização começa no início do trecho — parar no fim exige o vídeo da Câmara." presente; comvodResolvable, "Assistir na Câmara" presente (C171), sem novo botão. - não é oferecida sem superfície parável: sem YouTube e sem
vodResolvable/playbackUrl→ legenda de "não oferecida", seleção ainda compartilhável/cortável quando aplicável. - os blocos C162/C166/C167/C171/
no-videoexistentes continuam verdes (nenhum pino existente muda).
- toca no início e para no fim:
- E2E: não agrega valor aqui — declarado.
tests/e2e/campaignSpeechAcervo.e2e.spec.tsé asserção de HTML server-rendered e o preview só existe comselection(estado client, ausente no SSR); não há interação de playback no harness browserless (que ainda abortayoutube*). Nenhuma entrada nova no manifesto curado; nenhum pino de e2e muda.
Rabbit holes / Não escopo (engenharia)
- IFrame API/
enablejsapi/postMessage/enddo embed para "parar" no YouTube — a postura honesta cobre (intenção, corte explícito). - Segundo player/superfície, iframe próprio, render/export/trim, arquivo novo de preview — anti-goals da intenção.
- Loop contínuo, A/B do trecho, waveform, controle de velocidade, scrub próprio, "pular para o fim" — fora.
- Cancelar preview no
seeking/scrub nativo (decisão 7, risco de race) — gatilho registrado. - Reescrever a geometria de
SpeechExcerptControls(track/alças/ímã/snap/role="slider") ou colocar lógica de mídia nele — o card só ganha o slotpreviewArea+previewState; o mínimo 5 s, o compartilhamento/corte (C166/C167/C168) e a biblioteca ficam fora. Consent/collection/migration/escrita/telemetria; tocarresolver-vod/actions/speech.ts(C169) — fora.
Riscos e mitigação
- Serializa com C172 (mesmo
SpeechDetailPlayer.tsx): encaixar depois (ou antes, combinado) para não misturar diffs; o toque é cirúrgico no arquivo, sem reescrita derenderMedia/quadrantes. - Overshoot do
timeupdate(~4 Hz): o fim pode ser ultrapassado em ~250 ms —excerptPreviewStopAtdevolveendSecondse oonTimeUpdatefazvideo.currentTime = stopAtantes de pausar; teste puro cobre o clamp. - Estado
previewingpreso (pausa nativa/troca de superfície):onPausefecha o estado e o guard-effect em[surface, playbackUrl]limpa quando o<video>desmonta; teste de seleção mudando cobre o caminho principal. - Vazamento do trecho antigo: um único
applySelectionem todos os call-sites desetSelection(:261/:273/:443) — nenhum caminho escreveselectionsem parar o preview. - Falsa promessa no YouTube: o rótulo "Parar pré-visualização" e a pausa no fim são gated a
surface === 'vod'com<video>montado; no YouTube só reposiciona e o aviso literal aparece; teste jsdom pinna o rótulo. - Gesto único (resolve→play) com corrida de estado: a intenção pendente pode sobreviver a uma troca de seleção/superfície —
applySelection/stopPreview/guard-effect limpampreviewIntentRef;generating/failedtambém; teste cobre o caminho e a limpeza no erro. - Porte visual do card (chip/ring/rodapé): o artefato desenha o botão e o chip de estado dentro do card; o porte é classe-a-classe (decisão 1) e a crítica final do
designer(trigger c) confere a paridade contra o app renderizado (390/1280 + estados PARADO/REPRODUZINDO/FIM VISÍVEL/RESOLVENDO); divergência de detalhe é ajuste no card, sem mover lógica. - Regressão C162/C166/C167/C170/C171: ramos de superfície, transcrição, download, share, cut e clamps intocados; as unidades existentes (745 linhas) rodam a cada fase;
excerptPreviewStopAtnovo é consumido pelo componente (knip sem export morto) ePauseIconé usado. - C169 concorrente no domínio: nenhuma dependência de código; só torna o caminho "Assistir na Câmara" mais confiável.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto: com trecho selecionado no vídeo da Câmara, "Pré-visualizar trecho" posiciona no início, toca e para no fim; mudar a seleção cancela e reflete o novo intervalo; no YouTube a postura é honesta (reposiciona + aviso + Câmara via C171), sem parada prometida; seleção/share/corte intactos; transcrição C166 intacta.
- Invariantes AGENTS/engineering-standards: sem migration/
Consent/escrita/segundo player;lib/segue puro e client-safe; um dono do playback (SpeechDetailPlayer); copy pt-BR / identificadores em inglês; nenhum comentário supérfluo; nenhum twin (excerptPreviewStopAté a única função nova e é a dona da regra). - Testes de domínio previstos onde o contrato do player muda: unit puro (
speechExcerptSelection) + unit jsdom (speechDetailPlayer) verdes; e2e declarado sem valor (SSR-only) e sem entrada nova no manifesto;pnpm gate:fastlimpo.
Self-score decision-quality
5/5 — (1) as decisões caras (posição do controle, postura no YouTube, gating por superfície, fim/manual stop, dono da matemática, cancelamento por seleção) têm opções + recomendação + rejeitadas explícitas; (2) cabe no appetite herdado porque corta IFrame API, resolução encadeada, segundo player e arquivo novo — uma função pura + o dono existente + o card de seleção com um slot + as duas suítes unit; (3) rabbit holes nomeados com corte (IFrame API, auto-resolve, loop, scrub nativo, telemetria); (4) depth check: reusa SpeechDetailPlayer, o card de seleção existente, Button/PlayIcon/PauseIcon, o seekTo do YouTube, o bloco de saída C171 e speechExcerptSelection — nenhum shell/twin/componente paralelo; (5) o outcome da intenção permanece intacto — a engenharia não reescreveu o aceite; a posição do botão e a postura no YouTube seguem o artefato de gate (nenhum ponto de produto em aberto).
As-built (2026-09-16)
Rebase sobre main depois de C174 (#1084), C178 (#1081) e C181 (#1117) — o embed do YouTube foi removido na implementação do C178 e restaurado pelo C181 (Câmara como entrada, embed atrás de "Assistir no YouTube"). O plano acima foi reconciliado: as decisões 1/2/3 valem sobre o arquivo pós-C181 (surface 'vod' | 'youtube', seekTo ramificado, pendingSeekRef). Line numbers do corpo do plano referem-se ao arquivo pré-rebase; esta seção é a autoritativa.
Entregue:
excerptPreviewStopAt(src/lib/speechExcerptSelection.ts) — alvo de parada + clamp do overshoot dotimeupdate; tipoExcerptPreviewPhasecompartilhado.SpeechExcerptControls— recebepreviewArea(slot) epreviewState(idle | playing | ended): rodapé comborder-t,ring-1 ring-primary/30enquanto toca, chipREPRODUZINDO(chip) /FIM VISÍVEL · {fim}(texto muted, como o artefato). A geometria (track/alças/role="slider") intocada; nenhuma lógica de mídia no card.SpeechDetailPlayer—previewPhase+previewIntentRef;startPreview/stopPreview/clearPreviewIntent; gesto único resolve→toca (requestPreviewResolution,pendingSeekRefmira o início do trecho);onTimeUpdatepara no fim;onPause/onPlayfecham o preview; guard-effect em[surface, playbackUrl];applySelectionpara todo caminho que muda a seleção;onChangedo card passa por ele. YouTube só reposiciona (nunca "Parar") e o YouTube-only abre o embed no início; sem superfície → legenda da cena 4.
Simplify (aplicado na sessão): play() rejeitado devolve a fase (sem controle mentindo); requestPreview morto colapsado no caminho vivo; pendingSeekRef limpo quando a intenção cai (não vaza para um retry); onPlay limpa ended num resume nativo; botão/legenda do YouTube deduplicados; min-h-10 nos controles; chip de ended como texto.
Design (trigger a + c): o designer estendeu o artefato com a CENA 5 (VOD a resolver: PARADO → RESOLVENDO → FALHOU) antes do markup; a crítica final contra o app renderizado (screenshots 1280/390: idle, resolvendo, reproduzindo, fim visível, YouTube reposicionado, YouTube-only, sem superfície, CENA 5A/5C) voltou not certified em 3 bloqueios (estado no slot direito do cabeçalho + range em segunda linha; coluna de ação de ~300px para o bloco do YouTube; botão ocupado com border-border bg-muted/50 text-muted-foreground opacity-60) — corrigidos (mais o border-t só no desktop na composição normal) e a re-crítica certificou (Design tier: primary).
Já resolvido no simplify/critique (não reabrir): os P0–P2 dos dois revisores acima.
Explicitamente fora / adiado com gatilho:
- Posse do layout do rodapé pelo card (
previewAreacarregasm:items-end,max-w-[430px]): a fronteira que importa (card media-free) está preservada; um render-prop/estado puro só quando houver 2º consumidor doSpeechExcerptControls. excerptPreviewStopAt(range, current)só lêendSeconds: assinatura mais honesta (endSeconds: number) só quando aparecer um 2º consumidor da regra.- Helpers de teste (
selectWindow/mountedVideo): hoist para o módulo quando houver 3ª cópia. aria-pressedno botão de preview: deliberadamente omitido (o botão troca ação e rótulo; pressed state seria falso) — divergência do rascunho do plano, certificada pelodesignerno fechamento.