import { captureClientException } from "@agent-native/core/client/analytics"; import { appBasePath } from "@agent-native/core/client/api-path"; import { useT } from "@agent-native/core/client/i18n"; import { isLoomEmbedUrl, LOOM_START_MS_QUERY_PARAM, loomEmbedUrlWithTimestamp, } from "@shared/loom"; import { IconBolt, IconPlayerPlay } from "@tabler/icons-react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { resolveMediaDurationMs } from "@/components/player/media-duration"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { useMseVideoSource } from "@/hooks/use-mse-video-source"; import { parsePlaybackSpeed, readPlaybackSpeedPreference, savePlaybackSpeedPreference, } from "@/lib/playback-speed"; import { captureVideoThumbnailBlob, thumbnailUrlHasVisibleContent, uploadRecordingThumbnail, } from "@/lib/thumbnail-capture"; import { getExcludedRanges, parseEdits, type TrimRange, } from "@/lib/timestamp-mapping"; import { cn } from "@/lib/utils"; import { CaptionsOverlay } from "./captions-overlay"; import { CtaButton } from "./cta-button"; import { PlaybackCommentOverlay, type PlaybackComment, } from "./playback-comment-overlay"; import { PlayerControls, SPEED_OPTIONS } from "./player-controls"; function resolveLocalUrl(url: string | null | undefined): string | undefined { if (!url) return undefined; if (url.startsWith("/") && !url.startsWith("//")) { const basePath = appBasePath(); if (basePath && (url === basePath || url.startsWith(`${basePath}/`))) { return url; } return `${basePath}${url}`; } return url; } const VOLATILE_VIDEO_QUERY_PARAMS = new Set([ "t", "cb", LOOM_START_MS_QUERY_PARAM, "password", "X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-Security-Token", "X-Amz-Signature", "X-Amz-SignedHeaders", "AWSAccessKeyId", "Expires", "Signature", ]); function videoSourceIdentity(url: string | undefined): string { if (!url) return ""; try { const base = typeof window === "undefined" ? "http://clips.local" : window.location.href; const parsed = new URL(url, base); parsed.hash = ""; for (const key of VOLATILE_VIDEO_QUERY_PARAMS) { parsed.searchParams.delete(key); } parsed.searchParams.sort(); return `${parsed.origin}${parsed.pathname}${parsed.search}`; } catch { return url; } } function setUrlSearchParam(url: string, key: string, value: string): string { try { const base = typeof window === "undefined" ? "http://clips.local" : window.location.href; const parsed = new URL(url, base); parsed.searchParams.set(key, value); if (url.startsWith("/") && !url.startsWith("//")) { return `${parsed.pathname}${parsed.search}${parsed.hash}`; } return parsed.href; } catch { return url; } } function clampLoomSeek(ms: number, durationMs: number): number { const safeMs = Number.isFinite(ms) ? ms : 0; const upperBounded = durationMs > 0 ? Math.min(safeMs, durationMs) : safeMs; return Math.floor(Math.max(0, upperBounded)); } function applyLoomStartToVideoSrc(src: string, ms: number): string { const directEmbedUrl = loomEmbedUrlWithTimestamp(src, ms); if (directEmbedUrl) return directEmbedUrl; return setUrlSearchParam(src, LOOM_START_MS_QUERY_PARAM, String(ms)); } function isPlayerUiTarget(target: EventTarget | null): boolean { return ( typeof Element !== "undefined" && target instanceof Element && Boolean(target.closest("[data-player-ui]")) ); } export interface VideoPlayerHandle { video: HTMLVideoElement | null; play: () => Promise | void; pause: () => void; seek: (ms: number) => void; setSpeed: (rate: number) => void; toggleMute: () => void; toggleCaptions: () => void; toggleFullscreen: () => void; togglePip: () => Promise | void; } export interface VideoPlayerProps { recordingId: string; videoUrl: string | null | undefined; /** * Container format of `videoUrl`, when known. Used only to pick an accurate * `canPlayType` MIME check (e.g. Safari cannot play `video/webm`) — Clips * stores a single `videoUrl` per recording, so there is no alternate-format * URL to fall back to. Defaults to `"webm"` (the format every browser * MediaRecorder-based recording is stored as). */ videoFormat?: "webm" | "mp4" | null; embedProvider?: "loom" | null; durationMs: number; thumbnailUrl?: string | null; /** Default playback rate. Clips default is 1.2x. */ defaultSpeed?: number; /** Autoplay on mount. */ autoPlay?: boolean; /** Start time in ms. */ startMs?: number; /** Comment + chapter overlays for the scrubber. */ editsJson?: string | null; comments?: PlaybackComment[]; chapters?: { startMs: number; title: string }[]; reactions?: { id: string; emoji: string; videoTimestampMs: number }[]; transcriptSegments?: { startMs: number; endMs: number; text: string }[]; /** Theatre-mode wraps the whole viewport. */ theaterMode?: boolean; onTheaterToggle?: () => void; /** Whether to show the built-in CTA button. */ cta?: { id: string; label: string; url: string; color: string; placement: "end" | "throughout"; } | null; onCtaClick?: (ctaId: string) => void; /** Emit events as the video plays (for analytics). */ onTimeUpdate?: (currentMs: number, totalMs: number) => void; onPlay?: () => void; onPause?: () => void; onSeek?: (ms: number) => void; onSpeedChange?: (rate: number) => void; onEnded?: () => void; className?: string; /** When true the controls never hide (useful for embed with showControls). */ alwaysShowControls?: boolean; /** Hide all chrome (for embed). */ hideChrome?: boolean; /** Disable captions UI. */ hideCaptions?: boolean; /** Optional poster/thumbnail styling. */ cover?: boolean; /** * Viewer role for this recording. When `owner`, we opportunistically capture * a visible frame for missing or blank auto-generated library thumbnails. */ role?: "owner" | "admin" | "editor" | "viewer"; /** * Called with the live `