From e340eb4dcc186bc704d105d850b0aae2281e9a26 Mon Sep 17 00:00:00 2001
From: threehappypenguins
Date: Sun, 5 Jul 2026 20:13:41 -0300
Subject: [PATCH 1/3] feat(youtube-import): fallback to embed preview when no
browser-streamable MP4 exists
---
__tests__/api/youtube-import/resolve.test.ts | 37 +++
.../youtube-import/TrimRangeSlider.test.tsx | 28 +-
.../YouTubeEmbedPreviewPlayer.test.tsx | 89 +++++++
.../YouTubeImportModal.test.tsx | 67 ++++-
.../youtube-import/probe-keyframes.test.ts | 46 ++++
app/api/youtube-import/resolve/route.ts | 12 +
components/youtube-import/TrimRangeSlider.tsx | 60 +++--
.../YouTubeEmbedPreviewPlayer.tsx | 244 ++++++++++++++++++
.../youtube-import/YouTubeImportModal.tsx | 69 ++---
lib/youtube-import/probe-keyframes.ts | 35 ++-
lib/youtube-import/resolve-source.ts | 30 ++-
11 files changed, 632 insertions(+), 85 deletions(-)
create mode 100644 __tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx
create mode 100644 components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
diff --git a/__tests__/api/youtube-import/resolve.test.ts b/__tests__/api/youtube-import/resolve.test.ts
index f6485a1a..807d2afa 100644
--- a/__tests__/api/youtube-import/resolve.test.ts
+++ b/__tests__/api/youtube-import/resolve.test.ts
@@ -40,6 +40,7 @@ vi.mock('@/lib/youtube-import/preview-media-url', () => ({
}));
import { POST } from '@/app/api/youtube-import/resolve/route';
+import { NoBrowserStreamableFormatError } from '@/lib/youtube-import/probe-keyframes';
const USER_ID = 'user-123';
const ACCESS_TOKEN = 'yt-access-token';
@@ -134,6 +135,7 @@ describe('POST /api/youtube-import/resolve', () => {
const body = await response.json();
expect(body.data).toEqual({
...resolvedMetadata,
+ previewMode: 'direct',
previewStreamUrl: '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ',
previewExpiresAt: expect.any(Number),
});
@@ -200,6 +202,41 @@ describe('POST /api/youtube-import/resolve', () => {
expect(body.message).toBe('quotaExceeded');
});
+ it('returns embed preview mode when no browser-streamable format exists', async () => {
+ mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce(
+ new NoBrowserStreamableFormatError(
+ 'yt-dlp returned formats but none are browser-streamable progressive MP4'
+ )
+ );
+
+ const response = await POST(
+ createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
+ );
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.data).toEqual({
+ ...resolvedMetadata,
+ previewMode: 'embed',
+ });
+ expect(body.data.previewStreamUrl).toBeUndefined();
+ expect(body.data.previewExpiresAt).toBeUndefined();
+ });
+
+ it('returns 502 when preview media resolution fails for other reasons', async () => {
+ mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce(
+ new Error('yt-dlp metadata lookup failed')
+ );
+
+ const response = await POST(
+ createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
+ );
+
+ expect(response.status).toBe(502);
+ const body = await response.json();
+ expect(body.message).toBe('yt-dlp metadata lookup failed');
+ });
+
it('returns 400 when mapYouTubeImportResolvedSource rejects the video', async () => {
mockMapYouTubeImportResolvedSource.mockReturnValueOnce({
ok: false,
diff --git a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx
index b6e8b5f9..2670e545 100644
--- a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx
+++ b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx
@@ -38,14 +38,14 @@ function renderSlider(
) {
const onChange = props.onChange ?? vi.fn();
const initialValue = props.value ?? { startSeconds: 10, endSeconds: 100 };
+ const { onChange: _onChange, value: _value, ...sliderProps } = props;
const view = render(
);
return { onChange, ...view };
@@ -201,6 +201,26 @@ describe('TrimRangeSlider', () => {
expect(onChange).toHaveBeenCalled();
});
+ it('hides frame nudge buttons but keeps jump-step controls when showFrameNudge is false', () => {
+ renderSlider({ showFrameNudge: false });
+
+ expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument();
+ expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument();
+ expect(screen.getByTestId('trim-start-jump-later')).toBeInTheDocument();
+ expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument();
+ });
+
+ it('does not request keyframes when showFrameNudge is false and snapping is disabled', () => {
+ renderSlider({ showFrameNudge: false, enableKeyframeSnap: false });
+
+ fireEvent.click(screen.getByTestId('trim-start-jump-later'));
+
+ expect(global.fetch).not.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 } });
diff --git a/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx b/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx
new file mode 100644
index 00000000..99f21d23
--- /dev/null
+++ b/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { YouTubeEmbedPreviewPlayer } from '@/components/youtube-import/YouTubeEmbedPreviewPlayer';
+import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer';
+
+const VIDEO_ID = 'dQw4w9WgXcQ';
+
+describe('YouTubeEmbedPreviewPlayer', () => {
+ const mockSeekTo = vi.fn();
+ const mockGetCurrentTime = vi.fn();
+ const mockDestroy = vi.fn();
+
+ class MockYouTubePlayer {
+ seekTo = mockSeekTo;
+ getCurrentTime = mockGetCurrentTime;
+ destroy = mockDestroy;
+
+ constructor(
+ _element: HTMLElement,
+ options?: {
+ videoId?: string;
+ events?: {
+ onReady?: (event: { target: MockYouTubePlayer }) => void;
+ };
+ }
+ ) {
+ options?.events?.onReady?.({ target: this });
+ }
+ }
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockSeekTo.mockReset();
+ mockGetCurrentTime.mockReset();
+ mockDestroy.mockReset();
+ mockGetCurrentTime.mockReturnValue(42);
+
+ window.YT = {
+ Player: MockYouTubePlayer as unknown as NonNullable['Player'],
+ };
+ });
+
+ it('renders an embed preview container and creates a YT.Player for the video id', async () => {
+ const playerSpy = vi.spyOn(window.YT!, 'Player');
+ const { container } = render( );
+
+ expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(playerSpy).toHaveBeenCalledWith(
+ container.querySelector('.aspect-video div'),
+ expect.objectContaining({
+ videoId: VIDEO_ID,
+ })
+ );
+ });
+ });
+
+ it('exposes previewAt and getCurrentTime through playerRef', async () => {
+ const playerRef = { current: null as YouTubePlayerHandle | null };
+
+ render( );
+
+ await waitFor(() => {
+ expect(playerRef.current).not.toBeNull();
+ });
+
+ playerRef.current?.previewAt(120);
+
+ await waitFor(() => {
+ expect(mockSeekTo).toHaveBeenCalledWith(120, true);
+ });
+
+ expect(playerRef.current?.getCurrentTime()).toBe(42);
+ });
+
+ it('destroys the player on unmount', async () => {
+ const playerSpy = vi.spyOn(window.YT!, 'Player');
+ const { unmount } = render( );
+
+ await waitFor(() => {
+ expect(playerSpy).toHaveBeenCalled();
+ });
+
+ unmount();
+
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/youtube-import/YouTubeImportModal.test.tsx b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx
index c58d80f4..4ff9ea6d 100644
--- a/__tests__/components/youtube-import/YouTubeImportModal.test.tsx
+++ b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx
@@ -15,22 +15,16 @@ vi.mock('@/components/youtube-import/YouTubePreviewPlayer', () => ({
YouTubePreviewPlayer: () =>
,
}));
-vi.mock('@/components/youtube-import/TrimRangeSlider', () => ({
- TrimRangeSlider: ({
- onChange,
- }: {
- onChange: (value: { startSeconds: number; endSeconds: number }) => void;
- }) => (
- onChange({ startSeconds: 5, endSeconds: 95 })}
- data-testid="trim-range-slider"
- >
- Adjust trim
-
- ),
+vi.mock('@/components/youtube-import/YouTubeEmbedPreviewPlayer', () => ({
+ YouTubeEmbedPreviewPlayer: () =>
,
}));
+class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
const DRAFT_ID = 'draft-1';
const VIDEO_ID = 'dQw4w9WgXcQ';
@@ -39,10 +33,19 @@ const resolvedSource = {
title: 'Sunday Service',
durationSeconds: 3600,
thumbnailUrl: 'https://img.youtube.com/high.jpg',
+ previewMode: 'direct' as const,
previewStreamUrl: `/api/youtube-import/preview/stream?youtubeVideoId=${VIDEO_ID}`,
previewExpiresAt: Date.now() + 3_600_000,
};
+const embedResolvedSource = {
+ youtubeVideoId: VIDEO_ID,
+ title: 'Sunday Service',
+ durationSeconds: 3600,
+ thumbnailUrl: 'https://img.youtube.com/high.jpg',
+ previewMode: 'embed' as const,
+};
+
const livestreamRow: Livestream = {
id: 'livestream-1',
userId: 'user-1',
@@ -204,6 +207,7 @@ function renderModal(props: Partial> =
beforeEach(() => {
vi.clearAllMocks();
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterEach(() => {
@@ -246,6 +250,41 @@ describe('YouTubeImportModal', () => {
);
});
+ it('uses embed preview when resolve returns previewMode embed', async () => {
+ installFetchMock({
+ resolve: () => new Response(JSON.stringify({ data: embedResolvedSource }), { status: 200 }),
+ });
+ const user = userEvent.setup();
+
+ renderModal();
+
+ await waitFor(() => {
+ expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument();
+ });
+
+ await user.type(
+ screen.getByLabelText(/youtube link/i),
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
+ );
+ await user.click(screen.getByRole('button', { name: /use this link/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument();
+ expect(screen.queryByTestId('youtube-preview-player')).not.toBeInTheDocument();
+ });
+
+ const smartCutCheckbox = screen.getByRole('checkbox', { name: /smart cut/i });
+ expect(smartCutCheckbox).toBeChecked();
+ expect(smartCutCheckbox).toBeDisabled();
+
+ expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument();
+ expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument();
+ expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument();
+ });
+
it('hides the preview player when Show video preview is unchecked', async () => {
installFetchMock({});
const user = userEvent.setup();
diff --git a/__tests__/lib/youtube-import/probe-keyframes.test.ts b/__tests__/lib/youtube-import/probe-keyframes.test.ts
index d2bd7afe..7f2f55d3 100644
--- a/__tests__/lib/youtube-import/probe-keyframes.test.ts
+++ b/__tests__/lib/youtube-import/probe-keyframes.test.ts
@@ -11,6 +11,7 @@ import {
buildFfprobeReadInterval,
getDirectMediaUrl,
isBrowserStreamableMp4Format,
+ NoBrowserStreamableFormatError,
parseFfprobeKeyframeCsv,
parseFfprobeKeyframePacketCsv,
pickYtDlpProbeFormat,
@@ -493,4 +494,49 @@ describe('getDirectMediaUrl', () => {
await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow(/timed out after 50ms/);
});
+
+ it('throws NoBrowserStreamableFormatError when only DASH/HLS formats exist', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ mockSpawnProcess.mockImplementationOnce(() =>
+ createMockChild({
+ stdout: JSON.stringify({
+ duration: 3600,
+ formats: [
+ {
+ url: 'https://example.com/manifest.mpd',
+ height: 720,
+ vcodec: 'avc1',
+ acodec: 'none',
+ ext: 'mp4',
+ protocol: 'http_dash_segments',
+ format_id: '137',
+ },
+ {
+ url: 'https://example.com/playlist.m3u8',
+ height: 720,
+ vcodec: 'avc1',
+ acodec: 'mp4a',
+ ext: 'mp4',
+ protocol: 'm3u8_native',
+ format_id: '96',
+ },
+ ],
+ }),
+ })
+ );
+
+ await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toBeInstanceOf(
+ NoBrowserStreamableFormatError
+ );
+ expect(warnSpy).toHaveBeenCalledWith(
+ '[getDirectMediaUrl] No browser-streamable MP4 format; yt-dlp format list:',
+ [
+ { protocol: 'http_dash_segments', ext: 'mp4', format_id: '137' },
+ { protocol: 'm3u8_native', ext: 'mp4', format_id: '96' },
+ ]
+ );
+
+ warnSpy.mockRestore();
+ });
});
diff --git a/app/api/youtube-import/resolve/route.ts b/app/api/youtube-import/resolve/route.ts
index 7ab5c4b5..c7e44ecb 100644
--- a/app/api/youtube-import/resolve/route.ts
+++ b/app/api/youtube-import/resolve/route.ts
@@ -16,6 +16,7 @@ import {
buildYoutubeImportPreviewStreamPath,
resolvePreviewDirectMediaUrl,
} from '@/lib/youtube-import/preview-media-url';
+import { NoBrowserStreamableFormatError } from '@/lib/youtube-import/probe-keyframes';
import type { ApiError, ApiResponse } from '@/types';
interface ResolveYouTubeImportRequestBody {
@@ -172,6 +173,16 @@ export async function POST(req: NextRequest): Promise {
const previewMedia = await resolvePreviewDirectMediaUrl(userId, mapped.data.youtubeVideoId);
previewExpiresAt = previewMedia.expiresAt;
} catch (err) {
+ if (err instanceof NoBrowserStreamableFormatError) {
+ const res: ApiResponse = {
+ data: {
+ ...mapped.data,
+ previewMode: 'embed',
+ },
+ };
+ return NextResponse.json(res, { status: 200 });
+ }
+
const message =
err instanceof Error && err.message.trim() !== ''
? err.message.trim()
@@ -182,6 +193,7 @@ export async function POST(req: NextRequest): Promise {
const res: ApiResponse = {
data: {
...mapped.data,
+ previewMode: 'direct',
previewStreamUrl: buildYoutubeImportPreviewStreamPath(mapped.data.youtubeVideoId),
previewExpiresAt,
},
diff --git a/components/youtube-import/TrimRangeSlider.tsx b/components/youtube-import/TrimRangeSlider.tsx
index 37d3c80e..c59f19ed 100644
--- a/components/youtube-import/TrimRangeSlider.tsx
+++ b/components/youtube-import/TrimRangeSlider.tsx
@@ -44,6 +44,8 @@ export interface TrimRangeSliderProps {
playerHandle?: YouTubePlayerHandle;
/** When false, trim handles stay without keyframe probing after precise edits. */
enableKeyframeSnap?: boolean;
+ /** When false, hides one-frame nudge buttons (embed preview mode). */
+ showFrameNudge?: boolean;
}
/**
@@ -302,6 +304,8 @@ interface TrimHandleControlsProps {
disabled: boolean;
/** Whether a keyframe snap is in progress for this handle. */
isSnapping: boolean;
+ /** Whether frame-by-frame nudge buttons are shown. */
+ showFrameNudge: boolean;
/** Selected coarse jump distance in seconds. */
jumpStepSeconds: TrimJumpStepSeconds;
/** Whether the jump-earlier button is enabled. */
@@ -337,6 +341,7 @@ function TrimHandleControls({
seconds,
disabled,
isSnapping,
+ showFrameNudge,
jumpStepSeconds,
canJumpEarlier,
canFrameEarlier,
@@ -362,18 +367,20 @@ function TrimHandleControls({
{jumpStepSeconds}s
- onNudge(-1, TRIM_NUDGE_STEP_SECONDS)}
- >
-
-
+ {showFrameNudge ? (
+ onNudge(-1, TRIM_NUDGE_STEP_SECONDS)}
+ >
+
+
+ ) : null}
- onNudge(1, TRIM_NUDGE_STEP_SECONDS)}
- >
-
-
+ {showFrameNudge ? (
+ onNudge(1, TRIM_NUDGE_STEP_SECONDS)}
+ >
+
+
+ ) : null}
(5);
const [snappingHandle, setSnappingHandle] = useState<'start' | 'end' | null>(null);
@@ -649,6 +659,7 @@ export function TrimRangeSlider({
seconds={value.startSeconds}
disabled={disabled}
isSnapping={snappingHandle === 'start'}
+ showFrameNudge={showFrameNudge}
jumpStepSeconds={jumpStepSeconds}
canJumpEarlier={startCanJumpEarlier}
canFrameEarlier={startCanFrameEarlier}
@@ -708,6 +719,7 @@ export function TrimRangeSlider({
seconds={value.endSeconds}
disabled={disabled}
isSnapping={snappingHandle === 'end'}
+ showFrameNudge={showFrameNudge}
jumpStepSeconds={jumpStepSeconds}
canJumpEarlier={endCanJumpEarlier}
canFrameEarlier={endCanFrameEarlier}
diff --git a/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx b/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
new file mode 100644
index 00000000..781e6e82
--- /dev/null
+++ b/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
+import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer';
+
+const YOUTUBE_IFRAME_API_SRC = 'https://www.youtube.com/iframe_api';
+
+/** Minimal YouTube IFrame Player API surface used for import preview. */
+interface YouTubeIframePlayer {
+ seekTo(seconds: number, allowSeekAhead: boolean): void;
+ getCurrentTime(): number;
+ destroy(): void;
+}
+
+interface YouTubeIframePlayerOptions {
+ videoId: string;
+ playerVars?: Record;
+ events?: {
+ onReady?: (event: { target: YouTubeIframePlayer }) => void;
+ onError?: (event: { data: number }) => void;
+ };
+}
+
+interface YouTubeIframeApi {
+ Player: new (
+ element: HTMLElement | string,
+ options: YouTubeIframePlayerOptions
+ ) => YouTubeIframePlayer;
+}
+
+declare global {
+ interface Window {
+ YT?: YouTubeIframeApi;
+ onYouTubeIframeAPIReady?: () => void;
+ }
+}
+
+let iframeApiPromise: Promise | null = null;
+
+/**
+ * Loads the YouTube IFrame Player API script once per page.
+ * @returns Promise that resolves when `window.YT.Player` is available.
+ */
+function loadYouTubeIframeApi(): Promise {
+ if (typeof window === 'undefined') {
+ return Promise.reject(new Error('YouTube IFrame API requires a browser environment'));
+ }
+
+ if (window.YT?.Player) {
+ return Promise.resolve();
+ }
+
+ if (iframeApiPromise) {
+ return iframeApiPromise;
+ }
+
+ iframeApiPromise = new Promise((resolve, reject) => {
+ const existingScript = document.querySelector(
+ `script[src="${YOUTUBE_IFRAME_API_SRC}"]`
+ );
+ if (existingScript) {
+ if (window.YT?.Player) {
+ resolve();
+ return;
+ }
+
+ const previousReady = window.onYouTubeIframeAPIReady;
+ window.onYouTubeIframeAPIReady = () => {
+ previousReady?.();
+ resolve();
+ };
+ return;
+ }
+
+ const previousReady = window.onYouTubeIframeAPIReady;
+ window.onYouTubeIframeAPIReady = () => {
+ previousReady?.();
+ resolve();
+ };
+
+ const script = document.createElement('script');
+ script.src = YOUTUBE_IFRAME_API_SRC;
+ script.async = true;
+ script.onerror = () => {
+ iframeApiPromise = null;
+ reject(new Error('Failed to load YouTube IFrame Player API'));
+ };
+ document.head.appendChild(script);
+ });
+
+ return iframeApiPromise;
+}
+
+/**
+ * Props for {@link YouTubeEmbedPreviewPlayer}.
+ */
+export interface YouTubeEmbedPreviewPlayerProps {
+ /** YouTube video id to preview. */
+ youtubeVideoId: string;
+ /** Optional ref receiving imperative playback controls. */
+ playerRef?: React.RefObject;
+}
+
+/**
+ * YouTube IFrame Player preview for import trim when no direct stream is available.
+ * @param props - Player configuration.
+ * @returns Embed preview player container element.
+ */
+export function YouTubeEmbedPreviewPlayer({
+ youtubeVideoId,
+ playerRef,
+}: YouTubeEmbedPreviewPlayerProps) {
+ return (
+
+ );
+}
+
+function YouTubeEmbedPreviewPlayerInner({
+ youtubeVideoId,
+ playerRef,
+}: YouTubeEmbedPreviewPlayerProps) {
+ const containerRef = useRef(null);
+ const playerInstanceRef = useRef(null);
+ const pendingPreviewSecondsRef = useRef(null);
+ const [playbackError, setPlaybackError] = useState(null);
+
+ const seekPreviewTo = useCallback((seconds: number) => {
+ const player = playerInstanceRef.current;
+ const clampedSeconds = Math.max(0, seconds);
+ if (!player) {
+ pendingPreviewSecondsRef.current = clampedSeconds;
+ return;
+ }
+
+ player.seekTo(clampedSeconds, true);
+ }, []);
+
+ const previewAt = useCallback(
+ (seconds: number) => {
+ seekPreviewTo(seconds);
+ },
+ [seekPreviewTo]
+ );
+
+ useImperativeHandle(
+ playerRef,
+ () => ({
+ previewAt(seconds: number) {
+ previewAt(seconds);
+ },
+ getCurrentTime() {
+ return playerInstanceRef.current?.getCurrentTime() ?? 0;
+ },
+ }),
+ [previewAt]
+ );
+
+ useEffect(() => {
+ pendingPreviewSecondsRef.current = null;
+
+ let disposed = false;
+ let player: YouTubeIframePlayer | null = null;
+
+ void (async () => {
+ try {
+ await loadYouTubeIframeApi();
+ if (disposed || !containerRef.current || !window.YT?.Player) {
+ return;
+ }
+
+ player = new window.YT.Player(containerRef.current, {
+ videoId: youtubeVideoId,
+ playerVars: {
+ controls: 1,
+ modestbranding: 1,
+ rel: 0,
+ fs: 0,
+ playsinline: 1,
+ },
+ events: {
+ onReady: (event) => {
+ if (disposed) {
+ return;
+ }
+
+ playerInstanceRef.current = event.target;
+ const pendingSeconds = pendingPreviewSecondsRef.current;
+ if (pendingSeconds != null) {
+ pendingPreviewSecondsRef.current = null;
+ event.target.seekTo(pendingSeconds, true);
+ }
+ },
+ onError: () => {
+ if (disposed) {
+ return;
+ }
+ setPlaybackError(
+ 'Embed preview failed to load. Confirm the video is accessible on YouTube and try again.'
+ );
+ },
+ },
+ });
+ playerInstanceRef.current = player;
+ } catch (error) {
+ if (!disposed) {
+ const message = error instanceof Error ? error.message : 'Failed to load embed preview';
+ setPlaybackError(message);
+ }
+ }
+ })();
+
+ return () => {
+ disposed = true;
+ playerInstanceRef.current = null;
+ player?.destroy();
+ };
+ }, [youtubeVideoId]);
+
+ return (
+
+
+ {playbackError ? (
+
+ {playbackError}
+
+ ) : (
+
+ Preview uses YouTube's embed player. Trim points are approximate until import; smart
+ cut snaps to the nearest keyframe in the downloaded file.
+
+ )}
+
+ );
+}
diff --git a/components/youtube-import/YouTubeImportModal.tsx b/components/youtube-import/YouTubeImportModal.tsx
index 7217abec..9bde3b73 100644
--- a/components/youtube-import/YouTubeImportModal.tsx
+++ b/components/youtube-import/YouTubeImportModal.tsx
@@ -10,6 +10,7 @@ import {
YouTubePreviewPlayer,
type YouTubePlayerHandle,
} from '@/components/youtube-import/YouTubePreviewPlayer';
+import { YouTubeEmbedPreviewPlayer } from '@/components/youtube-import/YouTubeEmbedPreviewPlayer';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
@@ -37,6 +38,7 @@ import {
isActiveYoutubeImportStatus,
} from '@/lib/youtube-import/import-job-ui';
import type { ApiResponse, Livestream, YoutubeImportJob } from '@/types';
+import type { YouTubeImportResolvedSource } from '@/lib/youtube-import/resolve-source';
/** Poll interval for in-flight import jobs (matches draft upload polling). */
const IMPORT_JOB_POLL_INTERVAL_MS = 3000;
@@ -50,18 +52,12 @@ const LIVESTREAM_IMPORT_PAGE_SIZE = 2;
type YouTubeImportModalStep = 'source' | 'editor' | 'progress';
/**
- * Resolved YouTube source metadata from the resolve API.
+ * Resolved YouTube source stored in modal state, matching the resolve API plus caller context.
*/
-interface ResolvedYouTubeSource {
- youtubeVideoId: string;
- title: string;
- durationSeconds: number;
- thumbnailUrl: string;
- previewStreamUrl: string;
- previewExpiresAt: number;
+type ResolvedYouTubeSource = YouTubeImportResolvedSource & {
sourceUrl?: string;
livestreamId?: string;
-}
+};
interface LivestreamsListResponse extends ApiResponse {
meta?: {
@@ -213,14 +209,7 @@ export function YouTubeImportModal({
});
const payload = (await response.json().catch(() => null)) as
- | ApiResponse<{
- youtubeVideoId: string;
- title: string;
- durationSeconds: number;
- thumbnailUrl: string;
- previewStreamUrl: string;
- previewExpiresAt: number;
- }>
+ | ApiResponse
| { message?: string }
| null;
@@ -240,6 +229,7 @@ export function YouTubeImportModal({
setResolvedSource(resolved);
setTrimRange({ startSeconds: 0, endSeconds: resolved.durationSeconds });
+ setEnableSmartCut(true);
setStep('editor');
} catch (error) {
setErrorMessage(
@@ -286,7 +276,7 @@ export function YouTubeImportModal({
sourceUrl: resolvedSource.sourceUrl,
startSeconds: trimRange.startSeconds,
endSeconds: trimRange.endSeconds,
- smartCut: enableSmartCut,
+ smartCut: resolvedSource.previewMode === 'embed' ? true : enableSmartCut,
}),
});
@@ -438,6 +428,11 @@ export function YouTubeImportModal({
}, [jobStatus, onImportComplete]);
const canShowMoreLivestreams = livestreams.length < livestreamsTotal;
+ const previewMode = resolvedSource?.previewMode ?? 'direct';
+ const isEmbedPreview = previewMode === 'embed';
+ const smartCutTooltip = isEmbedPreview
+ ? 'Smart cut is required while YouTube is still processing this video. Without a scrubbable direct stream, trim points cannot be verified frame-by-frame — import snaps to the nearest keyframe instead.'
+ : 'Frame-accurate trim at import time. Disables keyframe snapping while you adjust the handles.';
return (
@@ -622,11 +617,12 @@ export function YouTubeImportModal({
setEnableSmartCut(event.target.checked)}
className="rounded border-border"
/>
@@ -637,18 +633,28 @@ export function YouTubeImportModal({
{showVideoPreview ? (
<>
- }
- />
+ {resolvedSource.previewMode === 'embed' ? (
+ }
+ />
+ ) : (
+ }
+ />
+ )}
- Drag the handles to choose where the imported clip starts and ends. The preview
- may look lower quality than the final video. Private videos must still be
- accessible to your connected YouTube account.
+ Drag the handles to choose where the imported clip starts and ends.
+ {resolvedSource.previewMode === 'embed'
+ ? ' Embed preview has reduced trim precision; smart cut refines cut points after download.'
+ : ' The preview may look lower quality than the final video.'}{' '}
+ Private videos must still be accessible to your connected YouTube account.
>
) : (
@@ -664,7 +670,8 @@ export function YouTubeImportModal({
value={trimRange}
onChange={setTrimRange}
playerHandle={showVideoPreview ? playerHandle : undefined}
- enableKeyframeSnap={showVideoPreview && !enableSmartCut}
+ enableKeyframeSnap={showVideoPreview && !enableSmartCut && previewMode === 'direct'}
+ showFrameNudge={previewMode === 'direct'}
/>
diff --git a/lib/youtube-import/probe-keyframes.ts b/lib/youtube-import/probe-keyframes.ts
index 8a819fcc..bafc578f 100644
--- a/lib/youtube-import/probe-keyframes.ts
+++ b/lib/youtube-import/probe-keyframes.ts
@@ -38,6 +38,18 @@ function getProcessTimeoutMs(): number {
/** Default expiry when yt-dlp does not expose a format expiration timestamp. */
const DEFAULT_DIRECT_MEDIA_URL_TTL_MS = 60 * 60 * 1000;
+/**
+ * Thrown when yt-dlp succeeds but returns no browser-streamable progressive MP4 format.
+ * Typical when YouTube has only DASH/HLS manifests (for example fresh `post_live` VODs).
+ */
+export class NoBrowserStreamableFormatError extends Error {
+ /** @param message - Human-readable failure reason. */
+ constructor(message: string) {
+ super(message);
+ this.name = 'NoBrowserStreamableFormatError';
+ }
+}
+
/** Default symmetric ffprobe read window for UI scrubbing and generic probes. */
export const DEFAULT_KEYFRAME_PROBE_WINDOW_SECONDS = 8;
@@ -324,14 +336,27 @@ export async function getDirectMediaUrl(youtubeVideoId: string): Promise ({
+ protocol: format.protocol ?? null,
+ ext: format.ext ?? null,
+ format_id: format.format_id ?? null,
+ }))
+ );
+ throw new NoBrowserStreamableFormatError(
+ 'yt-dlp returned formats but none are browser-streamable progressive MP4 (only DASH/HLS may be available while YouTube finishes VOD processing)'
);
}
+ const url = selected.url?.trim();
+ if (!url) {
+ throw new Error('yt-dlp selected format is missing a media URL');
+ }
+
const durationSeconds = metadata.duration;
if (
typeof durationSeconds !== 'number' ||
diff --git a/lib/youtube-import/resolve-source.ts b/lib/youtube-import/resolve-source.ts
index 7df5fec2..7581a330 100644
--- a/lib/youtube-import/resolve-source.ts
+++ b/lib/youtube-import/resolve-source.ts
@@ -38,14 +38,30 @@ export interface YouTubeImportResolvedMetadata {
}
/**
- * Full resolved import source including proxied preview stream details.
+ * Preview playback mode for the import trim editor.
+ * @property direct - Proxied yt-dlp progressive MP4 in an HTML5 video element.
+ * @property embed - YouTube IFrame Player when no direct stream is available.
*/
-export interface YouTubeImportResolvedSource extends YouTubeImportResolvedMetadata {
- /** Same-origin preview stream URL for the trim editor. */
- previewStreamUrl: string;
- /** Approximate Unix expiry for the proxied preview media URL. */
- previewExpiresAt: number;
-}
+export type YouTubeImportPreviewMode = 'direct' | 'embed';
+
+/**
+ * Full resolved import source including preview stream details or embed fallback.
+ */
+export type YouTubeImportResolvedSource = YouTubeImportResolvedMetadata &
+ (
+ | {
+ /** Proxied yt-dlp stream for HTML5 preview. */
+ previewMode: 'direct';
+ /** Same-origin preview stream URL for the trim editor. */
+ previewStreamUrl: string;
+ /** Approximate Unix expiry for the proxied preview media URL. */
+ previewExpiresAt: number;
+ }
+ | {
+ /** YouTube embed player when no browser-streamable direct format exists. */
+ previewMode: 'embed';
+ }
+ );
/**
* Parses a YouTube watch URL, short URL, `/live/` URL, or bare 11-character id.
From a775c8afd9375ea380fe1619348fe85584cd755a Mon Sep 17 00:00:00 2001
From: threehappypenguins
Date: Sun, 5 Jul 2026 20:31:42 -0300
Subject: [PATCH 2/3] fix(youtube-import): treat empty yt-dlp formats as hard
failures and correct embed smart-cut copy
---
.../youtube-import/probe-keyframes.test.ts | 23 +++++++++++++++++++
.../YouTubeEmbedPreviewPlayer.tsx | 4 ++--
.../youtube-import/YouTubeImportModal.tsx | 4 ++--
lib/youtube-import/probe-keyframes.ts | 4 ++++
4 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/__tests__/lib/youtube-import/probe-keyframes.test.ts b/__tests__/lib/youtube-import/probe-keyframes.test.ts
index 7f2f55d3..45f37763 100644
--- a/__tests__/lib/youtube-import/probe-keyframes.test.ts
+++ b/__tests__/lib/youtube-import/probe-keyframes.test.ts
@@ -539,4 +539,27 @@ describe('getDirectMediaUrl', () => {
warnSpy.mockRestore();
});
+
+ it('throws a generic Error when yt-dlp returns no formats', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ mockSpawnProcess.mockImplementationOnce(() =>
+ createMockChild({
+ stdout: JSON.stringify({
+ duration: 3600,
+ formats: [],
+ }),
+ })
+ );
+
+ const error = await getDirectMediaUrl('dQw4w9WgXcQ').catch((err: unknown) => err);
+ expect(error).toBeInstanceOf(Error);
+ expect(error).not.toBeInstanceOf(NoBrowserStreamableFormatError);
+ expect((error as Error).message).toBe(
+ 'yt-dlp metadata lookup did not return any video formats'
+ );
+ expect(warnSpy).not.toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ });
});
diff --git a/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx b/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
index 781e6e82..c377f36a 100644
--- a/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
+++ b/components/youtube-import/YouTubeEmbedPreviewPlayer.tsx
@@ -235,8 +235,8 @@ function YouTubeEmbedPreviewPlayerInner({
) : (
- Preview uses YouTube's embed player. Trim points are approximate until import; smart
- cut snaps to the nearest keyframe in the downloaded file.
+ Preview uses YouTube's embed player. Trim points are approximate in the preview;
+ smart cut produces frame-accurate cuts after download.
)}
diff --git a/components/youtube-import/YouTubeImportModal.tsx b/components/youtube-import/YouTubeImportModal.tsx
index 9bde3b73..0eb7b226 100644
--- a/components/youtube-import/YouTubeImportModal.tsx
+++ b/components/youtube-import/YouTubeImportModal.tsx
@@ -431,7 +431,7 @@ export function YouTubeImportModal({
const previewMode = resolvedSource?.previewMode ?? 'direct';
const isEmbedPreview = previewMode === 'embed';
const smartCutTooltip = isEmbedPreview
- ? 'Smart cut is required while YouTube is still processing this video. Without a scrubbable direct stream, trim points cannot be verified frame-by-frame — import snaps to the nearest keyframe instead.'
+ ? 'Smart cut is required while YouTube is still processing this video. Without a scrubbable direct stream, trim points cannot be verified frame-by-frame — import applies frame-accurate cuts after download.'
: 'Frame-accurate trim at import time. Disables keyframe snapping while you adjust the handles.';
return (
@@ -652,7 +652,7 @@ export function YouTubeImportModal({
Drag the handles to choose where the imported clip starts and ends.
{resolvedSource.previewMode === 'embed'
- ? ' Embed preview has reduced trim precision; smart cut refines cut points after download.'
+ ? ' Embed preview has reduced trim precision; smart cut produces frame-accurate cuts after download.'
: ' The preview may look lower quality than the final video.'}{' '}
Private videos must still be accessible to your connected YouTube account.
diff --git a/lib/youtube-import/probe-keyframes.ts b/lib/youtube-import/probe-keyframes.ts
index bafc578f..1c753ab0 100644
--- a/lib/youtube-import/probe-keyframes.ts
+++ b/lib/youtube-import/probe-keyframes.ts
@@ -337,6 +337,10 @@ export async function getDirectMediaUrl(youtubeVideoId: string): Promise
Date: Sun, 5 Jul 2026 20:33:31 -0300
Subject: [PATCH 3/3] chore: bumped version number
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 9187a92f..7f29e8f0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "videosphere",
- "version": "0.2.1",
+ "version": "0.2.2",
"private": true,
"engines": {
"node": ">=24.0.0",