From f0de151069c22e7886c73b2a4083f517e0474d7f Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Sat, 4 Jul 2026 12:34:09 -0300 Subject: [PATCH 1/4] feat(ui): improve YouTube trim editor and draft/livestream modal mobile UX --- .../youtube-import/TrimRangeSlider.test.tsx | 136 +++-- __tests__/lib/parse-trim-time-input.test.ts | 40 ++ .../dashboard/livestreams/page.tsx | 26 + components/drafts/DraftMetadataModal.tsx | 172 ++++-- .../livestreams/LivestreamMetadataModal.tsx | 240 +++++++-- .../StreamedLivestreamsHistoryClient.tsx | 26 + components/youtube-import/TrimRangeSlider.tsx | 500 ++++++++++++++---- .../youtube-import/YouTubeImportModal.tsx | 10 +- lib/parse-trim-time-input.ts | 93 ++++ lib/use-min-sm-viewport.ts | 29 + vitest.setup.ts | 16 +- 11 files changed, 1050 insertions(+), 238 deletions(-) create mode 100644 __tests__/lib/parse-trim-time-input.test.ts create mode 100644 lib/parse-trim-time-input.ts create mode 100644 lib/use-min-sm-viewport.ts diff --git a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx index f83eedbe..f2c114ee 100644 --- a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx +++ b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx @@ -3,6 +3,7 @@ import { useState, type ComponentProps } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { TrimRangeSlider, + applyTrimHandleSeconds, nudgeTrimHandleValue, pickClosestKeyframe, } from '@/components/youtube-import/TrimRangeSlider'; @@ -90,6 +91,24 @@ describe('nudgeTrimHandleValue', () => { }); }); +describe('applyTrimHandleSeconds', () => { + it('clamps the start handle within the trim range', () => { + expect( + applyTrimHandleSeconds({ startSeconds: 10, endSeconds: 100 }, 'start', 150, 300) + ).toEqual({ + startSeconds: 100, + endSeconds: 100, + }); + }); + + it('clamps the end handle within the trim range', () => { + expect(applyTrimHandleSeconds({ startSeconds: 10, endSeconds: 100 }, 'end', 5, 300)).toEqual({ + startSeconds: 10, + endSeconds: 10, + }); + }); +}); + describe('TrimRangeSlider', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); @@ -122,7 +141,7 @@ describe('TrimRangeSlider', () => { vi.restoreAllMocks(); }); - it('requests keyframes when the handle is released via keyboard', async () => { + it('requests keyframes when the slider handle is released via keyboard and snapping is enabled', async () => { const onChange = vi.fn(); vi.mocked(global.fetch).mockResolvedValue( new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 }) @@ -139,10 +158,10 @@ describe('TrimRangeSlider', () => { const [url] = vi.mocked(global.fetch).mock.calls[0] ?? []; expect(String(url)).toContain('/api/youtube-import/keyframes'); - expect(String(url)).toContain(`youtubeVideoId=${VIDEO_ID}`); + expect(onChange).toHaveBeenCalled(); }); - it('does not request keyframes when snapping is disabled', async () => { + it('does not request keyframes when the slider handle is released and snapping is disabled', async () => { const onChange = vi.fn(); renderSlider({ onChange, enableKeyframeSnap: false }); @@ -154,7 +173,35 @@ describe('TrimRangeSlider', () => { expect(onChange).toHaveBeenCalled(); }); - it('snaps the moved handle to the closest returned keyframe', async () => { + it('requests keyframes when a frame nudge button is clicked', async () => { + const onChange = vi.fn(); + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 }) + ); + renderSlider({ onChange }); + + fireEvent.click(screen.getByTestId('trim-start-frame-later')); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + const [url] = vi.mocked(global.fetch).mock.calls[0] ?? []; + expect(String(url)).toContain('/api/youtube-import/keyframes'); + expect(String(url)).toContain(`youtubeVideoId=${VIDEO_ID}`); + }); + + it('does not request keyframes when snapping is disabled', async () => { + const onChange = vi.fn(); + renderSlider({ onChange, enableKeyframeSnap: false }); + + fireEvent.click(screen.getByTestId('trim-start-frame-later')); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); + }); + + it('snaps the moved handle to the closest returned keyframe after a frame nudge', async () => { const onChange = vi.fn(); renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } }); @@ -162,9 +209,7 @@ describe('TrimRangeSlider', () => { new Response(JSON.stringify({ data: { keyframeSeconds: [5, 12, 20] } }), { status: 200 }) ); - const [startThumb] = screen.getAllByRole('slider'); - fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); - fireEvent.keyUp(startThumb); + fireEvent.click(screen.getByTestId('trim-start-frame-later')); await waitFor(() => { const lastCall = onChange.mock.calls.at(-1)?.[0]; @@ -172,32 +217,60 @@ describe('TrimRangeSlider', () => { }); }); - it('leaves the handle at the raw dragged value when keyframes are empty', async () => { + it('nudges the start handle by the selected jump amount', () => { const onChange = vi.fn(); - renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } }); + renderSlider({ onChange, enableKeyframeSnap: false }); - vi.mocked(global.fetch).mockResolvedValue( - new Response(JSON.stringify({ data: { keyframeSeconds: [] } }), { status: 200 }) - ); + fireEvent.click(screen.getByTestId('trim-start-jump-later')); - const [startThumb] = screen.getAllByRole('slider'); - fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + expect(onChange).toHaveBeenCalledWith({ + startSeconds: 15, + endSeconds: 100, + }); + }); - const rawValue = onChange.mock.calls.at(-1)?.[0]; - expect(rawValue).toBeDefined(); + it('uses the selected jump step amount', () => { + const onChange = vi.fn(); + renderSlider({ onChange, enableKeyframeSnap: false }); - fireEvent.keyUp(startThumb); + fireEvent.click(screen.getByTestId('trim-jump-step-10')); + fireEvent.click(screen.getByTestId('trim-start-jump-later')); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + startSeconds: 20, + endSeconds: 100, }); + }); - await waitFor(() => { - expect(screen.queryByTestId('trim-start-loading')).not.toBeInTheDocument(); + it('nudges the start handle from on-screen frame buttons', () => { + const onChange = vi.fn(); + renderSlider({ onChange, enableKeyframeSnap: false }); + + fireEvent.click(screen.getByTestId('trim-start-frame-later')); + + expect(onChange).toHaveBeenCalledWith({ + startSeconds: 10 + 1 / 30, + endSeconds: 100, + }); + }); + + it('commits a typed start timestamp', async () => { + const onChange = vi.fn(); + renderSlider({ + onChange, + enableKeyframeSnap: false, + value: { startSeconds: 10, endSeconds: 100 }, }); - const finalValue = onChange.mock.calls.at(-1)?.[0]; - expect(finalValue?.startSeconds).toBe(rawValue?.startSeconds); + fireEvent.click(screen.getByTestId('trim-start-time-display')); + const input = screen.getByTestId('trim-start-time-input'); + fireEvent.change(input, { target: { value: '1:30' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith({ + startSeconds: 90, + endSeconds: 100, + }); }); it('seeks the preview player while dragging when a player handle is provided', async () => { @@ -218,7 +291,7 @@ describe('TrimRangeSlider', () => { expect(onChange).toHaveBeenCalled(); }); - it('seeks immediately when the handle is released', async () => { + it('seeks immediately when the slider handle is released', async () => { const playerHandle = { previewAt: vi.fn(), getCurrentTime: vi.fn().mockReturnValue(0), @@ -233,18 +306,6 @@ describe('TrimRangeSlider', () => { expect(playerHandle.previewAt).toHaveBeenCalled(); }); - it('nudges the start handle from on-screen arrow buttons', () => { - const onChange = vi.fn(); - renderSlider({ onChange, enableKeyframeSnap: false }); - - fireEvent.click(screen.getByTestId('trim-start-nudge-later')); - - expect(onChange).toHaveBeenCalledWith({ - startSeconds: 10 + 1 / 30, - endSeconds: 100, - }); - }); - it('shows a loading indicator on the handle being snapped', async () => { let resolveFetch: (value: Response) => void = () => {}; const fetchPromise = new Promise((resolve) => { @@ -254,8 +315,7 @@ describe('TrimRangeSlider', () => { renderSlider({ value: { startSeconds: 10, endSeconds: 100 } }); - const [startThumb] = screen.getAllByRole('slider'); - fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + fireEvent.click(screen.getByTestId('trim-start-frame-later')); await waitFor(() => { expect(screen.getByTestId('trim-start-loading')).toBeInTheDocument(); diff --git a/__tests__/lib/parse-trim-time-input.test.ts b/__tests__/lib/parse-trim-time-input.test.ts new file mode 100644 index 00000000..731ade9c --- /dev/null +++ b/__tests__/lib/parse-trim-time-input.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { formatTrimTimeInputValue, parseTrimTimeInput } from '@/lib/parse-trim-time-input'; + +describe('parseTrimTimeInput', () => { + it('parses plain second values', () => { + expect(parseTrimTimeInput('90')).toBe(90); + expect(parseTrimTimeInput('90.5')).toBe(90.5); + }); + + it('parses minute:second values', () => { + expect(parseTrimTimeInput('1:30')).toBe(90); + expect(parseTrimTimeInput('12:05')).toBe(725); + expect(parseTrimTimeInput('1:30.5')).toBe(90.5); + }); + + it('parses hour:minute:second values', () => { + expect(parseTrimTimeInput('1:02:03')).toBe(3723); + expect(parseTrimTimeInput('1:02:03.25')).toBe(3723.25); + }); + + it('rejects invalid values', () => { + expect(parseTrimTimeInput('')).toBeNull(); + expect(parseTrimTimeInput('abc')).toBeNull(); + expect(parseTrimTimeInput('1:75')).toBeNull(); + expect(parseTrimTimeInput('1:02:75')).toBeNull(); + expect(parseTrimTimeInput('1:2:3:4')).toBeNull(); + }); +}); + +describe('formatTrimTimeInputValue', () => { + it('formats whole seconds using YouTube-style labels', () => { + expect(formatTrimTimeInputValue(90)).toBe('1:30'); + expect(formatTrimTimeInputValue(3723)).toBe('1:02:03'); + }); + + it('formats fractional seconds as decimals', () => { + expect(formatTrimTimeInputValue(90.5)).toBe('90.5'); + expect(formatTrimTimeInputValue(10 + 1 / 30)).toBe('10.033'); + }); +}); diff --git a/app/(dashboard)/dashboard/livestreams/page.tsx b/app/(dashboard)/dashboard/livestreams/page.tsx index bb32b152..57f41846 100644 --- a/app/(dashboard)/dashboard/livestreams/page.tsx +++ b/app/(dashboard)/dashboard/livestreams/page.tsx @@ -435,6 +435,31 @@ export default function LivestreamsPage() { [editingLivestream?.id, loadLivestreams] ); + const handleDeleteLivestreamById = useCallback( + async (livestreamId: string): Promise => { + setIsDeletingId(livestreamId); + try { + const response = await fetch(`/api/livestreams/${livestreamId}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete livestream'); + } + toast.success('Livestream deleted'); + if (editingLivestream?.id === livestreamId) { + setEditingLivestream(null); + } + await loadLivestreams(); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); + return false; + } finally { + setIsDeletingId(null); + } + }, + [editingLivestream?.id, loadLivestreams] + ); + const handleDuplicateLivestream = useCallback( async (livestream: Livestream) => { if (duplicatingLivestreamIdRef.current === livestream.id) return; @@ -659,6 +684,7 @@ export default function LivestreamsPage() { scheduledFacebookLivestreams={scheduledFacebookLivestreams} onKeySlotChanged={handleKeySlotChanged} onFacebookChanged={handleKeySlotChanged} + onDelete={handleDeleteLivestreamById} /> ); diff --git a/components/drafts/DraftMetadataModal.tsx b/components/drafts/DraftMetadataModal.tsx index 83dd2f65..df5a7938 100644 --- a/components/drafts/DraftMetadataModal.tsx +++ b/components/drafts/DraftMetadataModal.tsx @@ -50,6 +50,7 @@ import { partitionYouTubeCompatibleTags, } from '@/lib/platforms/sermon-audio-tags'; import { cn } from '@/lib/utils'; +import { useMinSmViewport } from '@/lib/use-min-sm-viewport'; import { cancelMultipartUploadJob, getPartByteRange, @@ -614,6 +615,7 @@ export function DraftMetadataModal({ disableInteractionLock = false, }: DraftMetadataModalProps) { const router = useRouter(); + const isMinSmViewport = useMinSmViewport(); const draftId = value?.id ?? null; const youtubeTargetActive = value?.targets.includes('youtube') ?? false; const vimeoTargetActive = value?.targets.includes('vimeo') ?? false; @@ -6193,66 +6195,130 @@ export function DraftMetadataModal({ {renderUploadHistorySection('draft-upload-history-panel', showUploadHistory, () => setShowUploadHistory((prev) => !prev) )} + + {!isMinSmViewport ? ( + +
+ {mode === 'edit' && onDelete ? ( + + ) : null} + +
+ + +
+ ) : null} ) : null} - - - {mode === 'edit' && onDelete && value ? ( + {isMinSmViewport ? ( + + {mode === 'edit' && onDelete && value ? ( + + ) : null} - ) : null} - - - + + + + ) : null} void | Promise; /** Called after Facebook arm/end succeeds so the list can refresh. */ onFacebookChanged?: () => void | Promise; + /** + * Deletes the open livestream when the user confirms from the modal footer. + * @param livestreamId - Persisted livestream identifier. + * @returns Whether deletion succeeded. + */ + onDelete?: (livestreamId: string) => Promise; } /** @@ -287,8 +304,10 @@ export function LivestreamMetadataModal({ scheduledFacebookLivestreams = [], onKeySlotChanged, onFacebookChanged, + onDelete, }: LivestreamMetadataModalProps) { const router = useRouter(); + const isMinSmViewport = useMinSmViewport(); const livestreamId = value?.id ?? null; const isDraft = value?.status === 'draft'; const isScheduled = value?.status === 'scheduled'; @@ -332,6 +351,8 @@ export function LivestreamMetadataModal({ const [thumbnailFileName, setThumbnailFileName] = useState(null); const [keySlotChanging, setKeySlotChanging] = useState(false); const [keySlotConflictWarning, setKeySlotConflictWarning] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const thumbnailInputRef = useRef(null); const thumbnailRequestAbortRef = useRef(null); const thumbnailXhrRef = useRef(null); @@ -1380,6 +1401,20 @@ export function LivestreamMetadataModal({ onClose(); }; + const handleDeleteLivestream = async () => { + if (!value?.id || !onDelete) return; + setIsDeleting(true); + try { + const deleted = await onDelete(value.id); + if (deleted) { + setShowDeleteConfirm(false); + onClose(); + } + } finally { + setIsDeleting(false); + } + }; + const canSave = Boolean(value) && isMetadataEditable && @@ -1391,6 +1426,13 @@ export function LivestreamMetadataModal({ thumbnailFileName ?? (value?.thumbnailR2Key || value?.thumbnailPreviewUrl ? 'Current thumbnail' : 'No file chosen'); + const deleteAriaLabel = isDraft ? 'Delete livestream draft' : 'Delete livestream'; + const deleteDialogTitle = isDraft ? 'Delete livestream draft?' : 'Delete livestream?'; + const deleteDialogDescription = isDraft + ? 'This will permanently delete this livestream draft and cannot be undone.' + : 'This will permanently delete this livestream and cannot be undone.'; + const deleteActionLabel = isDraft ? 'Delete draft' : 'Delete livestream'; + return ( ) : null} + + {scheduleError ? ( +
+ +
+ ) : null} + + {!isMinSmViewport ? ( + +
+ {mode === 'edit' && onDelete ? ( + + ) : null} + +
+ {isMetadataEditable ? ( + + ) : null} + {isDraft ? ( + + ) : null} +
+ ) : null} ) : null} {scheduleError ? ( -
+
) : null} - - - {isMetadataEditable ? ( + {isMinSmViewport ? ( + + {mode === 'edit' && onDelete && value ? ( + + ) : null} - ) : null} - {isDraft ? ( - + ) : null} + {isDraft ? ( + + ) : null} + + ) : null} + + + + + + {deleteDialogTitle} + {deleteDialogDescription} + + + Cancel + { + event.preventDefault(); + void handleDeleteLivestream(); }} - disabled={thumbnailUploading || isSaving || isScheduling} - className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-60" + className="bg-red-600 text-white hover:bg-red-700" + disabled={isDeleting} > - {isScheduling ? 'Scheduling…' : 'Schedule livestream'} - - ) : null} - - + {isDeleting ? 'Deleting…' : deleteActionLabel} + + + +
); } diff --git a/components/livestreams/StreamedLivestreamsHistoryClient.tsx b/components/livestreams/StreamedLivestreamsHistoryClient.tsx index 28a70c21..739938d0 100644 --- a/components/livestreams/StreamedLivestreamsHistoryClient.tsx +++ b/components/livestreams/StreamedLivestreamsHistoryClient.tsx @@ -208,6 +208,31 @@ export function StreamedLivestreamsHistoryClient() { [isDeletingId, loadHistory] ); + const handleDeleteLivestreamById = useCallback( + async (livestreamId: string): Promise => { + setIsDeletingId(livestreamId); + try { + const response = await fetch(`/api/livestreams/${livestreamId}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete livestream'); + } + toast.success('Livestream deleted'); + if (editingLivestream?.id === livestreamId) { + setEditingLivestream(null); + } + await loadHistory(); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); + return false; + } finally { + setIsDeletingId(null); + } + }, + [editingLivestream?.id, loadHistory] + ); + const handleDuplicateLivestream = useCallback( async (livestream: Livestream) => { if (isDuplicatingId) return; @@ -316,6 +341,7 @@ export function StreamedLivestreamsHistoryClient() { scheduledFacebookLivestreams={scheduledFacebookLivestreams} onKeySlotChanged={loadHistory} onFacebookChanged={loadHistory} + onDelete={handleDeleteLivestreamById} /> ); diff --git a/components/youtube-import/TrimRangeSlider.tsx b/components/youtube-import/TrimRangeSlider.tsx index a8658bb3..88e9d10c 100644 --- a/components/youtube-import/TrimRangeSlider.tsx +++ b/components/youtube-import/TrimRangeSlider.tsx @@ -1,12 +1,20 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Slider, SliderRange, SliderThumb, SliderTrack } from '@/components/ui/slider'; import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer'; -import { cn } from '@/lib/utils'; import { formatVideoDuration } from '@/lib/format-video-duration'; +import { + formatTrimTimeInputValue, + parseTrimTimeInput, + TRIM_JUMP_STEP_OPTIONS, + type TrimJumpStepSeconds, +} from '@/lib/parse-trim-time-input'; +import { cn } from '@/lib/utils'; /** Debounce interval for preview seeks while dragging trim handles. */ const PREVIEW_SEEK_THROTTLE_MS = 100; @@ -14,7 +22,7 @@ const PREVIEW_SEEK_THROTTLE_MS = 100; /** Ignore keyframe candidates farther than this from the released handle position. */ const MAX_KEYFRAME_SNAP_DISTANCE_SECONDS = 4; -/** Step size for arrow keys and on-screen nudge buttons (~one 30fps frame). */ +/** Step size for frame nudge buttons (~one 30fps frame). */ export const TRIM_NUDGE_STEP_SECONDS = 1 / 30; /** @@ -28,13 +36,13 @@ export interface TrimRangeSliderProps { /** Current trim range in seconds. */ value: { startSeconds: number; endSeconds: number }; /** - * Called when the user adjusts either handle (raw while dragging, snapped after settle). + * Called when the user adjusts either handle. * @param value - Updated trim range. */ onChange: (value: { startSeconds: number; endSeconds: number }) => void; /** Optional preview player handle to seek while trimming. */ playerHandle?: YouTubePlayerHandle; - /** When false, trim handles stay where released without keyframe probing. */ + /** When false, trim handles stay without keyframe probing after precise edits. */ enableKeyframeSnap?: boolean; } @@ -81,7 +89,36 @@ export function clampTrimSeconds(seconds: number, min: number, max: number): num } /** - * Applies one frame-step nudge to a trim handle. + * Applies an absolute timestamp to one trim handle. + * @param value - Current trim range in seconds. + * @param handle - Which handle to move. + * @param seconds - Target timestamp in seconds. + * @param durationSeconds - Total source duration in seconds. + * @returns Updated trim range. + */ +export function applyTrimHandleSeconds( + value: { startSeconds: number; endSeconds: number }, + handle: 'start' | 'end', + seconds: number, + durationSeconds: number +): { startSeconds: number; endSeconds: number } { + const max = Math.max(durationSeconds, 0); + + if (handle === 'start') { + return { + startSeconds: clampTrimSeconds(seconds, 0, value.endSeconds), + endSeconds: value.endSeconds, + }; + } + + return { + startSeconds: value.startSeconds, + endSeconds: clampTrimSeconds(seconds, value.startSeconds, max), + }; +} + +/** + * Applies one step nudge to a trim handle. * @param value - Current trim range in seconds. * @param handle - Which handle to move. * @param direction - `-1` for earlier, `1` for later. @@ -114,8 +151,256 @@ export function nudgeTrimHandleValue( return { startSeconds: value.startSeconds, endSeconds: nextEnd }; } +interface TrimTimestampFieldProps { + /** Which trim handle this field edits. */ + handle: 'start' | 'end'; + /** Current timestamp in seconds. */ + seconds: number; + /** Whether editing is disabled. */ + disabled: boolean; + /** Whether a keyframe snap is in progress for this handle. */ + isSnapping: boolean; + /** Minimum width class for the timestamp display. */ + widthClass: string; + /** + * Called when the user commits a parsed timestamp. + * @param seconds - Parsed timestamp in seconds. + */ + onCommit: (seconds: number) => void; +} + +/** + * Tap-to-edit trim timestamp control. + * @param props - Field configuration. + * @returns Editable timestamp UI. + */ +function TrimTimestampField({ + handle, + seconds, + disabled, + isSnapping, + widthClass, + onCommit, +}: TrimTimestampFieldProps) { + const inputId = useId(); + const inputRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(''); + const [hasError, setHasError] = useState(false); + + const beginEditing = () => { + if (disabled || isSnapping) { + return; + } + setDraftValue(formatTrimTimeInputValue(seconds)); + setHasError(false); + setIsEditing(true); + }; + + useEffect(() => { + if (!isEditing) { + return; + } + inputRef.current?.focus(); + inputRef.current?.select(); + }, [isEditing]); + + const cancelEditing = () => { + setIsEditing(false); + setHasError(false); + }; + + const commitEditing = () => { + const parsed = parseTrimTimeInput(draftValue); + if (parsed == null) { + setHasError(true); + return; + } + + setIsEditing(false); + setHasError(false); + onCommit(parsed); + }; + + const label = handle === 'start' ? 'Trim start time' : 'Trim end time'; + + if (isEditing) { + return ( + { + setDraftValue(event.target.value); + if (hasError) { + setHasError(false); + } + }} + onBlur={commitEditing} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitEditing(); + } + if (event.key === 'Escape') { + event.preventDefault(); + cancelEditing(); + } + }} + className={cn( + 'h-8 shrink-0 px-2 text-center text-xs tabular-nums sm:h-9 sm:text-sm', + widthClass, + hasError && 'border-destructive focus-visible:ring-destructive' + )} + /> + ); + } + + return ( + + ); +} + +interface TrimHandleControlsProps { + /** Which trim handle these controls adjust. */ + handle: 'start' | 'end'; + /** Current timestamp in seconds for this handle. */ + seconds: number; + /** Whether controls are disabled. */ + disabled: boolean; + /** Whether a keyframe snap is in progress for this handle. */ + isSnapping: boolean; + /** Selected coarse jump distance in seconds. */ + jumpStepSeconds: TrimJumpStepSeconds; + /** Whether the jump-earlier button is enabled. */ + canJumpEarlier: boolean; + /** Whether the frame-earlier button is enabled. */ + canFrameEarlier: boolean; + /** Whether the frame-later button is enabled. */ + canFrameLater: boolean; + /** Whether the jump-later button is enabled. */ + canJumpLater: boolean; + /** Minimum width class for the timestamp display. */ + timestampWidthClass: string; + /** + * Nudges the handle by the given step. + * @param direction - `-1` for earlier, `1` for later. + * @param stepSeconds - Distance in seconds. + */ + onNudge: (direction: -1 | 1, stepSeconds: number) => void; + /** + * Sets the handle to an absolute timestamp. + * @param seconds - Target timestamp in seconds. + */ + onCommitSeconds: (seconds: number) => void; +} + /** - * Two-handle trim slider with keyframe snap on handle release. + * Per-handle trim controls: jump nudge, frame nudge, and editable timestamp. + * @param props - Control configuration. + * @returns Handle control row. + */ +function TrimHandleControls({ + handle, + seconds, + disabled, + isSnapping, + jumpStepSeconds, + canJumpEarlier, + canFrameEarlier, + canFrameLater, + canJumpLater, + timestampWidthClass, + onNudge, + onCommitSeconds, +}: TrimHandleControlsProps) { + const handleLabel = handle === 'start' ? 'start' : 'end'; + + return ( +
+ + + + + +
+ ); +} + +/** + * Two-handle trim slider with editable timestamps and precise nudge controls. + * Keyframe snap runs after typed times, nudge buttons, and slider release when + * {@link TrimRangeSliderProps.enableKeyframeSnap} is true (stream-copy / smart cut off). * @param props - Slider configuration. * @returns Trim range slider UI. */ @@ -127,6 +412,7 @@ export function TrimRangeSlider({ playerHandle, enableKeyframeSnap = true, }: TrimRangeSliderProps) { + const [jumpStepSeconds, setJumpStepSeconds] = useState(5); const [snappingHandle, setSnappingHandle] = useState<'start' | 'end' | null>(null); const snapRequestIdRef = useRef(0); const valueRef = useRef(value); @@ -246,6 +532,31 @@ export function TrimRangeSlider({ [playerHandle] ); + const commitHandleValue = useCallback( + (handle: 'start' | 'end', nextValue: { startSeconds: number; endSeconds: number }) => { + const previewSeconds = handle === 'start' ? nextValue.startSeconds : nextValue.endSeconds; + const current = valueRef.current; + + if ( + nextValue.startSeconds === current.startSeconds && + nextValue.endSeconds === current.endSeconds + ) { + return; + } + + onChangeRef.current(nextValue); + commitPreviewAt(previewSeconds); + + if (!enableKeyframeSnap) { + return; + } + + const requestId = ++snapRequestIdRef.current; + void runKeyframeSnap(handle, previewSeconds, requestId); + }, + [commitPreviewAt, enableKeyframeSnap, runKeyframeSnap] + ); + const handleValueChange = useCallback( ([startSeconds, endSeconds]: number[]) => { const previous = valueRef.current; @@ -278,107 +589,62 @@ export function TrimRangeSlider({ ); const nudgeHandle = useCallback( - (handle: 'start' | 'end', direction: -1 | 1) => { - const nextValue = nudgeTrimHandleValue(valueRef.current, handle, direction, durationSeconds); + (handle: 'start' | 'end', direction: -1 | 1, stepSeconds: number) => { + const nextValue = nudgeTrimHandleValue( + valueRef.current, + handle, + direction, + durationSeconds, + stepSeconds + ); if (!nextValue) { return; } - onChangeRef.current(nextValue); - const previewSeconds = handle === 'start' ? nextValue.startSeconds : nextValue.endSeconds; - commitPreviewAt(previewSeconds); - if (!enableKeyframeSnap) { - return; - } - const requestId = ++snapRequestIdRef.current; - void runKeyframeSnap(handle, previewSeconds, requestId); + commitHandleValue(handle, nextValue); + }, + [commitHandleValue, durationSeconds] + ); + + const setHandleSeconds = useCallback( + (handle: 'start' | 'end', seconds: number) => { + const nextValue = applyTrimHandleSeconds(valueRef.current, handle, seconds, durationSeconds); + commitHandleValue(handle, nextValue); }, - [commitPreviewAt, durationSeconds, enableKeyframeSnap, runKeyframeSnap] + [commitHandleValue, durationSeconds] ); const disabled = durationSeconds <= 0; const maxSeconds = Math.max(durationSeconds, 0); - const startCanNudgeEarlier = !disabled && value.startSeconds > 0; - const startCanNudgeLater = !disabled && value.startSeconds < value.endSeconds; - const endCanNudgeEarlier = !disabled && value.endSeconds > value.startSeconds; - const endCanNudgeLater = !disabled && value.endSeconds < maxSeconds; + const startCanJumpEarlier = !disabled && value.startSeconds > 0; + const startCanFrameEarlier = startCanJumpEarlier; + const startCanFrameLater = !disabled && value.startSeconds < value.endSeconds; + const startCanJumpLater = startCanFrameLater; + const endCanJumpEarlier = !disabled && value.endSeconds > value.startSeconds; + const endCanFrameEarlier = endCanJumpEarlier; + const endCanFrameLater = !disabled && value.endSeconds < maxSeconds; + const endCanJumpLater = endCanFrameLater; const timestampWidthClass = durationSeconds >= 3600 ? 'w-[7ch] sm:min-w-[7ch]' : 'w-[5ch] sm:min-w-[5ch]'; return ( -
-
-
- - - {formatTrimSeconds(value.startSeconds)} - - -
- -
- - - {formatTrimSeconds(value.endSeconds)} - - -
+
+
+ + nudgeHandle('start', direction, stepSeconds)} + onCommitSeconds={(seconds) => setHandleSeconds('start', seconds)} + />
{snappingHandle === 'start' ? ( @@ -409,7 +675,7 @@ export function TrimRangeSlider({ {snappingHandle === 'end' ? ( @@ -421,6 +687,46 @@ export function TrimRangeSlider({ ) : null} + +
+ + nudgeHandle('end', direction, stepSeconds)} + onCommitSeconds={(seconds) => setHandleSeconds('end', seconds)} + /> +
+ +
+ Jump + {TRIM_JUMP_STEP_OPTIONS.map((option) => ( + + ))} +
); } diff --git a/components/youtube-import/YouTubeImportModal.tsx b/components/youtube-import/YouTubeImportModal.tsx index b80ede8d..7217abec 100644 --- a/components/youtube-import/YouTubeImportModal.tsx +++ b/components/youtube-import/YouTubeImportModal.tsx @@ -441,7 +441,10 @@ export function YouTubeImportModal({ return ( - + event.preventDefault()} + > Import from YouTube @@ -494,6 +497,7 @@ export function YouTubeImportModal({ onChange={(event) => setSourceUrlInput(event.target.value)} placeholder="https://www.youtube.com/watch?v=…" disabled={isResolving} + className="placeholder:opacity-45" /> diff --git a/lib/parse-trim-time-input.ts b/lib/parse-trim-time-input.ts new file mode 100644 index 00000000..b3f3217f --- /dev/null +++ b/lib/parse-trim-time-input.ts @@ -0,0 +1,93 @@ +import { formatVideoDuration } from '@/lib/format-video-duration'; + +/** Preset jump distances for coarse trim nudges (seconds). */ +export const TRIM_JUMP_STEP_OPTIONS = [1, 5, 10] as const; + +/** + * Jump distance preset in seconds for coarse trim nudges. + * @property 1 - One-second steps. + * @property 5 - Five-second steps. + * @property 10 - Ten-second steps. + */ +export type TrimJumpStepSeconds = (typeof TRIM_JUMP_STEP_OPTIONS)[number]; + +/** + * Formats seconds for display inside an editable trim timestamp field. + * Whole-second values use YouTube-style `H:MM:SS` / `M:SS`; fractional values use decimal seconds. + * @param seconds - Timestamp in seconds. + * @returns String suitable for pre-filling a trim time input. + */ +export function formatTrimTimeInputValue(seconds: number): string { + const safeSeconds = Math.max(0, seconds); + const wholeSeconds = Math.floor(safeSeconds); + const fraction = safeSeconds - wholeSeconds; + + if (fraction >= 0.001) { + return safeSeconds.toFixed(3).replace(/\.?0+$/, ''); + } + + return formatVideoDuration(safeSeconds); +} + +/** + * Parses user-entered trim timestamps. + * Accepts plain seconds (`90`, `90.5`), `M:SS`, `MM:SS`, `H:MM:SS`, and optional fractional + * seconds on the final segment (`1:30.5`). + * @param input - Raw user input. + * @returns Parsed seconds, or `null` when the input is invalid. + */ +export function parseTrimTimeInput(input: string): number | null { + const trimmed = input.trim(); + if (trimmed === '') { + return null; + } + + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : null; + } + + const parts = trimmed.split(':'); + if (parts.length < 2 || parts.length > 3) { + return null; + } + + const parsed: number[] = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index] ?? ''; + const isLast = index === parts.length - 1; + + if (isLast && part.includes('.')) { + const seconds = Number(part); + if (!Number.isFinite(seconds) || seconds < 0) { + return null; + } + parsed.push(seconds); + continue; + } + + if (!/^\d+$/.test(part)) { + return null; + } + + parsed.push(Number(part)); + } + + if (parts.length === 2) { + const minutes = parsed[0] ?? 0; + const seconds = parsed[1] ?? 0; + if (seconds >= 60) { + return null; + } + return minutes * 60 + seconds; + } + + const hours = parsed[0] ?? 0; + const minutes = parsed[1] ?? 0; + const seconds = parsed[2] ?? 0; + if (minutes >= 60 || seconds >= 60) { + return null; + } + + return hours * 3600 + minutes * 60 + seconds; +} diff --git a/lib/use-min-sm-viewport.ts b/lib/use-min-sm-viewport.ts new file mode 100644 index 00000000..ac5339b6 --- /dev/null +++ b/lib/use-min-sm-viewport.ts @@ -0,0 +1,29 @@ +import { useSyncExternalStore } from 'react'; + +const MIN_SM_MEDIA_QUERY = '(min-width: 640px)'; + +function subscribeToMinSmViewport(onStoreChange: () => void): () => void { + const mediaQueryList = window.matchMedia(MIN_SM_MEDIA_QUERY); + mediaQueryList.addEventListener('change', onStoreChange); + return () => mediaQueryList.removeEventListener('change', onStoreChange); +} + +function getMinSmViewportSnapshot(): boolean { + return window.matchMedia(MIN_SM_MEDIA_QUERY).matches; +} + +function getMinSmViewportServerSnapshot(): boolean { + return true; +} + +/** + * Tracks whether the viewport is at least Tailwind's `sm` breakpoint (640px). + * @returns `true` when `min-width: 640px` matches. + */ +export function useMinSmViewport(): boolean { + return useSyncExternalStore( + subscribeToMinSmViewport, + getMinSmViewportSnapshot, + getMinSmViewportServerSnapshot + ); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index 1548556a..e2efc055 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -13,11 +13,25 @@ // ============================================================================= import '@testing-library/jest-dom'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as axeMatchers from 'vitest-axe/matchers'; expect.extend(axeMatchers); +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('min-width: 640px'), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + // Keep tests hermetic: do not read .env files in Vitest setup. // Force test-safe values before modules are imported. process.env.JWT_SECRET = 'test-jwt-secret-for-vitest-only'; From a2ecfe2879687d8a3b1b425edd0a655d7db71922 Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Sat, 4 Jul 2026 12:47:33 -0300 Subject: [PATCH 2/4] refactor(livestreams): extract shared delete helper and fix trim timestamp blur --- .../youtube-import/TrimRangeSlider.test.tsx | 33 ++++++++++ .../delete-livestream-client.test.ts | 47 ++++++++++++++ .../dashboard/livestreams/page.tsx | 65 +++++++------------ .../StreamedLivestreamsHistoryClient.tsx | 48 +++++--------- components/youtube-import/TrimRangeSlider.tsx | 15 ++++- lib/livestreams/delete-livestream-client.ts | 21 ++++++ 6 files changed, 152 insertions(+), 77 deletions(-) create mode 100644 __tests__/lib/livestreams/delete-livestream-client.test.ts create mode 100644 lib/livestreams/delete-livestream-client.ts diff --git a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx index f2c114ee..b6e8b5f9 100644 --- a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx +++ b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx @@ -273,6 +273,39 @@ describe('TrimRangeSlider', () => { }); }); + it('commits a typed start timestamp only once when Enter is pressed', () => { + const onChange = vi.fn(); + renderSlider({ + onChange, + enableKeyframeSnap: false, + value: { startSeconds: 10, endSeconds: 100 }, + }); + + fireEvent.click(screen.getByTestId('trim-start-time-display')); + const input = screen.getByTestId('trim-start-time-input'); + fireEvent.change(input, { target: { value: '1:30' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('discards a typed start timestamp when Escape is pressed', () => { + const onChange = vi.fn(); + renderSlider({ + onChange, + enableKeyframeSnap: false, + value: { startSeconds: 10, endSeconds: 100 }, + }); + + fireEvent.click(screen.getByTestId('trim-start-time-display')); + const input = screen.getByTestId('trim-start-time-input'); + fireEvent.change(input, { target: { value: '1:30' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId('trim-start-time-display')).toHaveTextContent('0:10'); + }); + it('seeks the preview player while dragging when a player handle is provided', async () => { const playerHandle = { previewAt: vi.fn(), diff --git a/__tests__/lib/livestreams/delete-livestream-client.test.ts b/__tests__/lib/livestreams/delete-livestream-client.test.ts new file mode 100644 index 00000000..7148d4eb --- /dev/null +++ b/__tests__/lib/livestreams/delete-livestream-client.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner'; +import { deleteLivestreamViaApi } from '@/lib/livestreams/delete-livestream-client'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('deleteLivestreamViaApi', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('returns true and shows success toast when the API delete succeeds', async () => { + await expect(deleteLivestreamViaApi('livestream-1')).resolves.toBe(true); + expect(global.fetch).toHaveBeenCalledWith('/api/livestreams/livestream-1', { + method: 'DELETE', + }); + expect(toast.success).toHaveBeenCalledWith('Livestream deleted'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('returns false and shows an error toast when the API delete fails', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Cannot delete scheduled livestream' }), + } as Response); + + await expect(deleteLivestreamViaApi('livestream-2')).resolves.toBe(false); + expect(toast.error).toHaveBeenCalledWith('Cannot delete scheduled livestream'); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(dashboard)/dashboard/livestreams/page.tsx b/app/(dashboard)/dashboard/livestreams/page.tsx index 57f41846..d76886dd 100644 --- a/app/(dashboard)/dashboard/livestreams/page.tsx +++ b/app/(dashboard)/dashboard/livestreams/page.tsx @@ -21,6 +21,7 @@ import type { Livestream, } from '@/types'; import { canEditLivestreamSchedule } from '@/lib/livestreams/livestream-edit-policy'; +import { deleteLivestreamViaApi } from '@/lib/livestreams/delete-livestream-client'; import { partitionLivestreams } from '@/lib/livestreams/partition-livestreams'; import { getSchedulableLivestreamPlatforms, @@ -403,31 +404,18 @@ export default function LivestreamsPage() { setEditingLivestream(null); }, [createLivestreamSaved, editingLivestream, isCreateSession, loadLivestreams]); - const handleDeleteLivestream = useCallback( - async (livestream: Livestream) => { - if (editingLivestream?.id === livestream.id) { - toast.error('Close the livestream editor before deleting this livestream.'); - return; - } - - const title = displayTitle(livestream); - const confirmed = window.confirm(`Delete "${title}"? This cannot be undone.`); - if (!confirmed) return; - - setIsDeletingId(livestream.id); + const handleDeleteLivestreamById = useCallback( + async (livestreamId: string): Promise => { + setIsDeletingId(livestreamId); try { - const response = await fetch(`/api/livestreams/${livestream.id}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete livestream'); - } - toast.success('Livestream deleted'); - if (editingLivestream?.id === livestream.id) { + const deleted = await deleteLivestreamViaApi(livestreamId); + if (deleted && editingLivestream?.id === livestreamId) { setEditingLivestream(null); } - await loadLivestreams(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); + if (deleted) { + await loadLivestreams(); + } + return deleted; } finally { setIsDeletingId(null); } @@ -435,29 +423,20 @@ export default function LivestreamsPage() { [editingLivestream?.id, loadLivestreams] ); - const handleDeleteLivestreamById = useCallback( - async (livestreamId: string): Promise => { - setIsDeletingId(livestreamId); - try { - const response = await fetch(`/api/livestreams/${livestreamId}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete livestream'); - } - toast.success('Livestream deleted'); - if (editingLivestream?.id === livestreamId) { - setEditingLivestream(null); - } - await loadLivestreams(); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); - return false; - } finally { - setIsDeletingId(null); + const handleDeleteLivestream = useCallback( + async (livestream: Livestream) => { + if (editingLivestream?.id === livestream.id) { + toast.error('Close the livestream editor before deleting this livestream.'); + return; } + + const title = displayTitle(livestream); + const confirmed = window.confirm(`Delete "${title}"? This cannot be undone.`); + if (!confirmed) return; + + await handleDeleteLivestreamById(livestream.id); }, - [editingLivestream?.id, loadLivestreams] + [editingLivestream?.id, handleDeleteLivestreamById] ); const handleDuplicateLivestream = useCallback( diff --git a/components/livestreams/StreamedLivestreamsHistoryClient.tsx b/components/livestreams/StreamedLivestreamsHistoryClient.tsx index 739938d0..a3b7483b 100644 --- a/components/livestreams/StreamedLivestreamsHistoryClient.tsx +++ b/components/livestreams/StreamedLivestreamsHistoryClient.tsx @@ -13,6 +13,7 @@ import { toLivestreamConnectionSnapshots, type LivestreamConnectionSnapshot, } from '@/lib/livestreams/schedulable-platforms'; +import { deleteLivestreamViaApi } from '@/lib/livestreams/delete-livestream-client'; interface StreamedLivestreamsHistoryResponse extends ApiResponse { meta?: { @@ -187,45 +188,18 @@ export function StreamedLivestreamsHistoryClient() { [editingLivestream, loadHistory] ); - const handleDeleteLivestream = useCallback( - async (livestream: Livestream) => { - if (isDeletingId) return; - setIsDeletingId(livestream.id); - try { - const response = await fetch(`/api/livestreams/${livestream.id}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete livestream'); - } - toast.success('Livestream deleted'); - await loadHistory(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); - } finally { - setIsDeletingId(null); - } - }, - [isDeletingId, loadHistory] - ); - const handleDeleteLivestreamById = useCallback( async (livestreamId: string): Promise => { setIsDeletingId(livestreamId); try { - const response = await fetch(`/api/livestreams/${livestreamId}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete livestream'); - } - toast.success('Livestream deleted'); - if (editingLivestream?.id === livestreamId) { + const deleted = await deleteLivestreamViaApi(livestreamId); + if (deleted && editingLivestream?.id === livestreamId) { setEditingLivestream(null); } - await loadHistory(); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); - return false; + if (deleted) { + await loadHistory(); + } + return deleted; } finally { setIsDeletingId(null); } @@ -233,6 +207,14 @@ export function StreamedLivestreamsHistoryClient() { [editingLivestream?.id, loadHistory] ); + const handleDeleteLivestream = useCallback( + async (livestream: Livestream) => { + if (isDeletingId) return; + await handleDeleteLivestreamById(livestream.id); + }, + [isDeletingId, handleDeleteLivestreamById] + ); + const handleDuplicateLivestream = useCallback( async (livestream: Livestream) => { if (isDuplicatingId) return; diff --git a/components/youtube-import/TrimRangeSlider.tsx b/components/youtube-import/TrimRangeSlider.tsx index 88e9d10c..37d3c80e 100644 --- a/components/youtube-import/TrimRangeSlider.tsx +++ b/components/youtube-import/TrimRangeSlider.tsx @@ -184,6 +184,7 @@ function TrimTimestampField({ }: TrimTimestampFieldProps) { const inputId = useId(); const inputRef = useRef(null); + const skipBlurCommitRef = useRef(false); const [isEditing, setIsEditing] = useState(false); const [draftValue, setDraftValue] = useState(''); const [hasError, setHasError] = useState(false); @@ -192,6 +193,7 @@ function TrimTimestampField({ if (disabled || isSnapping) { return; } + skipBlurCommitRef.current = false; setDraftValue(formatTrimTimeInputValue(seconds)); setHasError(false); setIsEditing(true); @@ -206,6 +208,7 @@ function TrimTimestampField({ }, [isEditing]); const cancelEditing = () => { + skipBlurCommitRef.current = true; setIsEditing(false); setHasError(false); }; @@ -217,11 +220,21 @@ function TrimTimestampField({ return; } + skipBlurCommitRef.current = true; setIsEditing(false); setHasError(false); onCommit(parsed); }; + const handleBlur = () => { + if (skipBlurCommitRef.current) { + skipBlurCommitRef.current = false; + return; + } + + commitEditing(); + }; + const label = handle === 'start' ? 'Trim start time' : 'Trim end time'; if (isEditing) { @@ -243,7 +256,7 @@ function TrimTimestampField({ setHasError(false); } }} - onBlur={commitEditing} + onBlur={handleBlur} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); diff --git a/lib/livestreams/delete-livestream-client.ts b/lib/livestreams/delete-livestream-client.ts new file mode 100644 index 00000000..e75d939d --- /dev/null +++ b/lib/livestreams/delete-livestream-client.ts @@ -0,0 +1,21 @@ +import { toast } from 'sonner'; + +/** + * Deletes a livestream through the dashboard API and surfaces toast feedback. + * @param livestreamId - Persisted livestream identifier. + * @returns Whether deletion succeeded. + */ +export async function deleteLivestreamViaApi(livestreamId: string): Promise { + try { + const response = await fetch(`/api/livestreams/${livestreamId}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete livestream'); + } + toast.success('Livestream deleted'); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); + return false; + } +} From fe44768eade3733f87e7e05df564968d29ce5f14 Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Sat, 4 Jul 2026 17:36:02 -0300 Subject: [PATCH 3/4] fix: improve youtube import download pipeline and mobile viewport hydration --- __tests__/lib/use-min-sm-viewport.test.ts | 76 ++++ .../lib/youtube-import/run-import-job.test.ts | 132 ++++--- lib/use-min-sm-viewport.ts | 34 +- lib/youtube-import/run-import-job.ts | 332 ++++++++++++------ lib/youtube-import/yt-dlp-args.ts | 11 +- 5 files changed, 414 insertions(+), 171 deletions(-) create mode 100644 __tests__/lib/use-min-sm-viewport.test.ts diff --git a/__tests__/lib/use-min-sm-viewport.test.ts b/__tests__/lib/use-min-sm-viewport.test.ts new file mode 100644 index 00000000..a5c9484e --- /dev/null +++ b/__tests__/lib/use-min-sm-viewport.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMinSmViewport } from '@/lib/use-min-sm-viewport'; + +function ViewportProbe() { + const isMinSmViewport = useMinSmViewport(); + return createElement('span', { + 'data-viewport': isMinSmViewport ? 'desktop' : 'mobile', + }); +} + +describe('useMinSmViewport', () => { + let listeners: Set<() => void>; + let matches = false; + + beforeEach(() => { + listeners = new Set(); + matches = false; + + vi.stubGlobal('matchMedia', (query: string) => ({ + get matches() { + return matches; + }, + media: query, + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_event: string, listener: () => void) => { + listeners.delete(listener); + }, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders the mobile branch during SSR so hydration matches narrow viewports', () => { + matches = true; + const html = renderToString(createElement(ViewportProbe)); + expect(html).toContain('data-viewport="mobile"'); + }); + + it('syncs to the current viewport after mount', async () => { + matches = true; + const { result } = renderHook(() => useMinSmViewport()); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('updates when the viewport media query changes', async () => { + matches = false; + const { result } = renderHook(() => useMinSmViewport()); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + + matches = true; + act(() => { + for (const listener of listeners) { + listener(); + } + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/__tests__/lib/youtube-import/run-import-job.test.ts b/__tests__/lib/youtube-import/run-import-job.test.ts index 329e3aae..04c5200a 100644 --- a/__tests__/lib/youtube-import/run-import-job.test.ts +++ b/__tests__/lib/youtube-import/run-import-job.test.ts @@ -53,14 +53,20 @@ vi.mock('@/lib/youtube-import/import-job-fs', () => ({ import { buildYoutubeImportUploadKey, - buildYoutubeSectionSpecifier, computeDownloadPhaseProgressPercent, computeTrimOffsets, parseFfmpegTimeSeconds, parseYtDlpDownloadPercent, + parseYtDlpDownloadProgressLine, + parseYtDlpDownloadSizeToBytes, runYoutubeImportJob, + YtDlpMultiStreamDownloadProgressTracker, } from '@/lib/youtube-import/run-import-job'; -import { YT_DLP_IMPORT_DOWNLOAD_FORMAT } from '@/lib/youtube-import/yt-dlp-args'; +import { + YT_DLP_IMPORT_CONCURRENT_FRAGMENTS, + YT_DLP_IMPORT_DOWNLOAD_FORMAT, + YT_DLP_IMPORT_HTTP_CHUNK_SIZE, +} from '@/lib/youtube-import/yt-dlp-args'; type MockChild = EventEmitter & { stdout: EventEmitter; @@ -116,6 +122,8 @@ const baseJob = { $updatedAt: '2000-01-01T00:00:00.000Z', }; +const FULL_SOURCE_DURATION_SECONDS = 3600; + const WORK_DIR = '/tmp/yt-import/yt-import-job-abc'; beforeEach(() => { @@ -123,7 +131,7 @@ beforeEach(() => { mockMkdir.mockResolvedValue(undefined); mockMkdtemp.mockResolvedValue(WORK_DIR); mockRm.mockResolvedValue(undefined); - mockReadFile.mockResolvedValue(JSON.stringify({ section_start: 95 })); + mockReadFile.mockResolvedValue(JSON.stringify({ duration: FULL_SOURCE_DURATION_SECONDS })); mockStat.mockResolvedValue({ size: 4096 }); mockGetYoutubeImportJobById.mockResolvedValue(baseJob); mockUpdateYoutubeImportJobStatus.mockResolvedValue(undefined); @@ -149,7 +157,7 @@ beforeEach(() => { }); } if (command === 'ffprobe') { - return createMockChild({ stdout: '120.5\n' }); + return createMockChild({ stdout: `${FULL_SOURCE_DURATION_SECONDS}\n` }); } if (command === 'ffmpeg') { return createMockChild({}); @@ -165,6 +173,58 @@ describe('parseYtDlpDownloadPercent', () => { }); }); +describe('parseYtDlpDownloadSizeToBytes', () => { + it('converts binary and decimal size suffixes to bytes', () => { + expect(parseYtDlpDownloadSizeToBytes('10', 'MiB')).toBe(10 * 1024 ** 2); + expect(parseYtDlpDownloadSizeToBytes('1.5', 'GiB')).toBe(1.5 * 1024 ** 3); + expect(parseYtDlpDownloadSizeToBytes('512', 'KiB')).toBe(512 * 1024); + }); +}); + +describe('parseYtDlpDownloadProgressLine', () => { + it('parses percent and total size from yt-dlp download output', () => { + expect( + parseYtDlpDownloadProgressLine('[download] 50.0% of ~ 745.00MiB at 5.00MiB/s ETA 01:14') + ).toEqual({ + percent: 50, + totalBytes: 745 * 1024 ** 2, + }); + }); +}); + +describe('YtDlpMultiStreamDownloadProgressTracker', () => { + it('weights video and audio downloads into one continuous percent', () => { + const tracker = new YtDlpMultiStreamDownloadProgressTracker(); + + expect(tracker.update('[download] 50.0% of ~ 700.00MiB at 5.00MiB/s ETA 01:10')).toBeCloseTo( + 50, + 1 + ); + expect(tracker.update('[download] 100.0% of 700.00MiB in 00:02:10 at 5.00MiB/s')).toBeCloseTo( + 100, + 1 + ); + expect(tracker.update('[download] 50.0% of ~ 50.00MiB at 2.00MiB/s ETA 00:12')).toBeCloseTo( + ((700 + 25) / 750) * 100, + 1 + ); + expect(tracker.update('[download] 100.0% of 50.00MiB in 00:00:20 at 2.00MiB/s')).toBeCloseTo( + 100, + 1 + ); + }); + + it('does not reset when the next stream starts without size hints', () => { + const tracker = new YtDlpMultiStreamDownloadProgressTracker(); + + tracker.update('[download] 100.0% of 700.00MiB in 00:02:10 at 5.00MiB/s'); + const afterAudioStarts = tracker.update('[download] 1.0% of file at 1.00MiB/s ETA 00:05'); + + expect(afterAudioStarts).toBeGreaterThan(45); + expect(afterAudioStarts).toBeLessThan(55); + }); +}); + describe('parseFfmpegTimeSeconds', () => { it('parses the latest non-negative ffmpeg time value', () => { expect(parseFfmpegTimeSeconds('size=0kB time=-00:00:02.96 bitrate=N/A')).toBeNull(); @@ -185,7 +245,7 @@ describe('computeDownloadPhaseProgressPercent', () => { computeDownloadPhaseProgressPercent({ downloadPercent: 50, ffmpegTimeSeconds: 10, - sectionDurationSeconds: 60, + sourceDurationSeconds: 60, }) ).toBe(35); }); @@ -195,29 +255,21 @@ describe('computeDownloadPhaseProgressPercent', () => { computeDownloadPhaseProgressPercent({ downloadPercent: null, ffmpegTimeSeconds: 30, - sectionDurationSeconds: 60, + sourceDurationSeconds: 60, }) ).toBe(35); }); }); -describe('buildYoutubeSectionSpecifier', () => { - it('fetches a lead-in before the requested trim start', () => { - expect(buildYoutubeSectionSpecifier(100, 160)).toBe('*0-160'); - expect(buildYoutubeSectionSpecifier(200, 360)).toBe('*80-360'); - }); -}); - describe('computeTrimOffsets', () => { - it('computes relative trim points inside a section download', () => { + it('computes absolute trim points inside a full source download', () => { expect( computeTrimOffsets({ jobStartSeconds: 100, jobEndSeconds: 160, - sectionStartSeconds: 95, - downloadedDurationSeconds: 120, + downloadedDurationSeconds: 3600, }) - ).toEqual({ relativeStart: 5, relativeEnd: 65 }); + ).toEqual({ relativeStart: 100, relativeEnd: 160 }); }); }); @@ -235,8 +287,18 @@ describe('runYoutubeImportJob', () => { const ytDlpArgs = mockSpawnProcess.mock.calls.find(([command]) => command === 'yt-dlp')?.[1]; expect(ytDlpArgs).toEqual(expect.arrayContaining(['-f', YT_DLP_IMPORT_DOWNLOAD_FORMAT])); expect(ytDlpArgs).toEqual( - expect.arrayContaining(['--download-sections', '*0-160', '--force-keyframes-at-cuts']) + expect.arrayContaining([ + '--http-chunk-size', + YT_DLP_IMPORT_HTTP_CHUNK_SIZE, + '--concurrent-fragments', + String(YT_DLP_IMPORT_CONCURRENT_FRAGMENTS), + ]) ); + expect(ytDlpArgs).not.toEqual(expect.arrayContaining(['--download-sections'])); + expect(ytDlpArgs).not.toEqual(expect.arrayContaining(['--force-keyframes-at-cuts'])); + + const ffmpegArgs = mockSpawnProcess.mock.calls.find(([command]) => command === 'ffmpeg')?.[1]; + expect(ffmpegArgs).toEqual(expect.arrayContaining(['-ss', '100', '-t', '60'])); expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { status: 'downloading', @@ -282,9 +344,9 @@ describe('runYoutubeImportJob', () => { expect.objectContaining({ inputPath: `${WORK_DIR}/download.mp4`, outputPath: `${WORK_DIR}/trimmed.mp4`, - relativeStart: 5, - relativeEnd: 65, - durationSeconds: 120.5, + relativeStart: 100, + relativeEnd: 160, + durationSeconds: FULL_SOURCE_DURATION_SECONDS, isCancelled: expect.any(Function), }) ); @@ -308,29 +370,6 @@ describe('runYoutubeImportJob', () => { expect(mockUpdateUploadJobStatus).not.toHaveBeenCalled(); }); - it('updates progress from ffmpeg time output during section downloads', async () => { - mockSpawnProcess.mockImplementation((command: string) => { - if (command === 'yt-dlp') { - return createMockChild({ - stderr: 'size=512kB time=00:00:30.00 bitrate=N/A\n', - }); - } - if (command === 'ffprobe') { - return createMockChild({ stdout: '120.5\n' }); - } - if (command === 'ffmpeg') { - return createMockChild({}); - } - throw new Error(`Unexpected command: ${command}`); - }); - - await runYoutubeImportJob('yt-import-1'); - - expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { - progressPercent: 35, - }); - }); - it('marks the job failed and still cleans up temp files on errors', async () => { mockSpawnProcess.mockImplementationOnce((command: string) => { if (command === 'yt-dlp') { @@ -343,7 +382,7 @@ describe('runYoutubeImportJob', () => { expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { status: 'failed', - errorMessage: expect.stringContaining('yt-dlp section download failed'), + errorMessage: expect.stringContaining('yt-dlp source download failed'), }); expect(mockUploadLocalFileToR2).not.toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith(WORK_DIR, { recursive: true, force: true }); @@ -364,7 +403,7 @@ describe('runYoutubeImportJob', () => { } if (command === 'ffprobe') { return createMockChild({ - stdout: '120.5\n', + stdout: `${FULL_SOURCE_DURATION_SECONDS}\n`, onClose: () => { ffprobeDone = true; }, @@ -403,7 +442,6 @@ describe('runYoutubeImportJob', () => { let getCount = 0; mockGetYoutubeImportJobById.mockImplementation(async () => { getCount += 1; - // Startup reads plus immediate cancel poll see baseJob; cancellation on second scheduled poll. if (getCount < 5) { return baseJob; } diff --git a/lib/use-min-sm-viewport.ts b/lib/use-min-sm-viewport.ts index ac5339b6..5dcb6e49 100644 --- a/lib/use-min-sm-viewport.ts +++ b/lib/use-min-sm-viewport.ts @@ -1,29 +1,23 @@ -import { useSyncExternalStore } from 'react'; +import { useEffect, useState } from 'react'; const MIN_SM_MEDIA_QUERY = '(min-width: 640px)'; -function subscribeToMinSmViewport(onStoreChange: () => void): () => void { - const mediaQueryList = window.matchMedia(MIN_SM_MEDIA_QUERY); - mediaQueryList.addEventListener('change', onStoreChange); - return () => mediaQueryList.removeEventListener('change', onStoreChange); -} - -function getMinSmViewportSnapshot(): boolean { - return window.matchMedia(MIN_SM_MEDIA_QUERY).matches; -} - -function getMinSmViewportServerSnapshot(): boolean { - return true; -} - /** * Tracks whether the viewport is at least Tailwind's `sm` breakpoint (640px). + * Defaults to `false` during SSR and the first client paint so server markup + * matches hydration; syncs to `matchMedia` after mount. * @returns `true` when `min-width: 640px` matches. */ export function useMinSmViewport(): boolean { - return useSyncExternalStore( - subscribeToMinSmViewport, - getMinSmViewportSnapshot, - getMinSmViewportServerSnapshot - ); + const [isMinSmViewport, setIsMinSmViewport] = useState(false); + + useEffect(() => { + const mediaQueryList = window.matchMedia(MIN_SM_MEDIA_QUERY); + const syncViewport = () => setIsMinSmViewport(mediaQueryList.matches); + syncViewport(); + mediaQueryList.addEventListener('change', syncViewport); + return () => mediaQueryList.removeEventListener('change', syncViewport); + }, []); + + return isMinSmViewport; } diff --git a/lib/youtube-import/run-import-job.ts b/lib/youtube-import/run-import-job.ts index 3c753824..6d780b72 100644 --- a/lib/youtube-import/run-import-job.ts +++ b/lib/youtube-import/run-import-job.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { readFile, mkdir, mkdtemp, rm, stat } from '@/lib/youtube-import/import-job-fs'; +import { mkdir, mkdtemp, rm, stat } from '@/lib/youtube-import/import-job-fs'; import { join } from 'node:path'; import { uploadLocalFileToR2 } from '@/lib/r2'; import { createUploadJob, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; @@ -16,7 +16,9 @@ import { } from '@/lib/youtube-import/spawn-with-cancel'; import { buildYtDlpBaseArgs, + YT_DLP_IMPORT_CONCURRENT_FRAGMENTS, YT_DLP_IMPORT_DOWNLOAD_FORMAT, + YT_DLP_IMPORT_HTTP_CHUNK_SIZE, } from '@/lib/youtube-import/yt-dlp-args'; import type { YoutubeImportJob } from '@/types'; @@ -27,12 +29,6 @@ const DOWNLOAD_PROGRESS_MAX = 70; const TRIM_PROGRESS = 85; const UPLOAD_PROGRESS = 95; -/** - * Extra seconds to fetch before the requested trim start so yt-dlp can snap to a - * keyframe without dropping the user's in-point on long livestream VODs. - */ -export const YOUTUBE_SECTION_DOWNLOAD_LEAD_SECONDS = 120; - /** * Parses a yt-dlp `[download] …%` progress chunk. * @param chunk - stdout/stderr fragment from yt-dlp. @@ -48,9 +44,191 @@ export function parseYtDlpDownloadPercent(chunk: string): number | null { return Number.isFinite(percent) ? percent : null; } +/** + * Converts a yt-dlp human-readable size label to bytes. + * @param value - Numeric size amount from yt-dlp output. + * @param unit - Size unit suffix such as `MiB` or `GiB`. + * @returns Size in bytes, or `null` when the unit is unsupported. + */ +export function parseYtDlpDownloadSizeToBytes(value: string, unit: string): number | null { + const amount = Number(value); + if (!Number.isFinite(amount) || amount < 0) { + return null; + } + + const multipliers: Record = { + b: 1, + kib: 1024, + mib: 1024 ** 2, + gib: 1024 ** 3, + tib: 1024 ** 4, + kb: 1000, + mb: 1000 ** 2, + gb: 1000 ** 3, + }; + + const multiplier = multipliers[unit.toLowerCase()]; + return multiplier == null ? null : amount * multiplier; +} + +/** + * Parsed yt-dlp `[download]` progress for one stream. + * @property percent - Stream-local completion percent. + * @property totalBytes - Declared stream size when yt-dlp reports it. + */ +export interface YtDlpDownloadProgressLine { + /** Stream-local completion percent. */ + percent: number; + /** Declared stream size when yt-dlp reports it. */ + totalBytes: number | null; +} + +/** + * Parses percent and optional total-size hints from a yt-dlp `[download]` line. + * @param chunk - stdout/stderr fragment from yt-dlp. + * @returns Parsed stream progress, or `null` when no `[download]` percent is present. + */ +export function parseYtDlpDownloadProgressLine(chunk: string): YtDlpDownloadProgressLine | null { + const percent = parseYtDlpDownloadPercent(chunk); + if (percent == null) { + return null; + } + + const sizeMatch = + /\[download\][^\n]*%\s+of\s+~?\s*([\d.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|B)\b/i.exec(chunk); + + return { + percent, + totalBytes: sizeMatch ? parseYtDlpDownloadSizeToBytes(sizeMatch[1], sizeMatch[2]) : null, + }; +} + +interface TrackedDownloadStream { + totalBytes: number | null; + completed: boolean; + lastPercent: number; +} + +/** + * Aggregates per-stream yt-dlp `[download]` progress into one continuous 0–100% value. + * `bv*+ba` downloads video and audio separately; this weights each stream by size when + * available and falls back to equal per-stream weighting otherwise. + */ +export class YtDlpMultiStreamDownloadProgressTracker { + private streams: TrackedDownloadStream[] = []; + private currentStreamIndex = -1; + + /** + * Incorporates one yt-dlp stdout/stderr chunk. + * @param chunk - stdout/stderr fragment from yt-dlp. + * @returns Combined download percent across all streams, or `null` when no progress line is present. + */ + update(chunk: string): number | null { + const parsed = parseYtDlpDownloadProgressLine(chunk); + if (!parsed) { + return null; + } + + this.ensureCurrentStream(parsed.percent, parsed.totalBytes); + + const stream = this.streams[this.currentStreamIndex]; + if (parsed.totalBytes != null) { + stream.totalBytes = parsed.totalBytes; + } + stream.lastPercent = parsed.percent; + if (parsed.percent >= 100) { + stream.completed = true; + } + + return this.computeOverallPercent(); + } + + private ensureCurrentStream(percent: number, totalBytes: number | null): void { + const current = + this.currentStreamIndex >= 0 ? this.streams[this.currentStreamIndex] : undefined; + + if (current == null) { + this.startNewStream(percent, totalBytes); + return; + } + + if (current.completed) { + this.startNewStream(percent, totalBytes); + return; + } + + const percentReset = current.lastPercent > 30 && percent < current.lastPercent - 10; + const sizeChanged = + totalBytes != null && + current.totalBytes != null && + Math.abs(totalBytes - current.totalBytes) / current.totalBytes > 0.05; + + if (percentReset || sizeChanged) { + this.completeCurrentStream(); + this.startNewStream(percent, totalBytes); + } + } + + private startNewStream(percent: number, totalBytes: number | null): void { + this.streams.push({ + totalBytes, + completed: false, + lastPercent: percent, + }); + this.currentStreamIndex = this.streams.length - 1; + } + + private completeCurrentStream(): void { + const current = this.streams[this.currentStreamIndex]; + if (!current) { + return; + } + + current.completed = true; + current.lastPercent = 100; + } + + private computeOverallPercent(): number { + const allSizesKnown = this.streams.every( + (stream) => stream.totalBytes != null && stream.totalBytes > 0 + ); + + if (allSizesKnown) { + let downloadedBytes = 0; + let totalBytes = 0; + + for (const [index, stream] of this.streams.entries()) { + const streamTotal = stream.totalBytes ?? 0; + totalBytes += streamTotal; + + if (stream.completed) { + downloadedBytes += streamTotal; + } else if (index === this.currentStreamIndex) { + downloadedBytes += (stream.lastPercent / 100) * streamTotal; + } + } + + return totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0; + } + + const streamWeight = 100 / this.streams.length; + let combinedPercent = 0; + + for (const [index, stream] of this.streams.entries()) { + if (stream.completed) { + combinedPercent += streamWeight; + } else if (index === this.currentStreamIndex) { + combinedPercent += (stream.lastPercent / 100) * streamWeight; + } + } + + return combinedPercent; + } +} + /** * Parses the latest non-negative `time=HH:MM:SS.ms` value from ffmpeg stderr. - * Section downloads mux through ffmpeg and may not emit `[download] …%` until the end. + * Used as a fallback when yt-dlp delegates muxing to ffmpeg without `[download] …%` lines. * @param chunk - stdout/stderr fragment from yt-dlp/ffmpeg. * @returns Elapsed output seconds when present, otherwise `null`. */ @@ -76,13 +254,13 @@ export function parseFfmpegTimeSeconds(chunk: string): number | null { /** * Maps yt-dlp/ffmpeg progress signals to the download phase percent (0–70). - * @param input - Parsed progress signals and clip duration. + * @param input - Parsed progress signals and optional source duration for ffmpeg fallback. * @returns Overall job percent for the download phase, or `null` when no signal is present. */ export function computeDownloadPhaseProgressPercent(input: { downloadPercent: number | null; ffmpegTimeSeconds: number | null; - sectionDurationSeconds: number; + sourceDurationSeconds?: number; maxPercent?: number; }): number | null { const maxPercent = input.maxPercent ?? DOWNLOAD_PROGRESS_MAX; @@ -92,10 +270,11 @@ export function computeDownloadPhaseProgressPercent(input: { ratio = Math.min(1, Math.max(0, input.downloadPercent / 100)); } else if ( input.ffmpegTimeSeconds != null && - input.sectionDurationSeconds > 0 && + input.sourceDurationSeconds != null && + input.sourceDurationSeconds > 0 && input.ffmpegTimeSeconds >= 0 ) { - ratio = Math.min(1, input.ffmpegTimeSeconds / input.sectionDurationSeconds); + ratio = Math.min(1, input.ffmpegTimeSeconds / input.sourceDurationSeconds); } if (ratio == null) { @@ -106,49 +285,29 @@ export function computeDownloadPhaseProgressPercent(input: { } /** - * Computes ffmpeg copy-trim offsets inside a section download. - * @param input - Requested trim points and the section yt-dlp actually fetched. - * @returns Relative `-ss`/`-to` values for the downloaded file. + * Computes ffmpeg trim offsets inside a full source download. + * @param input - Requested trim points and the downloaded file duration. + * @returns Absolute `-ss`/`-t` values for the downloaded file. */ export function computeTrimOffsets(input: { jobStartSeconds: number; jobEndSeconds: number; - sectionStartSeconds: number; downloadedDurationSeconds: number; }): { relativeStart: number; relativeEnd: number } { - const relativeStart = Math.max(0, input.jobStartSeconds - input.sectionStartSeconds); - const relativeEnd = Math.min( - input.downloadedDurationSeconds, - input.jobEndSeconds - input.sectionStartSeconds - ); + const relativeStart = Math.max(0, input.jobStartSeconds); + const relativeEnd = Math.min(input.downloadedDurationSeconds, input.jobEndSeconds); if ( !Number.isFinite(relativeStart) || !Number.isFinite(relativeEnd) || relativeEnd <= relativeStart ) { - throw new Error('Trim range is empty after section download'); + throw new Error('Trim range is empty after source download'); } return { relativeStart, relativeEnd }; } -/** - * Builds the yt-dlp `--download-sections` specifier for a trim job. - * Fetches a short lead-in before the requested start so section/keyframe snapping - * does not omit the user's in-point. - * @param jobStartSeconds - Requested trim start in seconds on the source timeline. - * @param jobEndSeconds - Requested trim end in seconds on the source timeline. - * @returns yt-dlp section specifier (e.g. `*3025-3258`). - */ -export function buildYoutubeSectionSpecifier( - jobStartSeconds: number, - jobEndSeconds: number -): string { - const leadStart = Math.max(0, jobStartSeconds - YOUTUBE_SECTION_DOWNLOAD_LEAD_SECONDS); - return `*${leadStart}-${jobEndSeconds}`; -} - /** * Builds an R2 staging key for a YouTube import upload, mirroring presign naming. * @param userId - Owning user id. @@ -202,67 +361,37 @@ async function runSpawnStdout( return stdout.trim(); } -function parseSectionStartFromInfoJson(info: Record): number | null { - if (typeof info.section_start === 'number' && Number.isFinite(info.section_start)) { - return info.section_start; - } - - const requested = info.requested_downloads; - if (Array.isArray(requested) && requested.length > 0) { - const first = requested[0]; - if (typeof first === 'object' && first !== null) { - const sectionStart = (first as Record).section_start; - if (typeof sectionStart === 'number' && Number.isFinite(sectionStart)) { - return sectionStart; - } - } - } - - return null; -} - async function readDownloadMetadata( workDir: string, jobId: string ): Promise<{ downloadedPath: string; - sectionStartSeconds: number; downloadedDurationSeconds: number; }> { const downloadedPath = join(workDir, `${DOWNLOADED_BASENAME}.mp4`); - const infoPath = join(workDir, `${DOWNLOADED_BASENAME}.info.json`); const isCancelled = () => isImportJobCancelled(jobId); - const [infoRaw, durationRaw] = await Promise.all([ - readFile(infoPath, 'utf8'), - runSpawnStdout( - 'ffprobe', - [ - '-v', - 'error', - '-show_entries', - 'format=duration', - '-of', - 'default=noprint_wrappers=1:nokey=1', - downloadedPath, - ], - 'ffprobe duration probe', - { isCancelled } - ), - ]); - - const info = JSON.parse(infoRaw) as Record; - const sectionStartSeconds = parseSectionStartFromInfoJson(info); - if (sectionStartSeconds == null) { - throw new Error('yt-dlp info JSON did not include section_start metadata'); - } + const durationRaw = await runSpawnStdout( + 'ffprobe', + [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + downloadedPath, + ], + 'ffprobe duration probe', + { isCancelled } + ); const downloadedDurationSeconds = Number(durationRaw); if (!Number.isFinite(downloadedDurationSeconds) || downloadedDurationSeconds <= 0) { - throw new Error('Downloaded section has invalid duration'); + throw new Error('Downloaded source has invalid duration'); } - return { downloadedPath, sectionStartSeconds, downloadedDurationSeconds }; + return { downloadedPath, downloadedDurationSeconds }; } async function isImportJobCancelled(jobId: string): Promise { @@ -270,29 +399,26 @@ async function isImportJobCancelled(jobId: string): Promise { return current?.status === 'cancelled'; } -async function downloadYoutubeSection( +async function downloadYoutubeSource( job: YoutubeImportJob, workDir: string, jobId: string ): Promise<{ downloadedPath: string; - sectionStartSeconds: number; downloadedDurationSeconds: number; }> { const watchUrl = buildYouTubeWatchUrl(job.youtubeVideoId); const outputTemplate = join(workDir, `${DOWNLOADED_BASENAME}.%(ext)s`); - const section = buildYoutubeSectionSpecifier(job.startSeconds, job.endSeconds); let lastPersistedPercent = -1; - const sectionDurationSeconds = Math.max(1, job.endSeconds - job.startSeconds); + const progressTracker = new YtDlpMultiStreamDownloadProgressTracker(); void updateYoutubeImportJobStatus(jobId, { progressPercent: 1 }); const handleDownloadProgressChunk = (chunk: string) => { const overallPercent = computeDownloadPhaseProgressPercent({ - downloadPercent: parseYtDlpDownloadPercent(chunk), + downloadPercent: progressTracker.update(chunk), ffmpegTimeSeconds: parseFfmpegTimeSeconds(chunk), - sectionDurationSeconds, }); if (overallPercent == null || overallPercent === lastPersistedPercent) { return; @@ -308,9 +434,10 @@ async function downloadYoutubeSection( ...buildYtDlpBaseArgs(), '--no-playlist', '--newline', - '--download-sections', - section, - '--force-keyframes-at-cuts', + '--http-chunk-size', + YT_DLP_IMPORT_HTTP_CHUNK_SIZE, + '--concurrent-fragments', + String(YT_DLP_IMPORT_CONCURRENT_FRAGMENTS), '-f', YT_DLP_IMPORT_DOWNLOAD_FORMAT, '--merge-output-format', @@ -324,7 +451,7 @@ async function downloadYoutubeSection( outputTemplate, watchUrl, ], - 'yt-dlp section download', + 'yt-dlp source download', { onStderrChunk: handleDownloadProgressChunk, onStdoutChunk: handleDownloadProgressChunk, @@ -335,11 +462,10 @@ async function downloadYoutubeSection( return readDownloadMetadata(workDir, jobId); } -async function trimDownloadedSection( +async function trimDownloadedSource( job: YoutubeImportJob, download: { downloadedPath: string; - sectionStartSeconds: number; downloadedDurationSeconds: number; }, workDir: string, @@ -348,9 +474,9 @@ async function trimDownloadedSection( const { relativeStart, relativeEnd } = computeTrimOffsets({ jobStartSeconds: job.startSeconds, jobEndSeconds: job.endSeconds, - sectionStartSeconds: download.sectionStartSeconds, downloadedDurationSeconds: download.downloadedDurationSeconds, }); + const trimDurationSeconds = relativeEnd - relativeStart; const trimmedPath = join(workDir, TRIMMED_BASENAME); @@ -374,10 +500,10 @@ async function trimDownloadedSection( 'error', '-ss', String(relativeStart), - '-to', - String(relativeEnd), '-i', download.downloadedPath, + '-t', + String(trimDurationSeconds), '-c', 'copy', '-y', @@ -397,8 +523,8 @@ async function trimDownloadedSection( } /** - * Executes a single YouTube import/trim job end to end: downloads the - * requested time range, trims it with a stream-copy ffmpeg pass, uploads + * Executes a single YouTube import/trim job end to end: downloads the full + * source video, trims the requested range with a local ffmpeg pass, uploads * the result to R2, and hands off to the standard upload/distribution * pipeline. Updates the job's status/progress as it proceeds so callers * polling `getYoutubeImportJobById` see live progress. @@ -425,7 +551,7 @@ export async function runYoutubeImportJob(jobId: string): Promise { await updateYoutubeImportJobStatus(jobId, { status: 'downloading', progressPercent: 0 }); } - const download = await downloadYoutubeSection(job, workDir, jobId); + const download = await downloadYoutubeSource(job, workDir, jobId); if (await isImportJobCancelled(jobId)) { return; @@ -435,7 +561,7 @@ export async function runYoutubeImportJob(jobId: string): Promise { status: 'trimming', progressPercent: DOWNLOAD_PROGRESS_MAX, }); - const trimmedPath = await trimDownloadedSection(job, download, workDir, jobId); + const trimmedPath = await trimDownloadedSource(job, download, workDir, jobId); if (await isImportJobCancelled(jobId)) { return; diff --git a/lib/youtube-import/yt-dlp-args.ts b/lib/youtube-import/yt-dlp-args.ts index 27d2422a..a2c4f091 100644 --- a/lib/youtube-import/yt-dlp-args.ts +++ b/lib/youtube-import/yt-dlp-args.ts @@ -5,7 +5,7 @@ import { execPath } from 'node:process'; export const YT_DLP_DEFAULT_REMOTE_COMPONENTS = 'ejs:github'; /** - * yt-dlp format selector for YouTube import section downloads. + * yt-dlp format selector for YouTube import source downloads. * YouTube's default `bv*+ba` pairs best video with DASH Opus audio (typically 48 kHz), * even when the source livestream was ingested as MPEG-4 AAC (often 44.1 kHz). * Prefer m4a/mp4a audio so the merged MP4 stays AAC. @@ -13,6 +13,15 @@ export const YT_DLP_DEFAULT_REMOTE_COMPONENTS = 'ejs:github'; export const YT_DLP_IMPORT_DOWNLOAD_FORMAT = 'bv*+ba[ext=m4a]/bv*+ba[acodec^=mp4a.40.]/bv*+ba[acodec^=mp4a]/bv*+ba/b'; +/** + * HTTP chunk size for YouTube import downloads. + * Stays at or under YouTube's undocumented ~10MB per-request throttle threshold. + */ +export const YT_DLP_IMPORT_HTTP_CHUNK_SIZE = '10M'; + +/** Number of DASH/HLS fragments to fetch in parallel during import downloads. */ +export const YT_DLP_IMPORT_CONCURRENT_FRAGMENTS = 4; + function resolveDenoExecutable(): string | null { const candidates = [ process.env.DENO_BIN?.trim(), From a53329c2f77fe548a67859b4ac6de2ff9a467960 Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Sat, 4 Jul 2026 17:36:35 -0300 Subject: [PATCH 4/4] chore: bumped version # --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index da3aa9d9..9187a92f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "videosphere", - "version": "0.2.0", + "version": "0.2.1", "private": true, "engines": { "node": ">=24.0.0",