From 2430ed5ebb346ad3371f3cdc8d9c898150897dd8 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 22:00:59 -0300 Subject: [PATCH] =?UTF-8?q?feat(C173):=20pr=C3=A9-visualizar=20no=20player?= =?UTF-8?q?=20o=20trecho=20selecionado=20(Closes=20#1083)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-16-c173.md | 1 + .../acervo-player-preview-do-trecho-impl.md | 27 ++- .../campaign/speech/SpeechDetailPlayer.tsx | 213 +++++++++++++++++- .../campaign/speech/SpeechExcerptControls.tsx | 72 +++++- src/lib/speechExcerptSelection.ts | 13 ++ tests/unit/speechDetailPlayer.unit.spec.tsx | 201 ++++++++++++++++- .../unit/speechExcerptSelection.unit.spec.ts | 25 ++ 7 files changed, 529 insertions(+), 23 deletions(-) create mode 100644 docs/changelog/2026-09-16-c173.md diff --git a/docs/changelog/2026-09-16-c173.md b/docs/changelog/2026-09-16-c173.md new file mode 100644 index 00000000..1645acf7 --- /dev/null +++ b/docs/changelog/2026-09-16-c173.md @@ -0,0 +1 @@ +**C173 — pré-visualizar no player o trecho selecionado (2026-09-16):** com um trecho [início, fim] selecionado no detalhe da fala, o controle **"Pré-visualizar trecho"** (dentro do card de seleção, como o artefato de gate manda — a ação não migra para a fileira de compartilhar/cortar/baixar) posiciona o vídeo da Câmara no início, toca **só** a janela e **para no fim** (fim visível, play pronto); enquanto toca vira **"Parar pré-visualização"**. A regra de parada é pura e testável sem player: `excerptPreviewStopAt` (`src/lib/speechExcerptSelection.ts`) devolve o fim quando o playhead alcança (inclusive) e o `onTimeUpdate` faz o clamp do overshoot de ~4 Hz do `timeupdate`. Na fala com trecho da Câmara ainda não resolvido, o **gesto é único** (decidido com produto no GATE): o clique marca a intenção, resolve o arquivo e toca assim que ele chega; falha/`gerando` descarta a intenção (um "Tentar novamente" separado não auto-toca) e limpa o seek pendente. Mudar a seleção (alças/frases/toggle), pausar nativamente ou trocar de superfície cancela o preview — `applySelection` é o dono único do invariante "mudou a seleção → parou". No **YouTube** a postura é honesta: o embed não tem controle de fim, então o controle só **reposiciona** (nunca vira "Parar") com o aviso "No YouTube, a pré-visualização começa no início do trecho — parar no fim exige o vídeo da Câmara."; sem superfície na página, o card mostra a legenda e não oferece o controle. O card ganha o slot `previewArea` + o estado de apresentação `previewState` (ring/chip REPRODUZINDO, fim visível) — segue **media-free**; o playback continua no `SpeechDetailPlayer`. Sem `Consent`/collection/migration/escrita, sem segundo player, sem render/export; seleção/compartilhamento/corte (C166/C167) intactos. UI no gate: o `designer` estendeu o artefato (trigger a) para o estado VOD-a-resolver e **certificou** a implementação renderizada (screenshots 390/1280: idle/resolvendo/reproduzindo/fim visível, YouTube reposicionado, YouTube-only, sem superfície) — `Design tier: primary`. Reconciliado com C174 (#1084), C178 (#1081) e C181 (#1117 — o embed volta). Issue #1083. diff --git a/docs/plans/acervo-player-preview-do-trecho-impl.md b/docs/plans/acervo-player-preview-do-trecho-impl.md index a2f1ecb6..aad4b4bc 100644 --- a/docs/plans/acervo-player-preview-do-trecho-impl.md +++ b/docs/plans/acervo-player-preview-do-trecho-impl.md @@ -1,10 +1,10 @@ # Impl: Acervo: pré-visualizar no player o trecho selecionado -Status: aprovado +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 + as duas suítes unit) +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 @@ -124,3 +124,26 @@ flowchart LR ## 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 do `timeupdate`; tipo `ExcerptPreviewPhase` compartilhado. +- **`SpeechExcerptControls`** — recebe `previewArea` (slot) e `previewState` (`idle | playing | ended`): rodapé com `border-t`, `ring-1 ring-primary/30` enquanto toca, chip `REPRODUZINDO` (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`, `pendingSeekRef` mira o início do trecho); `onTimeUpdate` para no fim; `onPause`/`onPlay` fecham o preview; guard-effect em `[surface, playbackUrl]`; `applySelection` para todo caminho que muda a seleção; `onChange` do 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** (`previewArea` carrega `sm:items-end`, `max-w-[430px]`): a fronteira que importa (card media-free) está preservada; um render-prop/estado puro só quando houver 2º consumidor do `SpeechExcerptControls`. +- **`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-pressed`** no 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 pelo `designer` no fechamento. diff --git a/src/components/campaign/speech/SpeechDetailPlayer.tsx b/src/components/campaign/speech/SpeechDetailPlayer.tsx index 2dae0f8d..5d0915b0 100644 --- a/src/components/campaign/speech/SpeechDetailPlayer.tsx +++ b/src/components/campaign/speech/SpeechDetailPlayer.tsx @@ -1,6 +1,13 @@ 'use client' -import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon, ScissorsIcon } from 'lucide-react' +import { + DownloadIcon, + ExternalLinkIcon, + FilmIcon, + PauseIcon, + PlayIcon, + ScissorsIcon, +} from 'lucide-react' import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types' @@ -15,12 +22,14 @@ import { Spinner } from '@/components/ui/Spinner' import { postCampaignJson } from '@/lib/campaignJsonRequest' import type { SpeechCutViewModel } from '@/lib/speechCut' import { + excerptPreviewStopAt, excerptSelectionDuration, extendRangeToSegment, initialExcerptRange, isExcerptSelectionAvailable, MIN_EXCERPT_SECONDS, SPEECH_EXCERPT_REQUEST_EVENT, + type ExcerptPreviewPhase, type ExcerptRange, } from '@/lib/speechExcerptSelection' import { buildSpeechExcerptYoutubeUrl } from '@/lib/speechShare' @@ -30,6 +39,8 @@ 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.' +/** C173 — the preview action keeps the row's height and goes full width on touch. */ +const PREVIEW_BUTTON_CLASS = 'min-h-10 w-full sm:w-auto' /** C181 — the pre-C178 embed contract, preserved for the in-page YouTube surface. */ const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string => { @@ -188,6 +199,11 @@ export const SpeechDetailPlayer = ({ // in session so the share kit stays visible after the dialog closes. const [cutDialogOpen, setCutDialogOpen] = useState(false) const [publishedCut, setPublishedCut] = useState(null) + // C173 — the selected-window preview: the phase drives the control and the + // card ring; the intent ref carries the single gesture through a Câmara + // resolution (the file is not on the page yet when the assessor asks). + const [previewPhase, setPreviewPhase] = useState('idle') + const previewIntentRef = useRef(false) // 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(() => @@ -287,10 +303,11 @@ export const SpeechDetailPlayer = ({ } // 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 = () => { + // played (or the deep link). It never touches the Câmara request. C173 — the + // excerpt preview reuses it with an explicit second, when the embed is not up. + const watchYoutube = (seconds?: number) => { if (youtubeOffsetSeconds !== null) { - setYoutubeStart(youtubeOffsetSeconds + (activeStart ?? initialSeconds ?? 0)) + setYoutubeStart(youtubeOffsetSeconds + (seconds ?? activeStart ?? initialSeconds ?? 0)) } setSurface('youtube') } @@ -315,22 +332,81 @@ export const SpeechDetailPlayer = ({ const onTimeUpdate = () => { const video = videoRef.current if (!video) return - const current = video.currentTime + let current = video.currentTime + // C173 — the preview stops at the excerpt end. `timeupdate` is coarse, so + // the playhead is clamped back to the end (fim visível) before pausing. + if (selection && previewPhase === 'playing') { + const stopAt = excerptPreviewStopAt(selection, current) + if (stopAt !== null) { + video.pause() + video.currentTime = stopAt + current = stopAt + setPreviewPhase('ended') + } + } const active = segments.find( (segment) => current >= segment.startSeconds && current < segment.endSeconds, ) setActiveStart(active?.startSeconds ?? null) } + /** C173 — the intent is a single owner; every cancellation path goes through here. */ + const clearPreviewIntent = useCallback(() => { + previewIntentRef.current = false + }, []) + + /** C173 — a deliberate stop (button, selection change, surface switch) leaves no trace. */ + const stopPreview = useCallback(() => { + clearPreviewIntent() + videoRef.current?.pause() + setPreviewPhase('idle') + }, [clearPreviewIntent]) + + /** + * C173 — plays only the selected window; the end stop is owned by `onTimeUpdate`. + * `play()` can be refused (autoplay policy / load error), so a rejected promise + * takes the phase back instead of leaving a control that promises playback. + */ + const startPreview = useCallback(() => { + const video = videoRef.current + if (!video || !selection) return + video.currentTime = selection.startSeconds + setActiveStart(selection.startSeconds) + void video + .play() + .catch(() => setPreviewPhase((phase) => (phase === 'playing' ? 'idle' : phase))) + setPreviewPhase('playing') + }, [selection]) + + /** + * C173 — the single gesture's prep from the not-yet-resolved quadrant: mark the + * intent, aim the mount seek at the window start, and ask the Câmara. The file + * arriving starts the preview (effect below); a failed/generating answer drops it. + * Every other surface offers the control with its own direct handler. + */ + const requestPreviewResolution = () => { + if (!selection) return + previewIntentRef.current = true + pendingSeekRef.current = selection.startSeconds + void requestResolution(false) + } + /** Turns the picker on with the initial range — idempotent (keeps the current one). */ const requestSelection = useCallback(() => { if (selectionDuration === null || selectionDuration < MIN_EXCERPT_SECONDS) return setSelection((current) => current ?? initialExcerptRange(segments, selectionDuration)) }, [segments, selectionDuration]) + // C173 — every path that changes the selection stops the preview first, so the + // player never keeps playing a window that is no longer the selected one. + const applySelection = (next: ExcerptRange | null) => { + stopPreview() + setSelection(next) + } + const toggleSelection = () => { if (selecting) { - setSelection(null) + applySelection(null) return } requestSelection() @@ -344,6 +420,32 @@ export const SpeechDetailPlayer = ({ return () => window.removeEventListener(SPEECH_EXCERPT_REQUEST_EVENT, requestSelection) }, [requestSelection]) + // C173 — switching surface or losing the file drops the preview (no leak). + useEffect(() => { + if (surface !== 'vod' || !playbackUrl) { + clearPreviewIntent() + setPreviewPhase('idle') + } + }, [surface, playbackUrl, clearPreviewIntent]) + + // C173 — the single gesture through a Câmara resolution: once the verified file + // is up, the pending preview starts; a generating/failed answer drops the intent + // and the honest panel stays in charge (a later, separate retry does not auto-play). + useEffect(() => { + if (!previewIntentRef.current) return + if (playbackUrl) { + clearPreviewIntent() + startPreview() + return + } + if (resolution.kind !== 'idle' && resolution.kind !== 'resolving') { + // The file will not mount: drop the intent *and* its aimed seek, so a later + // retry does not jump to a window the assessor never previewed. + clearPreviewIntent() + pendingSeekRef.current = null + } + }, [playbackUrl, resolution, startPreview, clearPreviewIntent]) + // C162 keeps the transcript click seeking; C166 turns it into a phrase magnet // while the explicit selection mode is on. const onSegmentActivate = (index: number) => { @@ -353,10 +455,16 @@ export const SpeechDetailPlayer = ({ seekTo(segment.startSeconds) return } - setSelection(extendRangeToSegment(selection, segments, index, selectionDuration)) + applySelection(extendRangeToSegment(selection, segments, index, selectionDuration)) } const seekable = youtubeSurface ? youtubeOffsetSeconds !== null : Boolean(playbackUrl) + // C173 — the honest YouTube notice: the embed cannot be told to stop, and it can + // only open at the window start when the session offset is known. + const youtubePreviewNotice = + youtubeOffsetSeconds === null + ? 'No YouTube, a pré-visualização começa no início da sessão — parar no fim exige o vídeo da Câmara.' + : 'No YouTube, a pré-visualização começa no início do trecho — parar no fim exige o vídeo da Câmara.' const renderMedia = (): ReactNode => { if (youtubeSurface && youtubeVideoId) { @@ -379,6 +487,12 @@ export const SpeechDetailPlayer = ({ preload="metadata" src={playbackUrl} onTimeUpdate={onTimeUpdate} + // C173 — a native pause (or the end-of-preview pause) leaves the + // preview; the functional update keeps the `ended` phase when our own + // `pause()` is what fires the event. + onPause={() => setPreviewPhase((phase) => (phase === 'playing' ? 'idle' : phase))} + // C173 — a native resume past the window is free playback, not a preview. + onPlay={() => setPreviewPhase((phase) => (phase === 'ended' ? 'idle' : phase))} className="aspect-video w-full rounded-lg border bg-black" > @@ -522,7 +636,7 @@ export const SpeechDetailPlayer = ({ variant="outline" className="min-h-10 w-full sm:w-auto" data-slot="speech-youtube-exit-embed" - onClick={watchYoutube} + onClick={() => watchYoutube()} > Assistir no YouTube @@ -546,6 +660,85 @@ export const SpeechDetailPlayer = ({ ) } + /** + * C173 — the preview control that lives inside the selection card (the design + * keeps it out of the share/cut/download row). It is rendered here, where the + * playback lives, and handed to the card as a slot. + */ + const renderPreviewArea = (): ReactNode => { + if (!selection) return null + + const playButton = (onClick: () => void) => ( + + ) + const youtubeArea = (onClick: () => void) => ( +
+ {playButton(onClick)} +

{youtubePreviewNotice}

+
+ ) + + if (youtubeSurface) { + // Honest posture: the embed has no stop control, so previewing only repositions. + return youtubeArea(() => seekTo(selection.startSeconds)) + } + + if (playbackUrl) { + if (previewPhase !== 'playing') return playButton(startPreview) + return ( + + ) + } + + if (vodResolvable) { + if (resolving) { + return ( + + ) + } + return playButton(requestPreviewResolution) + } + + if (youtubeVideoId) { + // YouTube-only: the embed is one click away and opens at the window start. + return youtubeArea(() => watchYoutube(selection.startSeconds)) + } + + return ( +

+ A seleção permanece ajustável. Pré-visualização não oferecida nesta superfície. +

+ ) + } + return (
{renderMedia()} @@ -557,7 +750,9 @@ export const SpeechDetailPlayer = ({ segments={segments} range={selection} durationSeconds={selectionDuration} - onChange={setSelection} + onChange={applySelection} + previewArea={renderPreviewArea()} + previewState={youtubeSurface ? 'idle' : previewPhase} /> ) : null} diff --git a/src/components/campaign/speech/SpeechExcerptControls.tsx b/src/components/campaign/speech/SpeechExcerptControls.tsx index c6cf11a1..08968dbc 100644 --- a/src/components/campaign/speech/SpeechExcerptControls.tsx +++ b/src/components/campaign/speech/SpeechExcerptControls.tsx @@ -1,6 +1,6 @@ 'use client' -import type { KeyboardEvent, PointerEvent } from 'react' +import type { KeyboardEvent, PointerEvent, ReactNode } from 'react' import { useRef } from 'react' import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock' @@ -11,9 +11,11 @@ import { secondsFromTrackRatio, snapSecondsToSegmentBoundary, type ExcerptEdge, + type ExcerptPreviewPhase, type ExcerptRange, type ExcerptSegment, } from '@/lib/speechExcerptSelection' +import { cn } from '@/lib/utils' /** Handles snap to a nearby phrase boundary within this screen distance... */ const SNAP_TOLERANCE_PX = 8 @@ -34,19 +36,30 @@ type SpeechExcerptControlsProps = { range: ExcerptRange durationSeconds: number onChange: (range: ExcerptRange) => void + /** + * C173 — the player owns the preview (playback, timers, surface); the card + * only hosts the control it renders, so no media logic leaks into the pure + * geometry component. + */ + previewArea?: ReactNode + /** C173 — presentation state of the preview: card ring while playing, header chip. */ + previewState?: ExcerptPreviewPhase } /** * C166 — the excerpt range bar: two keyboard-operable handles (`role="slider"`) * over the speech duration, with a magnet on the transcript phrase boundaries. * It only translates pointers/keys into the pure geometry (`lib/speechExcerptSelection`); - * the phrase clicks themselves stay on the transcript. + * the phrase clicks themselves stay on the transcript. C173 — the preview control + * arrives as a pre-rendered `previewArea` slot: the card frames it, the player owns it. */ export const SpeechExcerptControls = ({ segments, range, durationSeconds, onChange, + previewArea, + previewState = 'idle', }: SpeechExcerptControlsProps) => { const trackRef = useRef(null) const dragEdgeRef = useRef(null) @@ -120,15 +133,44 @@ export const SpeechExcerptControls = ({ onKeyDown: onHandleKeyDown(edge), }) + const previewChip = + previewState === 'playing' ? ( + + + ) : previewState === 'ended' ? ( + + FIM VISÍVEL · {endLabel} + + ) : null + + const rangeLabel = ( + <> + {startLabel} →{' '} + {endLabel} · {spanLabel} + + ) + return ( -
+
+ {/* C173 — while a preview state is on, it owns the header's right slot and + the range drops to its own line (gallery PARADO/REPRODUZINDO/FIM VISÍVEL). */}
Trecho selecionado - - {startLabel} →{' '} - {endLabel} · {spanLabel} - + {previewChip ?? ( + {rangeLabel} + )}
+ {previewChip ? ( +

{rangeLabel}

+ ) : null}
{durationLabel}
-

- Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o ajuste fino.{' '} - {excerptLimitsLabel} — a duração aparece enquanto seleciona. -

+
+

+ Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o ajuste + fino. {excerptLimitsLabel} — a duração aparece enquanto seleciona. +

+ {previewArea} +
) } diff --git a/src/lib/speechExcerptSelection.ts b/src/lib/speechExcerptSelection.ts index ac808841..a16a7147 100644 --- a/src/lib/speechExcerptSelection.ts +++ b/src/lib/speechExcerptSelection.ts @@ -26,6 +26,9 @@ export type ExcerptRange = { export type ExcerptEdge = 'start' | 'end' +/** C173 — the presentation phase of the excerpt preview, shared by the player and the card. */ +export type ExcerptPreviewPhase = 'idle' | 'playing' | 'ended' + export const MIN_EXCERPT_SECONDS = 5 /** @@ -50,6 +53,16 @@ export const isExcerptSelectionAvailable = ( export const rangeDurationSeconds = (range: ExcerptRange): number => range.endSeconds - range.startSeconds +/** + * C173 — the stop target of the excerpt preview: the range end once + * `currentSeconds` reaches it (inclusive). The `