feat(C173): pré-visualizar no player o trecho selecionado (Closes #1083)

This commit is contained in:
Francisco Solla 2026-09-16 22:00:59 -03:00
parent b73fae7d49
commit 2430ed5ebb
7 changed files with 529 additions and 23 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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<SpeechCutViewModel | null>(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<ExcerptPreviewPhase>('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<number | null>(() =>
@ -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"
>
<track kind="captions" />
@ -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()}
>
<YoutubeIcon />
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) => (
<Button
type="button"
variant="outline"
className={PREVIEW_BUTTON_CLASS}
data-slot="speech-excerpt-preview"
onClick={onClick}
>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Pré-visualizar trecho
</Button>
)
const youtubeArea = (onClick: () => void) => (
<div className="flex w-full flex-col gap-2 sm:w-[300px] sm:shrink-0">
{playButton(onClick)}
<p className="text-[11px] leading-5 text-muted-foreground">{youtubePreviewNotice}</p>
</div>
)
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 (
<Button
type="button"
variant="outline"
className={cn(PREVIEW_BUTTON_CLASS, 'border-primary text-primary')}
data-slot="speech-excerpt-preview"
onClick={stopPreview}
>
<PauseIcon data-icon="inline-start" aria-hidden="true" />
Parar pré-visualização
</Button>
)
}
if (vodResolvable) {
if (resolving) {
return (
<Button
type="button"
variant="outline"
className="w-full cursor-wait border-border bg-muted/50 text-muted-foreground opacity-60"
data-slot="speech-excerpt-preview"
aria-busy="true"
disabled
>
<Spinner data-icon="inline-start" aria-label="Resolvendo o trecho" />
Resolvendo trecho…
</Button>
)
}
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 (
<p className="max-w-[430px] text-[11px] leading-5 text-muted-foreground">
A seleção permanece ajustável. Pré-visualização não oferecida nesta superfície.
</p>
)
}
return (
<div data-slot="speech-player" aria-busy={resolving || undefined}>
{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}

View file

@ -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<HTMLDivElement>(null)
const dragEdgeRef = useRef<ExcerptEdge | null>(null)
@ -120,15 +133,44 @@ export const SpeechExcerptControls = ({
onKeyDown: onHandleKeyDown(edge),
})
const previewChip =
previewState === 'playing' ? (
<span className="inline-flex h-5 items-center gap-1 rounded-full bg-primary/10 px-2 text-[10px] font-medium text-primary">
<span className="size-1.5 rounded-full bg-primary" aria-hidden="true" />
REPRODUZINDO
</span>
) : previewState === 'ended' ? (
<span className="text-[10px] font-medium text-muted-foreground">
FIM VISÍVEL · {endLabel}
</span>
) : null
const rangeLabel = (
<>
<span className="text-foreground">{startLabel}</span> →{' '}
<span className="text-foreground">{endLabel}</span> · {spanLabel}
</>
)
return (
<div className="mt-3 rounded-lg border bg-muted/30 p-3" data-slot="speech-excerpt-controls">
<div
className={cn(
'mt-3 rounded-lg border bg-muted/30 p-3',
previewState === 'playing' && 'ring-1 ring-primary/30',
)}
data-slot="speech-excerpt-controls"
>
{/* 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). */}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-primary">Trecho selecionado</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-foreground">{startLabel}</span> →{' '}
<span className="text-foreground">{endLabel}</span> · {spanLabel}
</span>
{previewChip ?? (
<span className="text-xs text-muted-foreground tabular-nums">{rangeLabel}</span>
)}
</div>
{previewChip ? (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">{rangeLabel}</p>
) : null}
<div
ref={trackRef}
@ -162,10 +204,18 @@ export const SpeechExcerptControls = ({
<span>{durationLabel}</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o ajuste fino.{' '}
{excerptLimitsLabel} — a duração aparece enquanto seleciona.
</p>
<div
className={cn(
'mt-3 flex flex-wrap items-end justify-between gap-3 pt-3',
previewState === 'idle' ? 'sm:border-t sm:border-border' : 'border-t border-border',
)}
>
<p className="min-w-0 flex-1 text-[11px] leading-relaxed text-muted-foreground">
Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o ajuste
fino. {excerptLimitsLabel} — a duração aparece enquanto seleciona.
</p>
{previewArea}
</div>
</div>
)
}

View file

@ -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 `<video>` `timeupdate` fires at
* ~4 Hz and can overshoot, so the returned value is what the player clamps back
* to before pausing. Null while the playhead is still inside the window. Pure
* and DOM-free — the rule is testable without a player, like the picker geometry.
*/
export const excerptPreviewStopAt = (range: ExcerptRange, currentSeconds: number): number | null =>
Number.isFinite(currentSeconds) && currentSeconds >= range.endSeconds ? range.endSeconds : null
const durationFloor = (durationSeconds: number): number =>
Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0

View file

@ -36,6 +36,8 @@ const gerando = { status: 'success' as const, resolution: { state: 'gerando' as
const indisponivel = { status: 'success' as const, resolution: { state: 'indisponivel' as const } }
const fetchMock = vi.fn()
const playMock = vi.fn(() => Promise.resolve())
const pauseMock = vi.fn()
const respondWith = (payload: unknown, ok = true) => {
fetchMock.mockResolvedValue({ ok, json: async () => payload })
@ -80,7 +82,11 @@ beforeAll(() => {
// jsdom does not implement media playback; the seek contract is what matters.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: () => Promise.resolve(),
value: () => playMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
configurable: true,
value: () => pauseMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', {
configurable: true,
@ -91,6 +97,8 @@ beforeAll(() => {
beforeEach(() => {
fetchMock.mockReset()
playMock.mockClear()
pauseMock.mockClear()
vi.stubGlobal('fetch', fetchMock)
resetCampaignCoarsePointerForTests()
stubMatchMedia(false)
@ -723,3 +731,194 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
})
})
describe('SpeechDetailPlayer — C173 preview the selected excerpt', () => {
const previewSlot = () =>
document.querySelector('[data-slot="speech-excerpt-preview"]') as HTMLButtonElement | null
const playPreview = () =>
fireEvent.click(screen.getByRole('button', { name: /^pré-visualizar trecho$/i }))
const stopPreview = () => screen.queryByRole('button', { name: /parar pré-visualização/i })
const selectWindow = (seconds: number) => {
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
// Twice: the first click extends to the phrase, the second re-selects it alone.
fireEvent.click(segmentButton(seconds))
fireEvent.click(segmentButton(seconds))
// Pin the range the helper promises: [seconds, seconds + the phrase length].
expect(
screen.getByRole('slider', { name: 'Início do trecho' }).getAttribute('aria-valuenow'),
).toBe(String(seconds))
}
const mountedVideo = async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer()
fireEvent.click(watchExcerptButton())
await waitFor(() => expect(videoElement()).not.toBeNull())
return videoElement()!
}
it('plays only the selected window and stops at its end', async () => {
const video = await mountedVideo()
selectWindow(43)
playPreview()
expect(video.currentTime).toBe(43)
expect(playMock).toHaveBeenCalled()
expect(stopPreview()).not.toBeNull()
expect(screen.getByText('REPRODUZINDO')).toBeDefined()
// timeupdate is coarse: the playhead overshoots and is clamped back to the end.
pauseMock.mockClear()
video.currentTime = 50.4
fireEvent.timeUpdate(video)
expect(pauseMock).toHaveBeenCalled()
expect(video.currentTime).toBe(50)
expect(stopPreview()).toBeNull()
expect(screen.getByText(/FIM VISÍVEL · 00:50/)).toBeDefined()
expect(previewSlot()?.textContent).toContain('Pré-visualizar trecho')
})
it('stops the preview when the selection changes', async () => {
await mountedVideo()
selectWindow(43)
playPreview()
expect(stopPreview()).not.toBeNull()
pauseMock.mockClear()
fireEvent.click(segmentButton(0))
expect(pauseMock).toHaveBeenCalled()
expect(stopPreview()).toBeNull()
})
it('leaves the preview on a native pause', async () => {
const video = await mountedVideo()
selectWindow(43)
playPreview()
fireEvent.pause(video)
expect(stopPreview()).toBeNull()
})
it('cancels the preview when the surface switches to the embed', async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer({
youtubeVideoId: YOUTUBE_ID,
youtubeOffsetSeconds: 2634,
initialSeconds: 100,
})
fireEvent.click(watchExcerptButton())
await waitFor(() => expect(videoElement()).not.toBeNull())
selectWindow(43)
playPreview()
expect(stopPreview()).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: /assistir no youtube/i }))
expect(iframeElement()).not.toBeNull()
expect(stopPreview()).toBeNull()
expect(screen.queryByText('FIM VISÍVEL · 00:50')).toBeNull()
})
it('resolves and plays the window in one gesture from the unresolved state', async () => {
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
fetchMock.mockReturnValue(pending.promise)
renderPlayer()
selectWindow(43)
expect(previewSlot()?.textContent).toContain('Pré-visualizar trecho')
fireEvent.click(previewSlot()!)
await waitFor(() => {
expect(previewSlot()?.getAttribute('aria-busy')).toBe('true')
})
expect(previewSlot()?.disabled).toBe(true)
expect(previewSlot()?.textContent).toContain('Resolvendo trecho')
await act(async () => {
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
})
await waitFor(() => expect(videoElement()).not.toBeNull())
expect(playMock).toHaveBeenCalled()
// The window start survives the resolution: the preview does not open at 0.
expect(videoElement()?.currentTime).toBe(43)
expect(stopPreview()).not.toBeNull()
})
it('discards the preview intent when the resolution fails', async () => {
respondWith(indisponivel)
renderPlayer()
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
playPreview()
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
// The control is offered again; nothing is playing.
expect(previewSlot()?.textContent).toContain('Pré-visualizar trecho')
// A separate retry must not auto-play the window the assessor never got.
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
fireEvent.click(screen.getByRole('button', { name: /tentar novamente/i }))
await waitFor(() => expect(videoElement()).not.toBeNull())
expect(playMock).not.toHaveBeenCalled()
expect(stopPreview()).toBeNull()
})
it('repositions the embed on YouTube and never promises the stop', async () => {
respondWith(pronto(PLAYBACK_URL, DOWNLOAD_URL))
renderPlayer({
youtubeVideoId: YOUTUBE_ID,
youtubeOffsetSeconds: 2634,
initialSeconds: 100,
})
fireEvent.click(watchExcerptButton())
await waitFor(() => expect(videoElement()).not.toBeNull())
selectWindow(43)
fireEvent.click(screen.getByRole('button', { name: /assistir no youtube/i }))
expect(iframeElement()).not.toBeNull()
playPreview()
expect(iframeElement()?.getAttribute('src')).toBe(
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2677`,
)
expect(videoElement()).toBeNull()
expect(stopPreview()).toBeNull()
expect(
screen.getByText(
'No YouTube, a pré-visualização começa no início do trecho — parar no fim exige o vídeo da Câmara.',
),
).toBeDefined()
})
it('opens the embed at the window start when only YouTube exists', () => {
renderPlayer({ youtubeVideoId: YOUTUBE_ID, youtubeOffsetSeconds: 2634, vodResolvable: false })
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
expect(iframeElement()).toBeNull()
playPreview()
expect(iframeElement()?.getAttribute('src')).toBe(
`https://www.youtube.com/embed/${YOUTUBE_ID}?playsinline=1&rel=0&start=2634`,
)
expect(stopPreview()).toBeNull()
})
it('offers no preview without any in-page surface', () => {
renderPlayer({ youtubeVideoId: null, vodResolvable: false })
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
expect(previewSlot()).toBeNull()
expect(
screen.getByText(
'A seleção permanece ajustável. Pré-visualização não oferecida nesta superfície.',
),
).toBeDefined()
})
})

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
excerptPreviewStopAt,
excerptSelectionDuration,
extendRangeToSegment,
initialExcerptRange,
@ -189,3 +190,27 @@ describe('track geometry', () => {
expect(isExcerptSelectionAvailable(null, [{ startSeconds: 0, endSeconds: 2 }])).toBe(false)
})
})
// C173 — the pure rule behind "the preview stops at the end of the excerpt".
describe('excerptPreviewStopAt', () => {
const range = { startSeconds: 43, endSeconds: 50 }
it('keeps playing while the playhead is inside the window', () => {
expect(excerptPreviewStopAt(range, 43)).toBeNull()
expect(excerptPreviewStopAt(range, 49.9)).toBeNull()
})
it('stops exactly at the end, inclusively', () => {
expect(excerptPreviewStopAt(range, 50)).toBe(50)
})
it('clamps back to the end when timeupdate overshoots', () => {
expect(excerptPreviewStopAt(range, 50.3)).toBe(50)
expect(excerptPreviewStopAt(range, 91)).toBe(50)
})
it('never stops on a non-finite playhead', () => {
expect(excerptPreviewStopAt(range, Number.NaN)).toBeNull()
expect(excerptPreviewStopAt(range, Number.POSITIVE_INFINITY)).toBeNull()
})
})