mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C166): seleciona trecho do acervo e compartilha link no ponto + WhatsApp (#1013)
This commit is contained in:
parent
0c0725835b
commit
93416cd5ef
16 changed files with 1348 additions and 34 deletions
1
docs/changelog/2026-09-15-c166.md
Normal file
1
docs/changelog/2026-09-15-c166.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C166 — Acervo: selecionar trecho e compartilhar por link (YouTube no ponto + WhatsApp) (2026-09-15):** o detalhe da fala agora tem o modo explícito **"Selecionar trecho"** (o clique-para-posicionar do C162 segue intacto fora dele): um clique nas frases marca os limites com ímã nos segmentos de transcrição, as duas alças (`role="slider"`, setas ±1 s, PageUp/Down ±5 s, Home/End, ponteiro com `setPointerCapture`) fazem o ajuste fino, e a barra mostra início/fim/duração em `mm:ss` com o mínimo de 5 s e o teto de 180 s. O botão **"Compartilhar"** abre exatamente duas opções — **"Copiar link"** (mensagem completa com o intervalo + URL, feedback "Link copiado" com reset de 2 s e live region) e **"Enviar no WhatsApp"** (`wa.me/?text=`, sem destinatário, nova aba) — em Popover no ponteiro fino e bottom sheet no coarse (`useCoarsePointer`). O link é `https://www.youtube.com/watch?v=<id>&t=<offset da sessão + início, segundos inteiros>`; o fim vai na mensagem (`Trecho de <tipo|fala> (<dd/mm/aaaa>): de <mm:ss> a <mm:ss>`), porque o YouTube não codifica o fim. Sem YouTube a opção de link não é oferecida — aviso honesto no lugar, com a seleção preservada (porta do corte do C167) e MP4/fonte intactos; com offset desconhecido o link vai sem `t=` e a nota diz que abre no início da sessão. Toda a lógica é pura e client-safe: `src/lib/speechExcerptSelection.ts` (ímã, clamps 5 s/180 s, px→segundos), `src/lib/speechShare.ts` (URL + mensagem + `wa.me`) e `src/lib/speechClock.ts`, que passa a ser o dono de `formatSpeechClock`/`formatSpeechSpan` (o VM do detalhe ganhou `durationSeconds` e `speechDateLabel`). Sem migration, sem `Consent`, sem server action, sem escrita; `advisor`/`leader` seguem negados e o crédito CC BY não mudou. Testes: 32 unit puros (geometria/seleção e share), 19 RTL do player (9 pins C162 + modo, teclado, drag com ímã, copiar/WhatsApp, coarse, reset do feedback) e o e2e browserless `campaignSpeechAcervo` com as asserções SSR do novo contrato.
|
||||
98
docs/plans/c166-compartilhar-trecho-link-impl.md
Normal file
98
docs/plans/c166-compartilhar-trecho-link-impl.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Impl: Acervo: selecionar trecho e compartilhar por link (YouTube no ponto + WhatsApp)
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: #1013
|
||||
Intenção: docs/plans/c166-compartilhar-trecho-link.md
|
||||
Appetite restante: ~1–1,5 dia (herdado; sem migration, sem server action, sem escrita)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** marcar [início, fim] no detalhe do acervo (ímã de frase, 5 s–180 s, duração visível) e compartilhar um link do YouTube que abre no ponto + mensagem com o intervalo — via copiar (feedback "Link copiado") ou `wa.me/?text=` do remetente.
|
||||
- **O que NÃO negociar:** nenhum link autenticado do acervo para fora; sem escrita/`Consent`/collection/migration; `advisor`/`leader` negados; crédito CC BY e o clique-para-posicionar C162 intactos; exatamente 2 opções de share; sem YouTube → sem link + aviso honesto (seleção continua); offset desconhecido → link sem `t=` (nunca inventar).
|
||||
- **O que reavaliar:** a intenção sugere `utilities/speech` para a lógica pura — o módulo puro vai para `src/lib/speech*` (casa com o prefixo do manifesto e não carrega Payload); `formatSpeechClock` muda de dono para o layer puro (hoje em `speechViewModels.ts`); o VM precisa de campos crus (`durationSeconds`, `speechDateLabel`) que a intenção não listava.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
VM["VM do servidor<br/>youtubeVideoId/offset, segments,<br/>durationSeconds, speechDateLabel"] --> P["SpeechDetailPlayer<br/>modo + range (segundos)"]
|
||||
P --> C["SpeechExcerptControls<br/>track + alças + teclado"]
|
||||
C -->|clique-ímã| T["Transcrição<br/>marca limites no modo"]
|
||||
C -->|range| S["SpeechExcerptShare<br/>Popover fino / Sheet coarse"]
|
||||
S -->|Copiar link| L["speechShare.ts<br/>URL + mensagem"]
|
||||
S -->|Enviar no WhatsApp| W["buildWhatsAppTextShareUrl"]
|
||||
L --> Y["youtube.com/watch?v=..&t=.."]
|
||||
W --> A["wa.me/?text=Trecho de ... + URL"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) lógica pura em `src/lib/speech*` + estado no player existente + 2 componentes irmãos | B) tudo dentro de `SpeechDetailPlayer.tsx` | C) componente/player próprio do trecho.
|
||||
**Recomendação:** A — geometria e composição de link/mensagem unit-testáveis sem DOM; o player segue sendo o único (anti-goal); o contrato C162 fica atrás de um modo explícito.
|
||||
**Rejeitadas:** B porque o arquivo já tem 381 linhas e a geometria ficaria refém do jsdom; C porque é o rabbit hole "segundo player" que a intenção corta.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/speechClock.ts`** (novo): dono de `formatSpeechClock` (movido de `speechViewModels.ts`, que passa a importá-lo; `src/utilities/ai/tools/findSpeechExcerpts.ts:22` troca o import). Sem re-export-twin: dono único, 2 call sites atualizados. Necessário porque `src/lib` é proibido de importar `@/utilities/**` e o share precisa do mesmo mm:ss.
|
||||
- **`src/lib/speechExcerptSelection.ts`** (novo, puro): tipos estruturais `ExcerptSegment`/`ExcerptRange` (sem importar o VM); `MIN_EXCERPT_SECONDS = 5`, `MAX_EXCERPT_SECONDS = 180`; `selectSegmentRange(segments, index, durationSeconds)` (frase inteira; se a frase tiver < 5 s, estende até 5 s respeitando a duração; cap 180; ASR fracionário snap por `floor`/`ceil`), `extendRangeToSegment(range, segments, index, durationSeconds)` (estende para antes/depois; clique dentro/sobreposto re-seleciona a frase), `moveRangeEdge(range, edge, nextSeconds, durationSeconds)` (alça/teclado com min 5 s e max 180 s), `secondsFromTrackRatio(ratio, durationSeconds)`, `rangeDurationSeconds(range)`, `initialExcerptRange(segments, durationSeconds)`.
|
||||
- **`src/lib/speechShare.ts`** (novo, puro): `buildSpeechExcerptShare({ videoId, offsetSeconds, startSeconds, endSeconds, speechType, dateLabel })` → `{ url, message, whatsAppUrl }`; `url` = `https://www.youtube.com/watch?v=<id>&t=<Math.floor(offset+start)>` (sem `t` quando `offsetSeconds === null`); `message` literal `Trecho de <tipo|"fala"> (<dd/mm/aaaa>): de <mm:ss> a <mm:ss> <url>`; `whatsAppUrl` reusa `buildWhatsAppTextShareUrl` (`src/lib/phone.ts:63`).
|
||||
- **`speechViewModels.ts`**: `durationSeconds: number | null` e `speechDateLabel: string` no `SpeechDetailViewModel`; `formatSpeechDate` (mesmo regex de `formatSpeechAt`, só `dd/mm/aaaa`); sem `endLabel` (o controle formata `endSeconds` com `formatSpeechClock` — um campo a mais seria dado morto). Importa `formatSpeechClock` de `@/lib/speechClock`.
|
||||
- **`[id]/page.tsx`**: passa `durationSeconds`, `speechType={view.type}` e `speechDateLabel` ao player.
|
||||
- **`SpeechDetailPlayer.tsx`**: props novas; estado `selecting` e `range`; toggle "Selecionar trecho" (`aria-pressed`) na linha de ações; ao ligar o modo, inicializa `range` com `initialExcerptRange(segments, effectiveDuration)`; transcrição muda em modo seleção (`onClick` marca em vez de `seekTo`; `disabled={!seekable && !selecting}` — sem isso a seleção morreria no quadrante sem YouTube/sem offset); copy da transcrição vira "clique para marcar o trecho" no modo; renderiza os 2 irmãos novos; aviso honesto quando `!youtubeVideoId`. Fora do modo, `onClick`/`seekTo` ficam byte a byte como hoje (C162).
|
||||
- **`SpeechExcerptControls.tsx`** (novo, client): barra com trilha própria e 2 handles `role="slider"` (`aria-valuemin/max/now/valuetext`, labels "Início do trecho"/"Fim do trecho"); pointer events + `setPointerCapture` com ímã nos limites de segmento (tolerância em px) e ajuste fino; teclado setas ±1 s, PageUp/Down ±5 s, Home/End; mostra início/fim/duração e a dica "mínimo 5s · máximo 3min"; chama só as funções puras.
|
||||
- **`SpeechExcerptShare.tsx`** (novo, client): botão "Compartilhar" (desabilitado sem range válido — min 5 s); `useCoarsePointer()` → `Popover` (fino) ou `Sheet` (`SheetContent`, coarse); opções exatas "Copiar link" (estado local `idle|copied|error`, reset 2 s, live region sr-only — padrão `ContentShareButton`, classes do tema campaign, sem `--pt-red`) e "Enviar no WhatsApp" (`<a target="_blank" rel="noopener noreferrer">`); preview da mensagem; nota "abre no início da sessão" quando `youtubeOffsetSeconds === null`. "Copiar link" copia a **mensagem completa** (intervalo + URL) — o fim não cabe no `t=`, então o texto é o que circula.
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum; o gate `speechCatalog` do detalhe segue sendo a única barreira (advisor/leader intactos).
|
||||
- **UI:** Impeccable B — tema `data-theme="campaign"`; reusa `Button`, `Popover`, `Sheet`, `useCoarsePointer`; copy do rascunho; nenhum shell novo.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não se aplica: a intenção já decidiu "não apresenta dados" — duração/mm:ss são feedback de ação, não dado do acervo.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Núcleo puro + VM** (~0,5 dia): mover `formatSpeechClock`, criar `speechExcerptSelection.ts`/`speechShare.ts`, campos do VM + wiring da página; testes puros verdes.
|
||||
2. **UI** (~0,5 dia): modo no player + `SpeechExcerptControls` + `SpeechExcerptShare` (Popover/Sheet) + aviso sem-YouTube; RTL estendido.
|
||||
3. **Gates** (~0,25 dia): `pnpm gate:fast`; e2e browserless com asserções SSR novas; `pnpm push`; CI roda o e2e `campaignSpeechAcervo` (manifest já mapeia `src/lib/speech`, component/speech e `utilities/speech`).
|
||||
|
||||
## Plano de testes
|
||||
|
||||
- **Unit puro** `tests/unit/speechExcerptSelection.unit.spec.ts` + `tests/unit/speechShare.unit.spec.ts` (jsdom irrelevante): snap de segmentos fracionários; frase inteira; frase < 5 s estendida até 5 s; cap 180 em frase longa; estender para trás/frente; clique dentro re-seleciona; min 5 s/max 180 s nas alças; deltas de teclado; Home/End; px→segundos (round/clamp); fallback de duração `segments.at(-1)?.endSeconds`; URL com `t` inteiro e sem `t` no offset desconhecido; mensagem com tipo e fallback "fala"; igualdade com `buildWhatsAppTextShareUrl`.
|
||||
- **Unit RTL** `tests/unit/speechDetailPlayer.unit.spec.tsx` (estender): manter os 9 pins C162 (4 VOD + 5 YouTube) e o de no-video; novos casos — alternar modo seleção não regride o seek fora dele; em modo, clique em `button[data-start-seconds="43"]` marca (não mexe no `video.currentTime`/iframe); botões de transcrição habilitam mesmo sem `seekable`; alças por teclado mudam os labels; 180 s capado aparece no label de duração; "Copiar link" com `navigator.clipboard` mockado (precedente `campaignInviteInteractions.unit.spec.ts`) → texto = mensagem + URL e feedback "Link copiado" (reset 2 s); href do WhatsApp contém `wa.me/?text=` com o texto codificado; sem YouTube → sem "Compartilhar" + aviso; offset `null` → link sem `t=` + nota. Popover radix em jsdom tem precedente (`campaignHeaderFilterPopover.unit.spec.ts`).
|
||||
- **E2E:** `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (browserless) segue o contrato HTTP existente e ganha asserções SSR baratas do novo contrato: detalhe com YouTube contém o toggle "Selecionar trecho" e `data-slot="speech-excerpt-controls"`; detalhe só-VOD contém o aviso "Compartilhar por link exige o vídeo no YouTube" e **não** contém o botão "Compartilhar". Sem novo browser e2e — a interação é dona do jsdom RTL; um spec de browser só adicionaria flake/custo sem novo contrato de servidor.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- IFrame API/`window.YT`/postMessage/enablejsapi — não existe no repo; playhead/pause do YouTube ficam fora (seleção independente do player, sem seek no YouTube além do reload de `start`).
|
||||
- Preview do trecho, loop, zoom/linha de tempo frame-a-frame, drag livre sobre a transcrição.
|
||||
- Persistir seleção na URL (`?t=&e=`), telemetria/UTM, encurtador, página pública/OG (C167/C168).
|
||||
- Extrair a transcrição para outro componente (churn no contrato pinado), segundo player, toast sonner, fallback `document.execCommand`.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão do clique C162:** modo explícito + pins existentes rodando a cada mudança; fora do modo o `onClick` é o atual.
|
||||
- **Seleção impossível sem seek:** `disabled` passa a considerar `selecting`; teste dedicado no quadrante sem YouTube.
|
||||
- **Frase de 4 s (fixture e2e) vs mínimo de 5 s:** `initialExcerptRange`/`selectSegmentRange` estendem até 5 s dentro da duração; nunca nasce range inválido.
|
||||
- **Clipboard indisponível (contexto inseguro):** catch → "Não foi possível copiar" + live region, sem crash (padrão ContentShareButton).
|
||||
- **Offset/duração ausentes:** sem `t=`; duração cai para `segments.at(-1)?.endSeconds` e, se < 5 s, o controle de seleção não renderiza (seleção indisponível honesta).
|
||||
- **ASR fracionário:** âncoras `floor`/`ceil`, alças em inteiros, `t` com `Math.floor` — fixtures fracionárias nos testes.
|
||||
- **Popover clipado no mobile:** Sheet em ponteiro coarse (precedente `CampaignHomeActionStrip`).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (5 s–180 s com ímã; 2 opções; `t=` = offset + início; mensagem com intervalo; sem YouTube → sem link + aviso; seleção preservada).
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem migration/Consent/write; `src/lib` sem importar utilities; labels pt-BR; identificadores em inglês).
|
||||
- [ ] Contrato C162 pinado verde (9 pins) + e2e browserless atualizado; `pnpm gate:fast` limpo.
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
- **A) Lógica pura.** Opções: `src/lib/speechShare.ts` + `src/lib/speechExcerptSelection.ts` | `utilities/speech` | dentro do componente. **Recomendação:** `src/lib/speech*` — client-safe, sem Payload, casa com o prefixo do manifesto e é unit-testável sem DOM. **Rejeitadas:** `utilities/speech` (o componente cliente importaria utilities por nada); no componente (não testável).
|
||||
- **B) Estado/UI.** Opções: estado no `SpeechDetailPlayer` + `SpeechExcerptControls`/`SpeechExcerptShare` irmãos | tudo no player | player separado. **Recomendação:** estado no player (único dono do vídeo) com 2 irmãos; modo altera a transcrição. **Rejeitadas:** tudo no player (381 linhas, geometria no jsdom); player separado (anti-goal); extrair a transcrição (churn C162).
|
||||
- **C) Seleção.** Opções: track própria com 2 handles `role="slider"` + clique-ímã | 2 `<input type=range>` | handles livres. **Recomendação:** track própria — `step` nativo não encaixa em limites arbitrários de segmento; ímã de frase + ajuste fino; teclado ±1/±5, Home/End. **Rejeitadas:** ranges nativos (step/dois polegares); alças livres (erram a frase).
|
||||
- **D) Duração.** Opções: `durationSeconds` cru no VM | derivar de `durationLabel` | `endLabel` por segmento. **Recomendação:** `durationSeconds: number | null` (já selecionado no `speechPageData`), fallback no cliente para `segments.at(-1)?.endSeconds`; sem `endLabel` (o controle formata com `formatSpeechClock`). **Rejeitadas:** parsear `durationLabel` (formatação não se lê de volta).
|
||||
- **E) Datas/mensagem.** Opções: `speechDateLabel` no VM | parsear `speechAtLabel` no lib. **Recomendação:** `speechDateLabel` (dono é `formatSpeechAt`; o lib não deve conhecer o formato de exibição; `· HH:mm` não vaza). Fallback de `type` nulo: `"fala"` minúsculo (mensagem natural, sem inventar classificação). **Rejeitadas:** parsear label (frágil, acopla a mensagem ao formato do rótulo).
|
||||
- **F) Sem YouTube / offset nulo.** Opções: sem opção de link + aviso, seleção continua | link autenticado do acervo | sem seleção. **Recomendação:** sem YouTube → nenhum "Compartilhar" e aviso honesto no lugar; offset nulo → link sem `t=` com nota "abre no início da sessão". **Rejeitadas:** link autenticado (não funciona para quem recebe e vaza URL interna); bloquear a seleção (ela é a porta do C167).
|
||||
- **G) Mobile.** Opções: Popover fino / Sheet coarse via `useCoarsePointer` | só Popover | só Sheet. **Recomendação:** split com `useCoarsePointer` reusando `Popover`/`Sheet` (ambos em `src/components/ui`), precedente `CampaignHomeActionStrip`/`CampaignHomeActionButton`. **Rejeitadas:** só Popover (clipa/pequeno demais no toque); só Sheet (pesado no desktop).
|
||||
- **H) Feedback de copiar.** Opções: estado local 2 s + live region | toast sonner. **Recomendação:** local (o feedback vive na linha da opção; não cobre o popover nem some com o Sheet aberto; padrão `ContentShareButton`). **Rejeitadas:** toast (fora do contexto da opção; segundo padrão de feedback na mesma fatia).
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
5/5 — (1) todas as decisões caras (A–E, G) têm opções e rejeitadas explícitas; (2) cabe no appetite herdado: sem schema, sem servidor, 2 módulos puros + 2 componentes + 1 move; (3) rabbit holes nomeados (YT API, preview, timeline, persistência, telemetria); (4) depth check: reusa `buildWhatsAppTextShareUrl`, `Popover`/`Sheet`/`Button`, `useCoarsePointer`, o player e o VM existentes — nenhum mecanismo paralelo; (5) o aceite de produto permanece intacto (a engenharia não reescreveu o outcome).
|
||||
|
|
@ -110,6 +110,9 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
|
|||
segments={view.segments}
|
||||
initialSeconds={initialSeconds}
|
||||
sourceUrl={sourceUrl}
|
||||
durationSeconds={view.durationSeconds}
|
||||
speechType={view.type}
|
||||
speechDateLabel={view.speechDateLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
'use client'
|
||||
|
||||
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
|
||||
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon, ScissorsIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
|
||||
import { SpeechExcerptControls } from '@/components/campaign/speech/SpeechExcerptControls'
|
||||
import { SpeechExcerptShare } from '@/components/campaign/speech/SpeechExcerptShare'
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import {
|
||||
extendRangeToSegment,
|
||||
initialExcerptRange,
|
||||
MIN_EXCERPT_SECONDS,
|
||||
type ExcerptRange,
|
||||
} from '@/lib/speechExcerptSelection'
|
||||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
|
@ -52,6 +60,12 @@ type SpeechDetailPlayerProps = {
|
|||
segments: readonly SpeechDetailSegmentViewModel[]
|
||||
initialSeconds: number | null
|
||||
sourceUrl: string | null
|
||||
/** C166 — raw duration for the excerpt picker; null falls back to the last segment end. */
|
||||
durationSeconds: number | null
|
||||
/** C166 — speech type for the share message (`null` reads as "fala"). */
|
||||
speechType: string | null
|
||||
/** C166 — day label (`dd/mm/aaaa`) for the share message. */
|
||||
speechDateLabel: string
|
||||
}
|
||||
|
||||
const StatusPanel = ({
|
||||
|
|
@ -100,10 +114,16 @@ export const SpeechDetailPlayer = ({
|
|||
segments,
|
||||
initialSeconds,
|
||||
sourceUrl,
|
||||
durationSeconds,
|
||||
speechType,
|
||||
speechDateLabel,
|
||||
}: SpeechDetailPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
|
||||
const [activeStart, setActiveStart] = useState<number | null>(null)
|
||||
// C166: one state for the explicit selection mode — a non-null range means
|
||||
// the mode is on, so "selecting" cannot drift from "has a range".
|
||||
const [selection, setSelection] = useState<ExcerptRange | null>(null)
|
||||
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null
|
||||
? youtubeOffsetSeconds + (initialSeconds ?? 0)
|
||||
|
|
@ -112,6 +132,10 @@ export const SpeechDetailPlayer = ({
|
|||
|
||||
const resolving = resolution.kind === 'resolving'
|
||||
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
|
||||
const selectionDuration = durationSeconds ?? segments.at(-1)?.endSeconds ?? null
|
||||
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
|
||||
const selecting = selection !== null
|
||||
const showExcerptShare = selecting && Boolean(youtubeVideoId)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
|
|
@ -186,6 +210,27 @@ export const SpeechDetailPlayer = ({
|
|||
setActiveStart(active?.startSeconds ?? null)
|
||||
}
|
||||
|
||||
const toggleSelection = () => {
|
||||
if (selecting) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
if (selectionDuration === null || selectionDuration < MIN_EXCERPT_SECONDS) return
|
||||
setSelection(initialExcerptRange(segments, selectionDuration))
|
||||
}
|
||||
|
||||
// C162 keeps the transcript click seeking; C166 turns it into a phrase magnet
|
||||
// while the explicit selection mode is on.
|
||||
const onSegmentActivate = (index: number) => {
|
||||
const segment = segments[index]
|
||||
if (!segment) return
|
||||
if (selection === null || selectionDuration === null) {
|
||||
seekTo(segment.startSeconds)
|
||||
return
|
||||
}
|
||||
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
|
||||
}
|
||||
|
||||
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
|
||||
const renderMedia = (): ReactNode => {
|
||||
|
|
@ -302,9 +347,44 @@ export const SpeechDetailPlayer = ({
|
|||
<div data-slot="speech-player" aria-busy={resolving || undefined}>
|
||||
{renderMedia()}
|
||||
|
||||
{selection && selectionDuration !== null ? (
|
||||
<SpeechExcerptControls
|
||||
segments={segments}
|
||||
range={selection}
|
||||
durationSeconds={selectionDuration}
|
||||
onChange={setSelection}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{selectionAvailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={selecting ? 'secondary' : 'outline'}
|
||||
className="min-h-10"
|
||||
aria-pressed={selecting}
|
||||
data-slot="speech-excerpt-toggle"
|
||||
onClick={toggleSelection}
|
||||
>
|
||||
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Selecionar trecho
|
||||
</Button>
|
||||
) : null}
|
||||
{showExcerptShare && selection && youtubeVideoId ? (
|
||||
<SpeechExcerptShare
|
||||
videoId={youtubeVideoId}
|
||||
offsetSeconds={youtubeOffsetSeconds}
|
||||
startSeconds={selection.startSeconds}
|
||||
endSeconds={selection.endSeconds}
|
||||
speechType={speechType}
|
||||
dateLabel={speechDateLabel}
|
||||
/>
|
||||
) : null}
|
||||
{vodResolvable ? (
|
||||
<Button
|
||||
// While the excerpt share is on screen it is the primary action;
|
||||
// the download steps back to outline instead of splitting the CTA.
|
||||
variant={showExcerptShare ? 'outline' : 'default'}
|
||||
className="min-h-10"
|
||||
disabled={resolving}
|
||||
aria-busy={resolving || undefined}
|
||||
|
|
@ -336,32 +416,56 @@ export const SpeechDetailPlayer = ({
|
|||
|
||||
{inlineNotice}
|
||||
|
||||
{!youtubeVideoId && selectionAvailable ? (
|
||||
<div className="mt-3 rounded-lg border bg-muted/40 px-4 py-3">
|
||||
<p className="text-xs font-medium text-foreground/90">
|
||||
Compartilhar por link exige o vídeo no YouTube
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Esta fala não tem vídeo no YouTube — não há link externo que abra no ponto. Você ainda
|
||||
pode baixar o MP4 ou abrir a fonte oficial, e a seleção de trecho continua disponível.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{segments.length > 0 ? (
|
||||
<section className="mt-5" aria-label="Transcrição">
|
||||
<p className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
{seekable ? 'Transcrição (clique para posicionar)' : 'Transcrição'}
|
||||
{selecting
|
||||
? 'Transcrição (clique nas frases para marcar o trecho)'
|
||||
: seekable
|
||||
? 'Transcrição (clique para posicionar)'
|
||||
: 'Transcrição'}
|
||||
</p>
|
||||
<ol className="mt-2 space-y-0.5">
|
||||
{segments.map((segment) => {
|
||||
{segments.map((segment, index) => {
|
||||
const active = activeStart === segment.startSeconds
|
||||
const inSelection =
|
||||
selection !== null &&
|
||||
segment.endSeconds > selection.startSeconds &&
|
||||
segment.startSeconds < selection.endSeconds
|
||||
return (
|
||||
<li key={segment.startSeconds}>
|
||||
<button
|
||||
type="button"
|
||||
data-start-seconds={segment.startSeconds}
|
||||
onClick={() => seekTo(segment.startSeconds)}
|
||||
disabled={!seekable}
|
||||
data-in-selection={inSelection || undefined}
|
||||
onClick={() => onSegmentActivate(index)}
|
||||
disabled={!seekable && !selecting}
|
||||
className={cn(
|
||||
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors',
|
||||
seekable && 'hover:bg-muted',
|
||||
seekable && !selecting && 'hover:bg-muted',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-muted',
|
||||
!selecting && active && 'bg-muted',
|
||||
inSelection && 'border-l-2 border-primary bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-12 shrink-0 pt-0.5 text-xs tabular-nums',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
inSelection || (!selecting && active)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{segment.startLabel}
|
||||
|
|
|
|||
178
src/components/campaign/speech/SpeechExcerptControls.tsx
Normal file
178
src/components/campaign/speech/SpeechExcerptControls.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client'
|
||||
|
||||
import type { KeyboardEvent, PointerEvent } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
|
||||
import {
|
||||
MAX_EXCERPT_SECONDS,
|
||||
MIN_EXCERPT_SECONDS,
|
||||
moveRangeEdge,
|
||||
rangeDurationSeconds,
|
||||
secondsFromTrackRatio,
|
||||
snapSecondsToSegmentBoundary,
|
||||
type ExcerptEdge,
|
||||
type ExcerptRange,
|
||||
type ExcerptSegment,
|
||||
} from '@/lib/speechExcerptSelection'
|
||||
|
||||
/** Handles snap to a nearby phrase boundary within this screen distance... */
|
||||
const SNAP_TOLERANCE_PX = 8
|
||||
/** ...never more than this in seconds, so a wide/short track cannot over-magnetize. */
|
||||
const SNAP_TOLERANCE_MAX_SECONDS = 2
|
||||
|
||||
const HANDLE_KEY_DELTAS: Record<string, number> = {
|
||||
ArrowLeft: -1,
|
||||
ArrowRight: 1,
|
||||
PageDown: -5,
|
||||
PageUp: 5,
|
||||
}
|
||||
|
||||
const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · máximo ${MAX_EXCERPT_SECONDS / 60}min`
|
||||
|
||||
type SpeechExcerptControlsProps = {
|
||||
segments: readonly ExcerptSegment[]
|
||||
range: ExcerptRange
|
||||
durationSeconds: number
|
||||
onChange: (range: ExcerptRange) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const SpeechExcerptControls = ({
|
||||
segments,
|
||||
range,
|
||||
durationSeconds,
|
||||
onChange,
|
||||
}: SpeechExcerptControlsProps) => {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const dragEdgeRef = useRef<ExcerptEdge | null>(null)
|
||||
|
||||
const durationLabel = formatSpeechClock(durationSeconds)
|
||||
const startLabel = formatSpeechClock(range.startSeconds)
|
||||
const endLabel = formatSpeechClock(range.endSeconds)
|
||||
const spanLabel = formatSpeechSpan(rangeDurationSeconds(range))
|
||||
|
||||
const percentOf = (seconds: number): number =>
|
||||
durationSeconds > 0 ? Math.min(100, Math.max(0, (seconds / durationSeconds) * 100)) : 0
|
||||
|
||||
const secondsFromPointer = (clientX: number): number => {
|
||||
const track = trackRef.current
|
||||
if (!track) return range.startSeconds
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0
|
||||
const raw = secondsFromTrackRatio(ratio, durationSeconds)
|
||||
const tolerance =
|
||||
rect.width > 0
|
||||
? Math.min(SNAP_TOLERANCE_MAX_SECONDS, (SNAP_TOLERANCE_PX / rect.width) * durationSeconds)
|
||||
: 0
|
||||
return snapSecondsToSegmentBoundary(raw, segments, tolerance)
|
||||
}
|
||||
|
||||
const onHandlePointerDown = (edge: ExcerptEdge) => (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.focus()
|
||||
dragEdgeRef.current = edge
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const onHandlePointerMove = (edge: ExcerptEdge) => (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragEdgeRef.current !== edge) return
|
||||
onChange(moveRangeEdge(range, edge, secondsFromPointer(event.clientX), durationSeconds))
|
||||
}
|
||||
|
||||
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
|
||||
dragEdgeRef.current = null
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const onHandleKeyDown = (edge: ExcerptEdge) => (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const current = edge === 'start' ? range.startSeconds : range.endSeconds
|
||||
const delta =
|
||||
event.key === 'Home'
|
||||
? -durationSeconds
|
||||
: event.key === 'End'
|
||||
? durationSeconds
|
||||
: (HANDLE_KEY_DELTAS[event.key] ?? 0)
|
||||
if (delta === 0) return
|
||||
event.preventDefault()
|
||||
onChange(moveRangeEdge(range, edge, current + delta, durationSeconds))
|
||||
}
|
||||
|
||||
const handleProps = (edge: ExcerptEdge, seconds: number) => ({
|
||||
role: 'slider' as const,
|
||||
tabIndex: 0,
|
||||
'aria-label': edge === 'start' ? 'Início do trecho' : 'Fim do trecho',
|
||||
'aria-valuemin':
|
||||
edge === 'start'
|
||||
? Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
|
||||
: range.startSeconds + MIN_EXCERPT_SECONDS,
|
||||
'aria-valuemax':
|
||||
edge === 'start'
|
||||
? range.endSeconds - MIN_EXCERPT_SECONDS
|
||||
: Math.min(durationSeconds, range.startSeconds + MAX_EXCERPT_SECONDS),
|
||||
'aria-valuenow': seconds,
|
||||
'aria-valuetext': formatSpeechClock(seconds),
|
||||
'data-excerpt-edge': edge,
|
||||
onPointerDown: onHandlePointerDown(edge),
|
||||
onPointerMove: onHandlePointerMove(edge),
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
onKeyDown: onHandleKeyDown(edge),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border bg-muted/30 p-3" data-slot="speech-excerpt-controls">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
data-slot="speech-excerpt-track"
|
||||
className="relative mt-4 h-9 touch-none select-none"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
|
||||
<div
|
||||
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{
|
||||
left: `${percentOf(range.startSeconds)}%`,
|
||||
right: `${100 - percentOf(range.endSeconds)}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
{...handleProps('start', range.startSeconds)}
|
||||
className="absolute top-1/2 h-7 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-sm border border-primary bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-coarse:h-8 pointer-coarse:w-5"
|
||||
style={{ left: `${percentOf(range.startSeconds)}%` }}
|
||||
/>
|
||||
<div
|
||||
{...handleProps('end', range.endSeconds)}
|
||||
className="absolute top-1/2 h-7 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-sm border border-primary bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-coarse:h-8 pointer-coarse:w-5"
|
||||
style={{ left: `${percentOf(range.endSeconds)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>00:00</span>
|
||||
<span>{startLabel}</span>
|
||||
<span>{endLabel}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
166
src/components/campaign/speech/SpeechExcerptShare.tsx
Normal file
166
src/components/campaign/speech/SpeechExcerptShare.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
'use client'
|
||||
|
||||
import { CheckIcon, CopyIcon, Share2Icon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { WhatsAppIcon } from '@/components/socialIcons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/Sheet'
|
||||
import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
|
||||
import { buildSpeechExcerptShare } from '@/lib/speechShare'
|
||||
|
||||
const COPY_FEEDBACK_RESET_MS = 2000
|
||||
|
||||
type CopyFeedback = 'idle' | 'copied' | 'error'
|
||||
|
||||
const COPY_LABELS: Record<CopyFeedback, string> = {
|
||||
idle: 'Copiar link',
|
||||
copied: 'Link copiado',
|
||||
error: 'Não foi possível copiar',
|
||||
}
|
||||
|
||||
const COPY_LIVE_MESSAGES: Record<CopyFeedback, string> = {
|
||||
idle: '',
|
||||
copied: 'Link copiado.',
|
||||
error: 'Não foi possível copiar o link.',
|
||||
}
|
||||
|
||||
type SpeechExcerptShareProps = {
|
||||
videoId: string
|
||||
/** Session offset in seconds; null makes the link open at the session start. */
|
||||
offsetSeconds: number | null
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
speechType: string | null
|
||||
dateLabel: string
|
||||
}
|
||||
|
||||
const OPTION_CLASS =
|
||||
'flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
||||
|
||||
/**
|
||||
* C166 — the excerpt share control: one "Compartilhar" action with exactly two
|
||||
* options (copy the ready message with the interval + link, or open the
|
||||
* sender's own WhatsApp via `wa.me`). The message is what circulates because
|
||||
* YouTube cannot encode the excerpt end. Popover on fine pointers, bottom sheet
|
||||
* on coarse ones; the copy feedback lives in the option row (local state), so
|
||||
* it never covers the control and vanishes on its own.
|
||||
*/
|
||||
export const SpeechExcerptShare = ({
|
||||
videoId,
|
||||
offsetSeconds,
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
speechType,
|
||||
dateLabel,
|
||||
}: SpeechExcerptShareProps) => {
|
||||
const isCoarsePointer = useCoarsePointer()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
if (copyFeedback === 'idle') return
|
||||
const reset = setTimeout(() => setCopyFeedback('idle'), COPY_FEEDBACK_RESET_MS)
|
||||
return () => clearTimeout(reset)
|
||||
}, [copyFeedback])
|
||||
|
||||
const share = buildSpeechExcerptShare({
|
||||
videoId,
|
||||
offsetSeconds,
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
speechType,
|
||||
dateLabel,
|
||||
})
|
||||
|
||||
const copyMessage = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(share.message)
|
||||
setCopyFeedback('copied')
|
||||
} catch {
|
||||
setCopyFeedback('error')
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = (onClick?: () => void, expanded?: boolean) => (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-10"
|
||||
onClick={onClick}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Share2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
Compartilhar
|
||||
</Button>
|
||||
)
|
||||
|
||||
const options = (
|
||||
<div data-slot="speech-excerpt-share-options">
|
||||
<button type="button" onClick={copyMessage} className={OPTION_CLASS}>
|
||||
{copyFeedback === 'copied' ? (
|
||||
<CheckIcon className="size-4 text-emerald-600" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{COPY_LABELS[copyFeedback]}
|
||||
</button>
|
||||
<a
|
||||
href={share.whatsAppUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
className={OPTION_CLASS}
|
||||
>
|
||||
<WhatsAppIcon className="size-4 text-[#25D366]" />
|
||||
Enviar no WhatsApp
|
||||
</a>
|
||||
<p className="border-t px-2 py-2 text-[11px] leading-relaxed break-words text-muted-foreground">
|
||||
{share.message}
|
||||
</p>
|
||||
{offsetSeconds === null ? (
|
||||
<p className="px-2 pb-2 text-[11px] text-muted-foreground">
|
||||
O link abre o vídeo no início da sessão.
|
||||
</p>
|
||||
) : null}
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{COPY_LIVE_MESSAGES[copyFeedback]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isCoarsePointer) {
|
||||
return (
|
||||
<div data-slot="speech-excerpt-share">
|
||||
{trigger(() => setOpen(true), open)}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="bottom" className="gap-2 px-2 pb-4">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Compartilhar trecho</SheetTitle>
|
||||
<SheetDescription>O link abre o vídeo no ponto escolhido.</SheetDescription>
|
||||
</SheetHeader>
|
||||
{options}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="speech-excerpt-share">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger()}</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-1">
|
||||
{options}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/lib/speechClock.ts
Normal file
27
src/lib/speechClock.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* C166 — the one owner of the acervo clock label ("01:12" / "1:02:03").
|
||||
* Lives in `lib/` because the pure share/selection modules build messages with
|
||||
* it and `lib/` never imports `utilities/`.
|
||||
*/
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/** Transcript timestamp ("01:12" / "1:02:03"). */
|
||||
export const formatSpeechClock = (seconds: number): string => {
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
|
||||
/** Compact duration label ("45s" / "1min27s" / "1h02min") for speech surfaces. */
|
||||
export const formatSpeechSpan = (seconds: number): string => {
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
if (hours > 0) return `${hours}h${pad(minutes)}min`
|
||||
if (minutes > 0) return `${minutes}min${pad(secs)}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
174
src/lib/speechExcerptSelection.ts
Normal file
174
src/lib/speechExcerptSelection.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* C166 — pure geometry for the acervo excerpt picker: whole-second
|
||||
* [start, end] ranges snapped to transcript segment boundaries, clamped to the
|
||||
* excerpt limits (5 s..180 s) and to the speech duration. No DOM and no React:
|
||||
* the client controls only translate pointers/keys into these calls.
|
||||
*/
|
||||
|
||||
export type ExcerptSegment = {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
}
|
||||
|
||||
export type ExcerptRange = {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
}
|
||||
|
||||
export type ExcerptEdge = 'start' | 'end'
|
||||
|
||||
export const MIN_EXCERPT_SECONDS = 5
|
||||
export const MAX_EXCERPT_SECONDS = 180
|
||||
|
||||
export const rangeDurationSeconds = (range: ExcerptRange): number =>
|
||||
range.endSeconds - range.startSeconds
|
||||
|
||||
const durationFloor = (durationSeconds: number): number =>
|
||||
Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0
|
||||
|
||||
const wholeFloor = (seconds: number): number => (Number.isFinite(seconds) ? Math.floor(seconds) : 0)
|
||||
const wholeCeil = (seconds: number): number => (Number.isFinite(seconds) ? Math.ceil(seconds) : 0)
|
||||
|
||||
/**
|
||||
* Clamp a raw [start, end] pair into a valid excerpt range: whole seconds,
|
||||
* inside [0, duration], at least 5 s and at most 180 s long (a short pair
|
||||
* grows forward, and backwards when the speech ends first). Returns null when
|
||||
* the duration cannot hold the minimum — the caller then hides the picker.
|
||||
*/
|
||||
export const normalizeExcerptRange = (
|
||||
rawStart: number,
|
||||
rawEnd: number,
|
||||
durationSeconds: number,
|
||||
): ExcerptRange | null => {
|
||||
const duration = durationFloor(durationSeconds)
|
||||
if (duration < MIN_EXCERPT_SECONDS) return null
|
||||
|
||||
let start = Math.min(Math.max(0, wholeFloor(rawStart)), duration - MIN_EXCERPT_SECONDS)
|
||||
let end = Math.min(Math.max(0, wholeCeil(rawEnd)), duration)
|
||||
|
||||
if (end < start) [start, end] = [end, start]
|
||||
if (end - start < MIN_EXCERPT_SECONDS) {
|
||||
end = Math.min(duration, start + MIN_EXCERPT_SECONDS)
|
||||
start = Math.max(0, end - MIN_EXCERPT_SECONDS)
|
||||
}
|
||||
if (end - start > MAX_EXCERPT_SECONDS) end = start + MAX_EXCERPT_SECONDS
|
||||
|
||||
return { startSeconds: start, endSeconds: end }
|
||||
}
|
||||
|
||||
/** The picked phrase as the initial range (or the first 5 s when there is no ASR). */
|
||||
export const initialExcerptRange = (
|
||||
segments: readonly ExcerptSegment[],
|
||||
durationSeconds: number,
|
||||
): ExcerptRange | null =>
|
||||
segments.length > 0
|
||||
? selectSegmentRange(segments, 0, durationSeconds)
|
||||
: normalizeExcerptRange(0, MIN_EXCERPT_SECONDS, durationSeconds)
|
||||
|
||||
/** A whole phrase, clamped by the duration and by the 5 s/180 s limits. */
|
||||
export const selectSegmentRange = (
|
||||
segments: readonly ExcerptSegment[],
|
||||
index: number,
|
||||
durationSeconds: number,
|
||||
): ExcerptRange | null => {
|
||||
const segment = segments[index]
|
||||
if (!segment) return null
|
||||
return normalizeExcerptRange(segment.startSeconds, segment.endSeconds, durationSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjacent ASR phrases share a fractional boundary (one ends at 50.1 and the
|
||||
* next starts at 50.1): a click whose edge lands this close to the current
|
||||
* range edge is the neighbour phrase, not an overlap. Without it, selecting
|
||||
* the next phrase right after the current one would re-select instead of
|
||||
* extending.
|
||||
*/
|
||||
const ADJACENT_PHRASE_EPSILON_SECONDS = 1
|
||||
|
||||
/**
|
||||
* Phrase click in selection mode: a phrase after the range extends the end, a
|
||||
* phrase before it moves the start back, and a phrase already inside the range
|
||||
* re-selects that phrase alone. Segment seconds are fractional (ASR), so the
|
||||
* start floors and the end ceils while clamping.
|
||||
*/
|
||||
export const extendRangeToSegment = (
|
||||
range: ExcerptRange,
|
||||
segments: readonly ExcerptSegment[],
|
||||
index: number,
|
||||
durationSeconds: number,
|
||||
): ExcerptRange | null => {
|
||||
const segment = segments[index]
|
||||
if (!segment) return range
|
||||
if (segment.endSeconds <= range.startSeconds + ADJACENT_PHRASE_EPSILON_SECONDS) {
|
||||
return normalizeExcerptRange(segment.startSeconds, range.endSeconds, durationSeconds)
|
||||
}
|
||||
if (segment.startSeconds >= range.endSeconds - ADJACENT_PHRASE_EPSILON_SECONDS) {
|
||||
return normalizeExcerptRange(range.startSeconds, segment.endSeconds, durationSeconds)
|
||||
}
|
||||
return selectSegmentRange(segments, index, durationSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle/keyboard move of one edge: the other edge stays put, the span stays
|
||||
* between 5 s and 180 s, and the range stays inside the speech.
|
||||
*/
|
||||
export const moveRangeEdge = (
|
||||
range: ExcerptRange,
|
||||
edge: ExcerptEdge,
|
||||
nextSeconds: number,
|
||||
durationSeconds: number,
|
||||
): ExcerptRange => {
|
||||
const duration = durationFloor(durationSeconds)
|
||||
const requested = Math.round(Number.isFinite(nextSeconds) ? nextSeconds : 0)
|
||||
|
||||
if (edge === 'start') {
|
||||
const minimum = Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
|
||||
const maximum = range.endSeconds - MIN_EXCERPT_SECONDS
|
||||
return {
|
||||
startSeconds: Math.min(Math.max(requested, minimum), maximum),
|
||||
endSeconds: range.endSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
const minimum = range.startSeconds + MIN_EXCERPT_SECONDS
|
||||
const maximum = Math.min(duration, range.startSeconds + MAX_EXCERPT_SECONDS)
|
||||
return {
|
||||
startSeconds: range.startSeconds,
|
||||
endSeconds: Math.min(Math.max(requested, minimum), maximum),
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointer ratio on the track (0..1) → whole seconds inside the speech. */
|
||||
export const secondsFromTrackRatio = (ratio: number, durationSeconds: number): number => {
|
||||
const clamped = Math.min(Math.max(Number.isFinite(ratio) ? ratio : 0, 0), 1)
|
||||
return Math.round(clamped * durationFloor(durationSeconds))
|
||||
}
|
||||
|
||||
/** Segment boundaries as whole seconds, ascending — the magnet targets of the handles. */
|
||||
export const segmentBoundarySeconds = (segments: readonly ExcerptSegment[]): number[] =>
|
||||
[
|
||||
...new Set(
|
||||
segments.flatMap((segment) => [
|
||||
wholeFloor(segment.startSeconds),
|
||||
wholeCeil(segment.endSeconds),
|
||||
]),
|
||||
),
|
||||
].sort((left, right) => left - right)
|
||||
|
||||
/** Snap to the nearest segment boundary within the tolerance (px converted to seconds). */
|
||||
export const snapSecondsToSegmentBoundary = (
|
||||
seconds: number,
|
||||
segments: readonly ExcerptSegment[],
|
||||
toleranceSeconds: number,
|
||||
): number => {
|
||||
let best = seconds
|
||||
let bestDistance = toleranceSeconds
|
||||
for (const boundary of segmentBoundarySeconds(segments)) {
|
||||
const distance = Math.abs(boundary - seconds)
|
||||
if (distance <= bestDistance) {
|
||||
best = boundary
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
66
src/lib/speechShare.ts
Normal file
66
src/lib/speechShare.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* C166 — pure share text for an acervo excerpt: the YouTube watch URL opening
|
||||
* at the picked point (session offset + excerpt start, whole seconds) and the
|
||||
* message that carries the interval, since YouTube cannot encode the end. The
|
||||
* WhatsApp URL reuses the repo's recipient-less `wa.me` builder.
|
||||
*/
|
||||
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
|
||||
import { formatSpeechClock } from '@/lib/speechClock'
|
||||
|
||||
const EXCERPT_SHARE_FALLBACK_TYPE = 'fala'
|
||||
|
||||
export type SpeechExcerptShareInput = {
|
||||
videoId: string
|
||||
/** Session offset in seconds; null means unknowable and the link carries no `t=`. */
|
||||
offsetSeconds: number | null
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
speechType: string | null
|
||||
/** Day label (`dd/mm/aaaa`) of the speech. */
|
||||
dateLabel: string
|
||||
}
|
||||
|
||||
export type SpeechExcerptShare = {
|
||||
url: string
|
||||
message: string
|
||||
whatsAppUrl: string
|
||||
}
|
||||
|
||||
/** `https://www.youtube.com/watch?v=<id>&t=<segundos>` — no `t=` when the offset is unknown. */
|
||||
export const buildSpeechExcerptYoutubeUrl = (
|
||||
videoId: string,
|
||||
offsetSeconds: number | null,
|
||||
startSeconds: number,
|
||||
): string => {
|
||||
const url = new URL('https://www.youtube.com/watch')
|
||||
url.searchParams.set('v', videoId)
|
||||
if (offsetSeconds !== null && Number.isFinite(offsetSeconds)) {
|
||||
url.searchParams.set(
|
||||
't',
|
||||
String(Math.max(0, Math.floor(offsetSeconds + Math.max(0, startSeconds)))),
|
||||
)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const buildSpeechExcerptMessage = ({
|
||||
speechType,
|
||||
dateLabel,
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
url,
|
||||
}: Omit<SpeechExcerptShareInput, 'videoId' | 'offsetSeconds'> & { url: string }): string =>
|
||||
`Trecho de ${speechType?.trim() || EXCERPT_SHARE_FALLBACK_TYPE} (${dateLabel}): de ${formatSpeechClock(startSeconds)} a ${formatSpeechClock(endSeconds)} ${url}`
|
||||
|
||||
export const buildSpeechExcerptShare = (input: SpeechExcerptShareInput): SpeechExcerptShare => {
|
||||
const { videoId, offsetSeconds, startSeconds, endSeconds, speechType, dateLabel } = input
|
||||
const url = buildSpeechExcerptYoutubeUrl(videoId, offsetSeconds, startSeconds)
|
||||
const message = buildSpeechExcerptMessage({
|
||||
speechType,
|
||||
dateLabel,
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
url,
|
||||
})
|
||||
return { url, message, whatsAppUrl: buildWhatsAppTextShareUrl(message) }
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod'
|
|||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
|
||||
import { formatSpeechClock } from '@/lib/speechClock'
|
||||
import {
|
||||
buildSpeechExcerpt,
|
||||
EXCERPT_VARIANT_TARGETS,
|
||||
|
|
@ -16,11 +17,7 @@ import {
|
|||
type SpeechExcerptRerankChoice,
|
||||
} from '@/utilities/ai/rerankSpeechExcerpts'
|
||||
import { loadSegmentsForSpeeches } from '@/utilities/speech/speechPageData'
|
||||
import {
|
||||
buildWatchHref,
|
||||
formatSpeechAt,
|
||||
formatSpeechClock,
|
||||
} from '@/utilities/speech/speechViewModels'
|
||||
import { buildWatchHref, formatSpeechAt } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
const DENIED_MESSAGE = 'Leitura do acervo de falas negada.'
|
||||
const NO_TERMS_MESSAGE = 'Tema sem termos de busca: informe palavras com 3 caracteres ou mais.'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
* list/detail render.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
|
||||
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
|
||||
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
|
||||
import {
|
||||
|
|
@ -66,9 +67,13 @@ export type SpeechDetailSegmentViewModel = {
|
|||
export type SpeechDetailViewModel = {
|
||||
id: number
|
||||
speechAtLabel: string
|
||||
/** Day-only label (`dd/mm/aaaa`) for the share message. */
|
||||
speechDateLabel: string
|
||||
type: string | null
|
||||
phase: string | null
|
||||
durationLabel: string | null
|
||||
/** Raw duration for the C166 excerpt picker; null when the row has none. */
|
||||
durationSeconds: number | null
|
||||
presidingOfficer: string | null
|
||||
summary: string | null
|
||||
officialTranscript: string | null
|
||||
|
|
@ -87,8 +92,6 @@ export type SpeechDetailViewModel = {
|
|||
vodResolvable: boolean
|
||||
}
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* `speechAt` is the Câmara wall-clock string ("2026-08-11T18:48", no timezone);
|
||||
* slicing it keeps the local reading and avoids a `Date` shifting it.
|
||||
|
|
@ -100,27 +103,18 @@ export const formatSpeechAt = (speechAt: string): string => {
|
|||
return `${day}/${month}/${year} · ${hour}:${minute}`
|
||||
}
|
||||
|
||||
const formatSpeechDuration = (seconds: number | null | undefined): string | null => {
|
||||
if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) {
|
||||
return null
|
||||
}
|
||||
const total = Math.round(seconds)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
if (hours > 0) return `${hours}h${pad(minutes)}min`
|
||||
if (minutes > 0) return `${minutes}min${pad(secs)}s`
|
||||
return `${secs}s`
|
||||
/** Day-only label (`dd/mm/aaaa`) for share messages; the clock stays out of it. */
|
||||
const formatSpeechDate = (speechAt: string): string => {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(speechAt)
|
||||
if (!match) return speechAt
|
||||
const [, year, month, day] = match
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
/** Transcript timestamp ("01:12" / "1:02:03"). */
|
||||
export const formatSpeechClock = (seconds: number): string => {
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
const formatSpeechDuration = (seconds: number | null | undefined): string | null =>
|
||||
seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0
|
||||
? null
|
||||
: formatSpeechSpan(seconds)
|
||||
|
||||
type MunicipalityMentions = {
|
||||
mentionedMunicipalities?: (number | Municipality)[] | null
|
||||
|
|
@ -250,9 +244,11 @@ export const toSpeechDetailViewModel = ({
|
|||
return {
|
||||
id: speech.id,
|
||||
speechAtLabel: formatSpeechAt(speech.speechAt),
|
||||
speechDateLabel: formatSpeechDate(speech.speechAt),
|
||||
type: speech.type ?? null,
|
||||
phase: speech.phase ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
durationSeconds: speech.durationSeconds ?? null,
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
summary: speech.summary ?? null,
|
||||
officialTranscript: speech.officialTranscript ?? null,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,11 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(detailHtml).toContain('Baixar vídeo (MP4)')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
|
||||
// C166 — the excerpt picker's door is server-rendered; its link limit is
|
||||
// not in this quadrant (the share control itself only appears in the mode,
|
||||
// which is client interaction).
|
||||
expect(detailHtml).toContain('Selecionar trecho')
|
||||
expect(detailHtml).not.toContain('Compartilhar por link exige o vídeo no YouTube')
|
||||
expect(detailHtml).not.toContain('<video')
|
||||
expect(detailHtml).not.toContain('vod.camara.leg.br')
|
||||
})
|
||||
|
|
@ -185,6 +190,10 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
const html = rendered(await detail.text())
|
||||
expect(html).toContain('Assistir o trecho')
|
||||
expect(html).toContain('Baixar vídeo (MP4)')
|
||||
// C166 — no YouTube means no excerpt link: the honest notice replaces it
|
||||
// while the selection door stays offered.
|
||||
expect(html).toContain('Selecionar trecho')
|
||||
expect(html).toContain('Compartilhar por link exige o vídeo no YouTube')
|
||||
expect(html).not.toContain('<video')
|
||||
expect(html).not.toContain('<iframe')
|
||||
// The stored link is an eligibility signal, never a rendered URL.
|
||||
|
|
|
|||
|
|
@ -190,6 +190,9 @@ describe('speech acervo (C154)', () => {
|
|||
expect(view.id).toBe(id)
|
||||
expect(view.segments).toHaveLength(2)
|
||||
expect(view.segments[1]?.startSeconds).toBe(3)
|
||||
// C166 — the excerpt picker needs the raw duration and the day-only label.
|
||||
expect(view.durationSeconds).toBe(252)
|
||||
expect(view.speechDateLabel).toBe('07/02/2023')
|
||||
expect(view.municipalities.map((municipality) => municipality.name)).toContain(
|
||||
'Feira de Santana',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
|
||||
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||
|
||||
const PLAYBACK_URL = 'https://vod.camara.leg.br/trecho.mp4'
|
||||
const DOWNLOAD_URL = 'https://vod.camara.leg.br/trecho-download.mp4'
|
||||
|
||||
|
|
@ -53,6 +56,9 @@ const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> =
|
|||
segments={SEGMENTS}
|
||||
initialSeconds={null}
|
||||
sourceUrl={null}
|
||||
durationSeconds={300}
|
||||
speechType="BREVES COMUNICAÇÕES"
|
||||
speechDateLabel="11/08/2026"
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
|
@ -77,10 +83,13 @@ beforeAll(() => {
|
|||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
resetCampaignCoarsePointerForTests()
|
||||
stubMatchMedia(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
|
@ -258,3 +267,202 @@ describe('SpeechDetailPlayer — no-video quadrant', () => {
|
|||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
|
||||
const segmentButton = (seconds: number) =>
|
||||
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
|
||||
const startSlider = () => screen.getByRole('slider', { name: 'Início do trecho' })
|
||||
const endSlider = () => screen.getByRole('slider', { name: 'Fim do trecho' })
|
||||
const toggleSelection = () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
|
||||
|
||||
it('turns phrase clicks into magnet selection while the mode is on, keeping C162 outside it', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('5')
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
|
||||
expect(endSlider().getAttribute('aria-valuemax')).toBe('180')
|
||||
// C162 contract: the selection mode does not seek the embed.
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
|
||||
// Clicking a phrase inside the range re-selects it alone.
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('43')
|
||||
|
||||
// Outside the mode the transcript click seeks exactly as before.
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
|
||||
})
|
||||
|
||||
it('adjusts the edges by keyboard with the 5 s/180 s limits', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
|
||||
fireEvent.keyDown(startSlider(), { key: 'ArrowRight' })
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('1')
|
||||
fireEvent.keyDown(startSlider(), { key: 'PageUp' })
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('6')
|
||||
fireEvent.keyDown(startSlider(), { key: 'End' })
|
||||
expect(startSlider().getAttribute('aria-valuenow')).toBe('45')
|
||||
|
||||
fireEvent.keyDown(endSlider(), { key: 'End' })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('225')
|
||||
expect(screen.getByText(/3min00s/)).toBeDefined()
|
||||
|
||||
fireEvent.keyDown(endSlider(), { key: 'ArrowLeft' })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('224')
|
||||
fireEvent.keyDown(endSlider(), { key: 'Home' })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
|
||||
})
|
||||
|
||||
it('keeps the selection available without YouTube and states the link limit honestly', () => {
|
||||
renderPlayer({ youtubeVideoId: null, vodResolvable: false })
|
||||
|
||||
expect(screen.getByText('Compartilhar por link exige o vídeo no YouTube')).toBeDefined()
|
||||
toggleSelection()
|
||||
expect(screen.queryByRole('button', { name: /^compartilhar$/i })).toBeNull()
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
|
||||
})
|
||||
|
||||
it('enables the phrases in selection mode and drops t when the offset is unknown', async () => {
|
||||
const writeText = vi.fn(async (_text: string) => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
|
||||
expect(segmentButton(43).disabled).toBe(true)
|
||||
toggleSelection()
|
||||
expect(segmentButton(43).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(segmentButton(43))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
expect(await screen.findByText('O link abre o vídeo no início da sessão.')).toBeDefined()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled())
|
||||
const copied = String(writeText.mock.calls[0]?.[0])
|
||||
expect(copied).toContain('https://www.youtube.com/watch?v=lLhRDkSPw0A')
|
||||
expect(copied).not.toContain('&t=')
|
||||
})
|
||||
|
||||
it('copies the message with the interval and links WhatsApp with the same text', async () => {
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(segmentButton(43))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
|
||||
const whatsapp = await screen.findByRole('link', { name: /enviar no whatsapp/i })
|
||||
expect(whatsapp.getAttribute('href')).toContain('https://wa.me/?text=')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:00 a 00:50 https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2634',
|
||||
),
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Link copiado' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('reports a copy failure without crashing', async () => {
|
||||
const writeText = vi.fn(async () => {
|
||||
throw new Error('denied')
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Copiar link' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Não foi possível copiar' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens the share options as a bottom sheet on coarse pointers', async () => {
|
||||
stubMatchMedia(true)
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
|
||||
expect(await screen.findByText('Compartilhar trecho')).toBeDefined()
|
||||
expect(screen.getByText('O link abre o vídeo no ponto escolhido.')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
|
||||
expect(screen.getByRole('link', { name: /enviar no whatsapp/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('drags a handle with the phrase-boundary magnet', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
toggleSelection()
|
||||
|
||||
const track = document.querySelector('[data-slot="speech-excerpt-track"]') as HTMLDivElement
|
||||
track.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
width: 300,
|
||||
top: 0,
|
||||
height: 36,
|
||||
right: 300,
|
||||
bottom: 36,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
const handle = document.querySelector('[data-excerpt-edge="end"]') as HTMLDivElement
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
handle.setPointerCapture = setPointerCapture
|
||||
handle.hasPointerCapture = vi.fn(() => true)
|
||||
handle.releasePointerCapture = releasePointerCapture
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 5 })
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(1)
|
||||
|
||||
// 44s is within the magnet window of the 43s phrase start.
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 44 })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('43')
|
||||
|
||||
// 150s is far from every boundary, so the handle moves freely.
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 150 })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('150')
|
||||
|
||||
// 250s would pass the 180 s span cap from the 0 s start.
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 250 })
|
||||
expect(endSlider().getAttribute('aria-valuenow')).toBe('180')
|
||||
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 250 })
|
||||
expect(releasePointerCapture).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the copy feedback after its window', async () => {
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
toggleSelection()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
|
||||
const copyButton = await screen.findByRole('button', { name: 'Copiar link' })
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(copyButton)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('button', { name: 'Link copiado' })).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
176
tests/unit/speechExcerptSelection.unit.spec.ts
Normal file
176
tests/unit/speechExcerptSelection.unit.spec.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extendRangeToSegment,
|
||||
initialExcerptRange,
|
||||
moveRangeEdge,
|
||||
normalizeExcerptRange,
|
||||
rangeDurationSeconds,
|
||||
secondsFromTrackRatio,
|
||||
segmentBoundarySeconds,
|
||||
selectSegmentRange,
|
||||
snapSecondsToSegmentBoundary,
|
||||
} from '@/lib/speechExcerptSelection'
|
||||
|
||||
/** ASR segments are fractional seconds — the picker works in whole seconds. */
|
||||
const SEGMENTS = [
|
||||
{ startSeconds: 0, endSeconds: 4.2 },
|
||||
{ startSeconds: 43.6, endSeconds: 50.1 },
|
||||
{ startSeconds: 50.1, endSeconds: 130.9 },
|
||||
{ startSeconds: 130.9, endSeconds: 252 },
|
||||
] as const
|
||||
|
||||
describe('normalizeExcerptRange', () => {
|
||||
it('never leaves 0..duration and reads whole seconds', () => {
|
||||
expect(normalizeExcerptRange(10.4, 20.7, 100)).toEqual({
|
||||
startSeconds: 10,
|
||||
endSeconds: 21,
|
||||
})
|
||||
expect(normalizeExcerptRange(-5, 500, 100)).toEqual({ startSeconds: 0, endSeconds: 100 })
|
||||
expect(normalizeExcerptRange(80, 20, 100)).toEqual({ startSeconds: 20, endSeconds: 80 })
|
||||
})
|
||||
|
||||
it('grows a short range to the 5 s minimum', () => {
|
||||
expect(normalizeExcerptRange(0, 4, 100)).toEqual({ startSeconds: 0, endSeconds: 5 })
|
||||
})
|
||||
|
||||
it('grows backwards when the speech ends first', () => {
|
||||
expect(normalizeExcerptRange(248, 252, 252)).toEqual({ startSeconds: 247, endSeconds: 252 })
|
||||
})
|
||||
|
||||
it('caps at 180 s', () => {
|
||||
expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 180 })
|
||||
expect(normalizeExcerptRange(500, 500, 600)).toEqual({ startSeconds: 500, endSeconds: 505 })
|
||||
})
|
||||
|
||||
it('is null when the duration cannot hold the minimum', () => {
|
||||
expect(normalizeExcerptRange(0, 4, 4)).toBeNull()
|
||||
expect(normalizeExcerptRange(0, 4, Number.NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats non-finite edges as zero instead of leaking NaN', () => {
|
||||
expect(normalizeExcerptRange(Number.NaN, Number.NaN, 100)).toEqual({
|
||||
startSeconds: 0,
|
||||
endSeconds: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectSegmentRange', () => {
|
||||
it('selects the whole phrase with fractional edges snapped outward', () => {
|
||||
expect(selectSegmentRange(SEGMENTS, 1, 252)).toEqual({ startSeconds: 43, endSeconds: 51 })
|
||||
})
|
||||
|
||||
it('extends a phrase shorter than 5 s', () => {
|
||||
expect(selectSegmentRange(SEGMENTS, 0, 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
|
||||
})
|
||||
|
||||
it('is null for an unknown phrase or an impossible speech', () => {
|
||||
expect(selectSegmentRange(SEGMENTS, 9, 252)).toBeNull()
|
||||
expect(selectSegmentRange(SEGMENTS, 0, 3)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialExcerptRange', () => {
|
||||
it('starts on the first phrase', () => {
|
||||
expect(initialExcerptRange(SEGMENTS, 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
|
||||
})
|
||||
|
||||
it('falls back to the first 5 s without ASR', () => {
|
||||
expect(initialExcerptRange([], 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
|
||||
expect(initialExcerptRange([], 3)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extendRangeToSegment', () => {
|
||||
const range = { startSeconds: 43, endSeconds: 51 }
|
||||
|
||||
it('extends forward to the clicked phrase end', () => {
|
||||
expect(extendRangeToSegment(range, SEGMENTS, 2, 252)).toEqual({
|
||||
startSeconds: 43,
|
||||
endSeconds: 131,
|
||||
})
|
||||
})
|
||||
|
||||
it('extends backwards to the clicked phrase start', () => {
|
||||
expect(extendRangeToSegment(range, SEGMENTS, 0, 252)).toEqual({
|
||||
startSeconds: 0,
|
||||
endSeconds: 51,
|
||||
})
|
||||
})
|
||||
|
||||
it('re-selects a phrase already inside the range', () => {
|
||||
expect(extendRangeToSegment({ startSeconds: 0, endSeconds: 131 }, SEGMENTS, 1, 252)).toEqual({
|
||||
startSeconds: 43,
|
||||
endSeconds: 51,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the range when the phrase is unknown', () => {
|
||||
expect(extendRangeToSegment(range, SEGMENTS, 9, 252)).toEqual(range)
|
||||
})
|
||||
|
||||
it('caps the extension at 180 s when the clicked phrase is long', () => {
|
||||
expect(extendRangeToSegment(range, SEGMENTS, 3, 252)).toEqual({
|
||||
startSeconds: 43,
|
||||
endSeconds: 223,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveRangeEdge', () => {
|
||||
const range = { startSeconds: 43, endSeconds: 51 }
|
||||
|
||||
it('moves the start down to the beginning and up to the end minus 5 s', () => {
|
||||
expect(moveRangeEdge(range, 'start', 0, 252)).toEqual({ startSeconds: 0, endSeconds: 51 })
|
||||
expect(moveRangeEdge(range, 'start', 80, 252)).toEqual({ startSeconds: 46, endSeconds: 51 })
|
||||
expect(moveRangeEdge(range, 'start', 20, 252)).toEqual({ startSeconds: 20, endSeconds: 51 })
|
||||
})
|
||||
|
||||
it('moves the end inside start + 5 s and the 180 s cap', () => {
|
||||
expect(moveRangeEdge(range, 'end', 0, 252)).toEqual({ startSeconds: 43, endSeconds: 48 })
|
||||
expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 223 })
|
||||
expect(moveRangeEdge(range, 'end', 200, 252)).toEqual({ startSeconds: 43, endSeconds: 200 })
|
||||
})
|
||||
|
||||
it('never leaves the speech duration', () => {
|
||||
expect(moveRangeEdge({ startSeconds: 198, endSeconds: 203 }, 'end', 500, 203)).toEqual({
|
||||
startSeconds: 198,
|
||||
endSeconds: 203,
|
||||
})
|
||||
expect(moveRangeEdge({ startSeconds: 240, endSeconds: 252 }, 'start', 10, 252)).toEqual({
|
||||
startSeconds: 72,
|
||||
endSeconds: 252,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-finite input', () => {
|
||||
expect(moveRangeEdge(range, 'end', Number.NaN, 252)).toEqual({
|
||||
startSeconds: 43,
|
||||
endSeconds: 48,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('track geometry', () => {
|
||||
it('converts the pointer ratio to whole seconds inside the duration', () => {
|
||||
expect(secondsFromTrackRatio(0.5, 100)).toBe(50)
|
||||
expect(secondsFromTrackRatio(0.333, 90)).toBe(30)
|
||||
expect(secondsFromTrackRatio(-1, 100)).toBe(0)
|
||||
expect(secondsFromTrackRatio(2, 100)).toBe(100)
|
||||
expect(secondsFromTrackRatio(Number.NaN, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('lists the phrase boundaries as ascending whole seconds without duplicates', () => {
|
||||
expect(segmentBoundarySeconds(SEGMENTS)).toEqual([0, 5, 43, 50, 51, 130, 131, 252])
|
||||
})
|
||||
|
||||
it('snaps to a boundary within the tolerance and stays put outside it', () => {
|
||||
expect(snapSecondsToSegmentBoundary(45, SEGMENTS, 3)).toBe(43)
|
||||
expect(snapSecondsToSegmentBoundary(45, SEGMENTS, 1)).toBe(45)
|
||||
})
|
||||
|
||||
it('derives the span between the edges', () => {
|
||||
expect(rangeDurationSeconds({ startSeconds: 43, endSeconds: 130 })).toBe(87)
|
||||
})
|
||||
})
|
||||
108
tests/unit/speechShare.unit.spec.ts
Normal file
108
tests/unit/speechShare.unit.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSpeechExcerptMessage,
|
||||
buildSpeechExcerptShare,
|
||||
buildSpeechExcerptYoutubeUrl,
|
||||
} from '@/lib/speechShare'
|
||||
|
||||
const decodeWaText = (url: string): string =>
|
||||
decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
|
||||
|
||||
describe('buildSpeechExcerptYoutubeUrl', () => {
|
||||
it('opens the watch page at the session offset plus the excerpt start', () => {
|
||||
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', 2634, 43)).toBe(
|
||||
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
|
||||
)
|
||||
})
|
||||
|
||||
it('floors fractional seconds — the link carries whole seconds', () => {
|
||||
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', 2634.6, 43.9)).toBe(
|
||||
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2678',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits t when the session offset is unknown', () => {
|
||||
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', null, 43)).toBe(
|
||||
'https://www.youtube.com/watch?v=lLhRDkSPw0A',
|
||||
)
|
||||
})
|
||||
|
||||
it('never goes negative', () => {
|
||||
expect(buildSpeechExcerptYoutubeUrl('id', 10, -30)).toBe(
|
||||
'https://www.youtube.com/watch?v=id&t=10',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpeechExcerptMessage', () => {
|
||||
it('carries the interval and the link, since YouTube cannot encode the end', () => {
|
||||
expect(
|
||||
buildSpeechExcerptMessage({
|
||||
speechType: 'BREVES COMUNICAÇÕES',
|
||||
dateLabel: '11/08/2026',
|
||||
startSeconds: 43,
|
||||
endSeconds: 130,
|
||||
url: 'https://www.youtube.com/watch?v=id&t=2634',
|
||||
}),
|
||||
).toBe(
|
||||
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:43 a 02:10 https://www.youtube.com/watch?v=id&t=2634',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to "fala" when the speech has no type', () => {
|
||||
const message = buildSpeechExcerptMessage({
|
||||
speechType: ' ',
|
||||
dateLabel: '11/08/2026',
|
||||
startSeconds: 0,
|
||||
endSeconds: 65,
|
||||
url: 'https://www.youtube.com/watch?v=id',
|
||||
})
|
||||
expect(message).toBe(
|
||||
'Trecho de fala (11/08/2026): de 00:00 a 01:05 https://www.youtube.com/watch?v=id',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the hour in the clock when the speech is long', () => {
|
||||
const message = buildSpeechExcerptMessage({
|
||||
speechType: null,
|
||||
dateLabel: '11/08/2026',
|
||||
startSeconds: 3723,
|
||||
endSeconds: 3843,
|
||||
url: 'https://www.youtube.com/watch?v=id',
|
||||
})
|
||||
expect(message).toContain('de 1:02:03 a 1:04:03')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpeechExcerptShare', () => {
|
||||
const input = {
|
||||
videoId: 'lLhRDkSPw0A',
|
||||
offsetSeconds: 2634,
|
||||
startSeconds: 43,
|
||||
endSeconds: 130,
|
||||
speechType: 'BREVES COMUNICAÇÕES',
|
||||
dateLabel: '11/08/2026',
|
||||
} as const
|
||||
|
||||
it('bundles the positioned URL and the message with the interval', () => {
|
||||
const share = buildSpeechExcerptShare(input)
|
||||
expect(share.url).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677')
|
||||
expect(share.message).toBe(
|
||||
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:43 a 02:10 https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the recipient-less wa.me builder with the exact message', () => {
|
||||
const share = buildSpeechExcerptShare(input)
|
||||
expect(share.whatsAppUrl.startsWith('https://wa.me/?text=')).toBe(true)
|
||||
expect(decodeWaText(share.whatsAppUrl)).toBe(share.message)
|
||||
})
|
||||
|
||||
it('shares a link without t when the session offset is unknown', () => {
|
||||
const share = buildSpeechExcerptShare({ ...input, offsetSeconds: null })
|
||||
expect(share.url).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A')
|
||||
expect(share.message).toContain('https://www.youtube.com/watch?v=lLhRDkSPw0A')
|
||||
expect(share.message).not.toContain('&t=')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue