diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index 11349dc5..93a64749 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const skeletonVariants = cva( - "relative overflow-hidden rounded-md transition-shadow", + "relative overflow-hidden rounded-md transition-shadow flex items-center justify-center", { variants: { variant: { @@ -11,12 +11,17 @@ const skeletonVariants = cva( glass: "bg-white/5 backdrop-blur-sm border border-white/10", dark: "bg-black/20", subtle: "bg-foreground/[0.03]", + clip: "bg-primary/5 border border-primary/10 shadow-inner", }, animation: { none: "", pulse: "animate-pulse", - shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.04] before:to-transparent", - "shimmer-glass": "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent", + shimmer: + "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.05] before:to-transparent", + "shimmer-glass": + "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent", + "shimmer-premium": + "before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/[0.1] before:to-transparent", }, }, defaultVariants: { @@ -28,20 +33,46 @@ const skeletonVariants = cva( export interface SkeletonProps extends React.HTMLAttributes, - VariantProps {} + VariantProps { + label?: string; +} /** * A magnificent Skeleton component designed with Apple-inspired aesthetics. - * Supports shimmer animations, pulse effects, and glassmorphism. + * Supports shimmer animations, pulse effects, and labels. */ const Skeleton = React.forwardRef( - ({ className, variant, animation, ...props }, ref) => { + ({ className, variant, animation, label, children, ...props }, ref) => { return (
+ > + {(label || children) && ( +
+ {label && ( +
+ {label.split("").map((char, i) => ( + + {char} + + ))} +
+ )} + {children} +
+ )} +
); }, ); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9a4642fa..a2f6982a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -6356,15 +6356,17 @@ export default function VideoEditor() { > c.showSourceAudio)} sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings} getSourceAudioTrackSettingsForClip={ diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 44405905..7931ce81 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -12,6 +12,7 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; import AudioWaveform from "./components/waveform/AudioWaveform"; import type { AudioPeaksData } from "./core/timelineTypes"; import glassStyles from "./ItemGlass.module.css"; @@ -34,6 +35,8 @@ interface ItemProps { waveformNormalize?: boolean; muted?: boolean; variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; + isLoading?: boolean; + loadingLabel?: string; } // Map zoom depth to multiplier labels @@ -73,15 +76,46 @@ export default function Item({ waveformNormalize = false, muted = false, variant = "zoom", + isLoading = false, + loadingLabel, children, }: ItemProps) { const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, - disabled, + disabled: disabled || isLoading, data: { rowId }, }); + const timeLabel = useMemo( + () => `${formatMs(span.start)} – ${formatMs(span.end)}`, + [span.start, span.end], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + const isZoom = variant === "zoom"; const isTrim = variant === "trim"; const isClip = variant === "clip"; @@ -101,11 +135,6 @@ export default function Item({ ? glassStyles.glassDarkGreen : glassStyles.glassYellow; - const timeLabel = useMemo( - () => `${formatMs(span.start)} – ${formatMs(span.end)}`, - [span.start, span.end], - ); - const MIN_ITEM_PX = 6; const handleSelect = () => { onSelect?.(); diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index df2ed47b..21dd13f2 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -40,7 +40,6 @@ import { calculateTimelineScale } from "./core/time"; import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime"; import { useTimelineRange } from "./hooks/useTimelineRange"; import TimelineCanvas from "./components/viewport/TimelineCanvas"; -import TimelineToolbar from "./components/toolbar/TimelineToolbar"; export interface TimelineEditorProps { videoDuration: number; @@ -85,6 +84,8 @@ export interface TimelineEditorProps { onOpenCropEditor?: () => void; isCropped?: boolean; videoPath?: string | null; + videoSourcePath?: string | null; + cursorTelemetrySourcePath?: string | null; hideToolbar?: boolean; showSourceAudioTrack?: boolean; onSourceAudioAvailabilityChange?: (available: boolean) => void; @@ -175,6 +176,8 @@ const TimelineEditor = forwardRef( onOpenCropEditor, isCropped = false, videoPath, + videoSourcePath, + cursorTelemetrySourcePath, hideToolbar = false, showSourceAudioTrack = false, onSourceAudioAvailabilityChange, @@ -272,7 +275,7 @@ const TimelineEditor = forwardRef( return { previewSpans, hiddenZoomIds }; }, [clipRegions, liveSpanPreviewById, zoomRegions]); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); - const sourceAudioPeaks = useTimelineAudioPeaks(videoPath, { + const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks(videoPath, { enableSourceSidecarFallback: true, }); const localSourcePath = useMemo(() => { @@ -290,8 +293,8 @@ const TimelineEditor = forwardRef( () => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "system") : null), [localSourcePath], ); - const micSidecarPeaks = useTimelineAudioPeaks(micSidecarPath); - const systemSidecarPeaks = useTimelineAudioPeaks(systemSidecarPath); + const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks(micSidecarPath); + const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks(systemSidecarPath); const sourceAudioTracks = useMemo(() => { if (systemSidecarPeaks || micSidecarPeaks) { const tracks: SourceAudioTrackWithPeaks[] = []; @@ -319,6 +322,17 @@ const TimelineEditor = forwardRef( ] : []; }, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t]); + + const isLoading = useMemo(() => { + // If we are still actively trying to load audio peaks (main or sidecars) + if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) return true; + + // Robust telemetry loading detection: + // If a source path is set but telemetry hasn't arrived (or failed/retried) for it yet. + if (videoSourcePath && cursorTelemetrySourcePath !== videoSourcePath) return true; + + return false; + }, [videoPath, videoSourcePath, cursorTelemetrySourcePath, sourceAudioLoading, micSidecarLoading, systemSidecarLoading]); useEffect(() => { onSourceAudioTracksMetaChange?.(sourceAudioTracks.map((t) => ({ id: t.id, label: t.label }))); }, [onSourceAudioTracksMetaChange, sourceAudioTracks]); @@ -441,12 +455,6 @@ const TimelineEditor = forwardRef( keyShortcuts, isTimelineFocusedRef, }); - const handleToolbarAddAnnotation = useCallback(() => { - handleAddAnnotation(); - }, [handleAddAnnotation]); - const handleToolbarAddAudio = useCallback(() => { - void handleAddAudio(); - }, [handleAddAudio]); if (!videoDuration || videoDuration === 0) { return ( @@ -466,32 +474,6 @@ const TimelineEditor = forwardRef( return (
- {hideToolbar ? null : ( - - )}
( showSourceAudioTrack={showSourceAudioTrack} liveSpanPreviewById={liveZoomPreview.previewSpans} liveHiddenItemIds={Array.from(liveZoomPreview.hiddenZoomIds)} + isLoading={isLoading} />
diff --git a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx index 7ff0c1a9..d780dcfb 100644 --- a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx +++ b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx @@ -9,6 +9,7 @@ interface PlaybackCursorProps { onSeek?: (time: number) => void; timelineRef: RefObject; keyframes?: { id: string; time: number }[]; + isLoading?: boolean; } export default function PlaybackCursor({ @@ -17,6 +18,7 @@ export default function PlaybackCursor({ onSeek, timelineRef, keyframes = [], + isLoading = false, }: PlaybackCursorProps) { const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -100,11 +102,27 @@ export default function PlaybackCursor({
- {formatPlayheadTime(clampedTime)} +
+ {formatPlayheadTime(clampedTime).split("").map((char, i) => ( + + {char} + + ))} +
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index aab23f76..736a6d6e 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -68,6 +68,7 @@ interface TimelineCanvasProps { showSourceAudioTrack?: boolean; liveSpanPreviewById?: Record; liveHiddenItemIds?: string[]; + isLoading?: boolean; } interface TimelineHoverParams { @@ -244,6 +245,7 @@ interface TimelineCanvasRowsProps { onZoomRowMouseMove: MouseEventHandler; onZoomRowMouseLeave: MouseEventHandler; onZoomRowClick: MouseEventHandler; + isLoading?: boolean; } interface AudioItemWithWaveformProps { @@ -261,7 +263,7 @@ function AudioItemWithWaveform({ isSelected, onSelectAudio, }: AudioItemWithWaveformProps) { - const peaks = useTimelineAudioPeaks(item.audioPath ?? null); + const { peaks } = useTimelineAudioPeaks(item.audioPath ?? null); const normalizedWaveformSpan = useMemo(() => { const duration = Math.max(0, waveformSpan.end - waveformSpan.start); return { start: 0, end: duration }; @@ -310,6 +312,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ onZoomRowMouseMove, onZoomRowMouseLeave, onZoomRowClick, + isLoading = false, }: TimelineCanvasRowsProps) { const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]); const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => { @@ -376,6 +379,8 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ isSelected={selectAllBlocksActive || item.id === selectedClipId} onSelectId={onSelectClip} variant="clip" + isLoading={isLoading} + loadingLabel="Analyzing..." > {item.label} @@ -522,6 +527,7 @@ export default function TimelineCanvas({ showSourceAudioTrack = false, liveSpanPreviewById, liveHiddenItemIds, + isLoading = false, }: TimelineCanvasProps) { const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); @@ -726,6 +732,7 @@ export default function TimelineCanvas({ onSeek={onSeek} timelineRef={localTimelineRef} keyframes={keyframes} + isLoading={isLoading} /> {canShowGhostPlayhead && (
diff --git a/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts index fa750e29..87ebc357 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts @@ -40,20 +40,30 @@ interface TimelineAudioPeaksOptions { peakCount?: number; } +export interface TimelineAudioPeaksResult { + peaks: AudioPeaksData | null; + loading: boolean; +} + export function useTimelineAudioPeaks( mediaResource: string | null | undefined, options: TimelineAudioPeaksOptions = {}, -): AudioPeaksData | null { - const [data, setData] = useState(null); +): TimelineAudioPeaksResult { + const [peaks, setPeaks] = useState(null); + const [loading, setLoading] = useState(false); const sourceRef = useRef(mediaResource); const enableSourceSidecarFallback = options.enableSourceSidecarFallback ?? false; const peakCount = options.peakCount ?? WAVEFORM_DEFAULT_PEAK_COUNT; useEffect(() => { sourceRef.current = mediaResource; - setData(null); - if (!mediaResource) return; + setPeaks(null); + if (!mediaResource) { + setLoading(false); + return; + } + setLoading(true); let cancelled = false; const run = async () => { @@ -64,29 +74,50 @@ export function useTimelineAudioPeaks( try { const result = await tryGenerate(mediaResource); - if (!cancelled && sourceRef.current === mediaResource) setData(result); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } return; } catch { // fallthrough } - if (!enableSourceSidecarFallback) return; + if (!enableSourceSidecarFallback) { + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + return; + } const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource); const localSourcePath = localPathFromServer || (/^file:\/\//i.test(mediaResource) ? fromFileUrl(mediaResource) : mediaResource); - if (!localSourcePath) return; + if (!localSourcePath) { + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + return; + } - for (const candidate of buildSidecarAudioCandidates(localSourcePath)) { + const candidates = buildSidecarAudioCandidates(localSourcePath); + for (const candidate of candidates) { try { const result = await tryGenerate(candidate); - if (!cancelled && sourceRef.current === mediaResource) setData(result); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } return; } catch { // try next } } + + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } }; void run(); @@ -96,5 +127,5 @@ export function useTimelineAudioPeaks( }; }, [mediaResource, enableSourceSidecarFallback, peakCount]); - return data; + return { peaks, loading }; } diff --git a/tailwind.config.cjs b/tailwind.config.cjs index fbecd630..1a6e3280 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -18,11 +18,22 @@ module.exports = { transform: "translateX(100%)", }, }, + "text-shimmer": { + "0%, 100%": { + "background-size": "200% 200%", + "background-position": "left center", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "right center", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", shimmer: "shimmer 2s infinite", + "text-shimmer": "text-shimmer 2.5s ease-out infinite", }, borderRadius: { lg: "var(--radius)",