mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C173): pré-visualizar no player o trecho selecionado (Closes #1083)
This commit is contained in:
parent
45738b16f0
commit
f8b0d5ecac
7 changed files with 529 additions and 23 deletions
1
docs/changelog/2026-09-16-c173.md
Normal file
1
docs/changelog/2026-09-16-c173.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue