From 93416cd5ef6aea4eaf6b0cd60d3fd41bf3116c9a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 15 Sep 2026 07:31:34 -0300 Subject: [PATCH] feat(C166): seleciona trecho do acervo e compartilha link no ponto + WhatsApp (#1013) --- docs/changelog/2026-09-15-c166.md | 1 + .../c166-compartilhar-trecho-link-impl.md | 98 +++++++++ .../(app)/comunicacao/acervo/[id]/page.tsx | 3 + .../campaign/speech/SpeechDetailPlayer.tsx | 120 +++++++++- .../campaign/speech/SpeechExcerptControls.tsx | 178 +++++++++++++++ .../campaign/speech/SpeechExcerptShare.tsx | 166 ++++++++++++++ src/lib/speechClock.ts | 27 +++ src/lib/speechExcerptSelection.ts | 174 +++++++++++++++ src/lib/speechShare.ts | 66 ++++++ src/utilities/ai/tools/findSpeechExcerpts.ts | 7 +- src/utilities/speech/speechViewModels.ts | 38 ++-- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 9 + tests/int/speechAcervo.int.spec.ts | 3 + tests/unit/speechDetailPlayer.unit.spec.tsx | 208 ++++++++++++++++++ .../unit/speechExcerptSelection.unit.spec.ts | 176 +++++++++++++++ tests/unit/speechShare.unit.spec.ts | 108 +++++++++ 16 files changed, 1348 insertions(+), 34 deletions(-) create mode 100644 docs/changelog/2026-09-15-c166.md create mode 100644 docs/plans/c166-compartilhar-trecho-link-impl.md create mode 100644 src/components/campaign/speech/SpeechExcerptControls.tsx create mode 100644 src/components/campaign/speech/SpeechExcerptShare.tsx create mode 100644 src/lib/speechClock.ts create mode 100644 src/lib/speechExcerptSelection.ts create mode 100644 src/lib/speechShare.ts create mode 100644 tests/unit/speechExcerptSelection.unit.spec.ts create mode 100644 tests/unit/speechShare.unit.spec.ts diff --git a/docs/changelog/2026-09-15-c166.md b/docs/changelog/2026-09-15-c166.md new file mode 100644 index 00000000..4c2c28b9 --- /dev/null +++ b/docs/changelog/2026-09-15-c166.md @@ -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=&t=`; o fim vai na mensagem (`Trecho de (
): de a `), 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. diff --git a/docs/plans/c166-compartilhar-trecho-link-impl.md b/docs/plans/c166-compartilhar-trecho-link-impl.md new file mode 100644 index 00000000..3749c1c6 --- /dev/null +++ b/docs/plans/c166-compartilhar-trecho-link-impl.md @@ -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
youtubeVideoId/offset, segments,
durationSeconds, speechDateLabel"] --> P["SpeechDetailPlayer
modo + range (segundos)"] + P --> C["SpeechExcerptControls
track + alças + teclado"] + C -->|clique-ímã| T["Transcrição
marca limites no modo"] + C -->|range| S["SpeechExcerptShare
Popover fino / Sheet coarse"] + S -->|Copiar link| L["speechShare.ts
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=&t=` (sem `t` quando `offsetSeconds === null`); `message` literal `Trecho de (
): de a `; `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" (``); 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 `` | 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). diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx index 050d27ea..aec71159 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx @@ -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} /> diff --git a/src/components/campaign/speech/SpeechDetailPlayer.tsx b/src/components/campaign/speech/SpeechDetailPlayer.tsx index de7d65c7..e59ca42c 100644 --- a/src/components/campaign/speech/SpeechDetailPlayer.tsx +++ b/src/components/campaign/speech/SpeechDetailPlayer.tsx @@ -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(null) const [resolution, setResolution] = useState({ kind: 'idle' }) const [activeStart, setActiveStart] = useState(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(null) const [youtubeStart, setYoutubeStart] = useState(() => 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 = ({
{renderMedia()} + {selection && selectionDuration !== null ? ( + + ) : null} +
+ {selectionAvailable ? ( + + ) : null} + {showExcerptShare && selection && youtubeVideoId ? ( + + ) : null} {vodResolvable ? (
+ ) : null} + {segments.length > 0 ? (

- {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'}

    - {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 (
  1. + ) + + const options = ( +
    + + setOpen(false)} + className={OPTION_CLASS} + > + + Enviar no WhatsApp + +

    + {share.message} +

    + {offsetSeconds === null ? ( +

    + O link abre o vídeo no início da sessão. +

    + ) : null} + + {COPY_LIVE_MESSAGES[copyFeedback]} + +
    + ) + + if (isCoarsePointer) { + return ( +
    + {trigger(() => setOpen(true), open)} + + + + Compartilhar trecho + O link abre o vídeo no ponto escolhido. + + {options} + + +
    + ) + } + + return ( +
    + + {trigger()} + + {options} + + +
    + ) +} diff --git a/src/lib/speechClock.ts b/src/lib/speechClock.ts new file mode 100644 index 00000000..209baaf4 --- /dev/null +++ b/src/lib/speechClock.ts @@ -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` +} diff --git a/src/lib/speechExcerptSelection.ts b/src/lib/speechExcerptSelection.ts new file mode 100644 index 00000000..efd183b1 --- /dev/null +++ b/src/lib/speechExcerptSelection.ts @@ -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 +} diff --git a/src/lib/speechShare.ts b/src/lib/speechShare.ts new file mode 100644 index 00000000..f2e3a517 --- /dev/null +++ b/src/lib/speechShare.ts @@ -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=&t=` — 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 & { 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) } +} diff --git a/src/utilities/ai/tools/findSpeechExcerpts.ts b/src/utilities/ai/tools/findSpeechExcerpts.ts index 25655eca..4d097c57 100644 --- a/src/utilities/ai/tools/findSpeechExcerpts.ts +++ b/src/utilities/ai/tools/findSpeechExcerpts.ts @@ -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.' diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index 44d3db0e..9b8ea6cc 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -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, diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts index afceed17..583931ee 100644 --- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts +++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts @@ -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(' { 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(' { 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', ) diff --git a/tests/unit/speechDetailPlayer.unit.spec.tsx b/tests/unit/speechDetailPlayer.unit.spec.tsx index 124cfd5b..33fe508c 100644 --- a/tests/unit/speechDetailPlayer.unit.spec.tsx +++ b/tests/unit/speechDetailPlayer.unit.spec.tsx @@ -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[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() + }) +}) diff --git a/tests/unit/speechExcerptSelection.unit.spec.ts b/tests/unit/speechExcerptSelection.unit.spec.ts new file mode 100644 index 00000000..f468b7fe --- /dev/null +++ b/tests/unit/speechExcerptSelection.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/speechShare.unit.spec.ts b/tests/unit/speechShare.unit.spec.ts new file mode 100644 index 00000000..9cd50054 --- /dev/null +++ b/tests/unit/speechShare.unit.spec.ts @@ -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=') + }) +})