diff --git a/src/features/media-library/components/media-grid.tsx b/src/features/media-library/components/media-grid.tsx index 9e6ec3776..602f7df7a 100644 --- a/src/features/media-library/components/media-grid.tsx +++ b/src/features/media-library/components/media-grid.tsx @@ -24,7 +24,7 @@ import { } from '@/components/ui/alert-dialog' import { GRID_MIN_SIZE_PX, GRID_GAP_BY_SIZE } from './media-grid-constants' -import { showMediaFilePicker } from '@/features/media-library/utils/media-file-picker' +import { showFileHandlePicker } from '@/features/media-library/utils/media-file-picker' interface MediaGridProps { onMediaSelect?: (mediaId: string) => void @@ -158,7 +158,7 @@ export const MediaGrid = memo(function MediaGrid({ const handleRelink = useCallback( async (mediaId: string) => { try { - const handles = await showMediaFilePicker({ multiple: false }) + const handles = await showFileHandlePicker({ multiple: false }) const handle = handles[0] if (!handle) return diff --git a/src/features/media-library/components/media-library.tsx b/src/features/media-library/components/media-library.tsx index 83816379e..96f6722e7 100644 --- a/src/features/media-library/components/media-library.tsx +++ b/src/features/media-library/components/media-library.tsx @@ -534,11 +534,11 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL [importMediaFromUrl, importUrlValue, isImportUrlSubmitting], ) - // Import files from drag-drop handles - memoized to prevent MediaGrid re-renders + // Import files from drag-drop - memoized to prevent MediaGrid re-renders const handleImportHandles = useCallback( - async (handles: FileSystemFileHandle[]) => { + async (files: File[]) => { try { - await importHandles(handles) + await importHandles(files) } catch (error) { logger.error('Import failed:', error) } @@ -617,7 +617,7 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL }) } if (entries.length > 0) { - await handleImportHandles(entries.map((entry) => entry.handle)) + await handleImportHandles(entries.map((entry) => entry.file)) } }, [showNotification, handleImportHandles, t], diff --git a/src/features/media-library/components/missing-media-dialog.tsx b/src/features/media-library/components/missing-media-dialog.tsx index 0b53dbccd..43bc1b542 100644 --- a/src/features/media-library/components/missing-media-dialog.tsx +++ b/src/features/media-library/components/missing-media-dialog.tsx @@ -17,7 +17,7 @@ import { Link2Off, RefreshCw, FolderOpen, X, AlertTriangle, Search, Folder } fro import { useMediaLibraryStore } from '../stores/media-library-store' import { mediaLibraryService } from '../services/media-library-service' import { useProjectStore } from '@/features/media-library/deps/projects' -import { showMediaFilePicker } from '@/features/media-library/utils/media-file-picker' +import { showFileHandlePicker } from '@/features/media-library/utils/media-file-picker' import { getProjectBrokenMediaInfo } from '@/features/media-library/utils/broken-media' export function MissingMediaDialog() { @@ -148,7 +148,7 @@ export function MissingMediaDialog() { return } - const handles = await showMediaFilePicker({ multiple: false }) + const handles = await showFileHandlePicker({ multiple: false }) const handle = handles[0] if (!handle) return diff --git a/src/features/media-library/contracts/timeline.ts b/src/features/media-library/contracts/timeline.ts index d850f88d6..b2881ac08 100644 --- a/src/features/media-library/contracts/timeline.ts +++ b/src/features/media-library/contracts/timeline.ts @@ -29,7 +29,6 @@ export { } from '../utils/drag-data-cache' export { extractValidMediaFileEntriesFromDataTransfer, - supportsFileSystemDragDrop, } from '../utils/file-drop' export type { OrphanedClipInfo } from '../types' export type { ExtractedMediaFileEntry } from '../utils/file-drop' diff --git a/src/features/media-library/services/media-library-service.ts b/src/features/media-library/services/media-library-service.ts index bf7ce3adf..11de74580 100644 --- a/src/features/media-library/services/media-library-service.ts +++ b/src/features/media-library/services/media-library-service.ts @@ -852,6 +852,17 @@ class MediaLibraryService { return this.importMediaFileToOpfs(file, projectId) } + /** Import a File object directly into OPFS-backed storage (browser file picker / drag-drop). */ + async importMediaFile( + file: File, + projectId: string, + ): Promise { + if (!projectId) { + throw new Error('No project selected') + } + return this.importMediaFileToOpfs(file, projectId) + } + /** * Save a generated still image into a project-backed media library entry. * diff --git a/src/features/media-library/stores/media-import-actions.test.ts b/src/features/media-library/stores/media-import-actions.test.ts index 43bcbfa97..3684aab97 100644 --- a/src/features/media-library/stores/media-import-actions.test.ts +++ b/src/features/media-library/stores/media-import-actions.test.ts @@ -4,7 +4,7 @@ import type { MediaLibraryActions, MediaLibraryState } from '../types' import { createImportActions } from './media-import-actions' const mediaLibraryServiceMocks = vi.hoisted(() => ({ - importMediaWithHandle: vi.fn(), + importMediaFile: vi.fn(), importMediaFromUrl: vi.fn(), getMediaFile: vi.fn(), })) @@ -73,7 +73,7 @@ type ImportUpdater = function makeMedia(overrides: Partial = {}): MediaMetadata { return { id: 'media-1', - storageType: 'handle', + storageType: 'opfs', fileName: 'clip.mp4', fileSize: 1024, mimeType: 'video/mp4', @@ -104,13 +104,6 @@ function applyStateUpdate(state: ImportState, updater: ImportUpdater): ImportSta } } -function createHandle(file: File): FileSystemFileHandle { - return { - name: file.name, - getFile: vi.fn().mockResolvedValue(file), - } as unknown as FileSystemFileHandle -} - function createMockState(overrides: ImportState = {}): MediaLibraryState & MediaLibraryActions { return { currentProjectId: 'project-1', @@ -156,9 +149,8 @@ describe('createImportActions', () => { it('replaces optimistic placeholders with imported media', async () => { const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' }) - const handle = createHandle(file) const imported = makeMedia({ id: 'imported-1', fileName: 'clip.mp4' }) - mediaLibraryServiceMocks.importMediaWithHandle.mockResolvedValue(imported) + mediaLibraryServiceMocks.importMediaFile.mockResolvedValue(imported) let currentState = createMockState() const set = vi.fn((updater: ImportUpdater) => { @@ -168,7 +160,7 @@ describe('createImportActions', () => { const get = vi.fn(() => currentState) const actions = createImportActions(set, get) - const result = await actions.importHandles([handle]) + const result = await actions.importHandles([file]) expect(result).toEqual([imported]) expect(currentState.mediaItems).toEqual([imported]) @@ -229,9 +221,8 @@ describe('createImportActions', () => { it('removes duplicate placeholders and shows an info notification', async () => { const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' }) - const handle = createHandle(file) const duplicate = makeMedia({ id: 'existing-1', fileName: 'clip.mp4' }) - mediaLibraryServiceMocks.importMediaWithHandle.mockResolvedValue({ + mediaLibraryServiceMocks.importMediaFile.mockResolvedValue({ ...duplicate, isDuplicate: true, }) @@ -244,7 +235,7 @@ describe('createImportActions', () => { const get = vi.fn(() => currentState) const actions = createImportActions(set, get) - const result = await actions.importHandles([handle]) + const result = await actions.importHandles([file]) expect(result).toEqual([]) expect(currentState.mediaItems).toEqual([]) @@ -258,9 +249,8 @@ describe('createImportActions', () => { it('returns duplicates for placement flows while still removing placeholders', async () => { const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' }) - const handle = createHandle(file) const duplicate = makeMedia({ id: 'existing-1', fileName: 'clip.mp4' }) - mediaLibraryServiceMocks.importMediaWithHandle.mockResolvedValue({ + mediaLibraryServiceMocks.importMediaFile.mockResolvedValue({ ...duplicate, isDuplicate: true, }) @@ -273,7 +263,7 @@ describe('createImportActions', () => { const get = vi.fn(() => currentState) const actions = createImportActions(set, get) - const result = await actions.importHandlesForPlacement([handle]) + const result = await actions.importHandlesForPlacement([file]) expect(result).toEqual([{ ...duplicate, isDuplicate: true }]) expect(currentState.mediaItems).toEqual([]) @@ -282,8 +272,7 @@ describe('createImportActions', () => { it('cleans up failed placeholders and reports import errors', async () => { const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' }) - const handle = createHandle(file) - mediaLibraryServiceMocks.importMediaWithHandle.mockRejectedValue(new Error('Import failed')) + mediaLibraryServiceMocks.importMediaFile.mockRejectedValue(new Error('Import failed')) let currentState = createMockState() const set = vi.fn((updater: ImportUpdater) => { @@ -293,42 +282,11 @@ describe('createImportActions', () => { const get = vi.fn(() => currentState) const actions = createImportActions(set, get) - const result = await actions.importHandles([handle]) + const result = await actions.importHandles([file]) expect(result).toEqual([]) expect(currentState.mediaItems).toEqual([]) expect(currentState.importingIds).toEqual([]) - expect(loggerMocks.error).toHaveBeenCalledWith('Failed to import clip.mp4', expect.any(Error)) - }) - - it('sets a browser support error when the picker API is unavailable', async () => { - const originalWindow = globalThis.window - const originalNavigator = globalThis.navigator - const mockWindow = {} as Window & typeof globalThis - const mockNavigator = {} as Navigator - - vi.stubGlobal('window', mockWindow) - vi.stubGlobal('navigator', mockNavigator) - - try { - let currentState = createMockState() - const set = vi.fn((updater: ImportUpdater) => { - currentState = applyStateUpdate(currentState, updater) as MediaLibraryState & - MediaLibraryActions - }) - const get = vi.fn(() => currentState) - - const actions = createImportActions(set, get) - const result = await actions.importMedia() - - expect(result).toEqual([]) - expect(currentState.error).toBe( - 'File picker not supported in this browser. Use Chrome or Edge.', - ) - expect(currentState.errorLink).toBeNull() - } finally { - vi.stubGlobal('window', originalWindow) - vi.stubGlobal('navigator', originalNavigator) - } + expect(loggerMocks.error).toHaveBeenCalledWith('Failed to import clip.mp4', expect.anything()) }) }) diff --git a/src/features/media-library/stores/media-import-actions.ts b/src/features/media-library/stores/media-import-actions.ts index 4292cd4d2..f443bfd0b 100644 --- a/src/features/media-library/stores/media-import-actions.ts +++ b/src/features/media-library/stores/media-import-actions.ts @@ -4,7 +4,7 @@ import { mediaLibraryService } from '../services/media-library-service' import { proxyService } from '../services/proxy-service' import { getMimeType } from '../utils/validation' import { getSharedProxyKey } from '../utils/proxy-key' -import { hasMediaFilePickerSupport, showMediaFilePicker } from '../utils/media-file-picker' +import { showMediaFilePicker } from '../utils/media-file-picker' import { createLogger, createOperationId } from '@/shared/logging/logger' const logger = createLogger('MediaImport') @@ -19,7 +19,6 @@ type Get = () => MediaLibraryState & MediaLibraryActions type ImportedMetadata = MediaMetadata & { isDuplicate?: boolean; hasUnsupportedCodec?: boolean } interface ImportTask { - handle: FileSystemFileHandle tempId: string file: File } @@ -28,17 +27,12 @@ interface CompletedImportTask extends ImportTask { metadata: ImportedMetadata } -function buildOptimisticMediaItem( - handle: FileSystemFileHandle, - file: File, - tempId: string, -): MediaMetadata { +function buildOptimisticMediaItem(file: File, tempId: string): MediaMetadata { const now = Date.now() return { id: tempId, - storageType: 'handle', - fileHandle: handle, + storageType: 'opfs', fileName: file.name, fileSize: file.size, fileLastModified: file.lastModified, @@ -110,7 +104,7 @@ function processImportResults( } if (result.status === 'fulfilled') { - const { metadata, tempId, file, handle } = result.value + const { metadata, tempId, file } = result.value if (metadata.isDuplicate) { removeImportPlaceholder(set, tempId) @@ -129,14 +123,13 @@ function processImportResults( unsupportedCodecFiles.push({ fileName: file.name, audioCodec: metadata.audioCodec, - handle, }) } } } else { failedCount++ removeImportPlaceholder(set, importTask.tempId) - logger.error(`Failed to import ${importTask.file.name}`, result.reason) + logger.error(`Failed to import ${importTask.file.name}`, importResults[index]) } }) @@ -172,26 +165,13 @@ export function createImportActions( MediaLibraryActions, 'importMedia' | 'importMediaFromUrl' | 'importHandles' | 'importHandlesForPlacement' > { - const createOptimisticImportTasks = async ( - handles: FileSystemFileHandle[], - ): Promise => { + const createOptimisticImportTasks = (files: File[]): ImportTask[] => { const importTasks: ImportTask[] = [] - for (const handle of handles) { - if (!handle) continue + for (const file of files) { + if (!file) continue const tempId = crypto.randomUUID() - - let file: File - try { - file = await handle.getFile() - } catch (error) { - // getFile() can fail if permission is denied or file is missing — - // remove the placeholder that was about to be inserted and skip. - logger.error(`Failed to read file from handle "${handle.name}":`, error) - continue - } - - const tempItem = buildOptimisticMediaItem(handle, file, tempId) + const tempItem = buildOptimisticMediaItem(file, tempId) set((state) => ({ mediaItems: [tempItem, ...state.mediaItems], @@ -199,7 +179,7 @@ export function createImportActions( error: null, })) - importTasks.push({ handle, tempId, file }) + importTasks.push({ tempId, file }) } return importTasks @@ -210,14 +190,14 @@ export function createImportActions( projectId: string, ): Promise[]> => Promise.allSettled( - importTasks.map(async ({ handle, tempId, file }) => { - const metadata = await mediaLibraryService.importMediaWithHandle(handle, projectId) - return { metadata, tempId, file, handle } + importTasks.map(async ({ tempId, file }) => { + const metadata = await mediaLibraryService.importMediaFile(file, projectId) + return { metadata, tempId, file } }), ) - const importHandlesInternal = async ( - handles: FileSystemFileHandle[], + const importFilesInternal = async ( + files: File[], options?: { includeDuplicatesInResults?: boolean }, ): Promise => { const { currentProjectId } = get() @@ -232,10 +212,10 @@ export function createImportActions( event.merge({ source: 'drag-drop', projectId: currentProjectId, - fileCount: handles.length, + fileCount: files.length, }) - const importTasks = await createOptimisticImportTasks(handles) + const importTasks = createOptimisticImportTasks(files) const importResults = await runImportTasks(importTasks, currentProjectId) const { results, importedCount, duplicateNames, unsupportedCodecFiles, failedCount } = @@ -262,31 +242,22 @@ export function createImportActions( return [] } - // Check if File System Access API is supported - if (!hasMediaFilePickerSupport()) { - const isBrave = 'brave' in navigator - set({ - error: isBrave - ? 'File System Access API is disabled in Brave. Copy the URL below, paste it in your address bar, set the flag to Enabled, and relaunch.' - : 'File picker not supported in this browser. Use Chrome or Edge.', - errorLink: isBrave ? 'brave://flags/#file-system-access-api' : null, - }) - return [] - } - const opId = createOperationId() const event = logger.startEvent('import', opId) event.set('source', 'picker') event.set('projectId', currentProjectId) try { - // Open file picker - const handles = await showMediaFilePicker({ multiple: true }) + const files = await showMediaFilePicker({ multiple: true }) - event.set('fileCount', handles.length) + if (files.length === 0) { + event.success({ outcome: 'cancelled', imported: 0, duplicates: 0, failed: 0, unsupportedCodecs: 0 }) + return [] + } - // Create optimistic placeholders for all files immediately - const importTasks = await createOptimisticImportTasks(handles) + event.set('fileCount', files.length) + + const importTasks = createOptimisticImportTasks(files) const importResults = await runImportTasks(importTasks, currentProjectId) const { results, importedCount, duplicateNames, unsupportedCodecFiles, failedCount } = @@ -303,18 +274,11 @@ export function createImportActions( return results } catch (error) { - // User cancelled or error if (error instanceof Error && error.name !== 'AbortError') { - set({ error: error.message }) + set({ error: (error as Error).message }) event.failure(error) } else { - event.success({ - outcome: 'cancelled', - imported: 0, - duplicates: 0, - failed: 0, - unsupportedCodecs: 0, - }) + event.success({ outcome: 'cancelled', imported: 0, duplicates: 0, failed: 0, unsupportedCodecs: 0 }) } return [] } @@ -386,11 +350,11 @@ export function createImportActions( } }, - importHandles: async (handles: FileSystemFileHandle[]) => { - return importHandlesInternal(handles) + importHandles: async (files: File[]) => { + return importFilesInternal(files) }, - importHandlesForPlacement: async (handles: FileSystemFileHandle[]) => - importHandlesInternal(handles, { includeDuplicatesInResults: true }), + importHandlesForPlacement: async (files: File[]) => + importFilesInternal(files, { includeDuplicatesInResults: true }), } } diff --git a/src/features/media-library/types.ts b/src/features/media-library/types.ts index 319224aec..4b7f5e31b 100644 --- a/src/features/media-library/types.ts +++ b/src/features/media-library/types.ts @@ -115,26 +115,20 @@ export interface MediaLibraryActions { // CRUD Operations (project-scoped in v3) loadMediaItems: () => Promise - /** - * Import media using file picker (instant, no copy - local-first) - * Uses FileSystemFileHandle to reference files directly on user's disk - */ + /** Open the browser file picker and import selected files into OPFS. */ importMedia: () => Promise /** * Import media from a direct URL into OPFS-backed storage. * Best for CORS-enabled direct media files (mp4, mp3, png, etc.). */ importMediaFromUrl: (url: string) => Promise - /** - * Import media from file handles (for drag-drop) - * Uses FileSystemFileHandle directly without file picker - */ - importHandles: (handles: FileSystemFileHandle[]) => Promise + /** Import media from File objects (e.g. drag-drop) into OPFS. */ + importHandles: (files: File[]) => Promise /** * Import media for direct placement flows. * Existing media are returned too so drop targets can place duplicates without re-importing. */ - importHandlesForPlacement: (handles: FileSystemFileHandle[]) => Promise + importHandlesForPlacement: (files: File[]) => Promise deleteMedia: (id: string) => Promise deleteMediaBatch: (ids: string[]) => Promise /** diff --git a/src/features/media-library/utils/file-drop.ts b/src/features/media-library/utils/file-drop.ts index a949e25c3..b2f2a7929 100644 --- a/src/features/media-library/utils/file-drop.ts +++ b/src/features/media-library/utils/file-drop.ts @@ -1,7 +1,6 @@ import { getMediaType, getMimeType, validateMediaFile } from './validation' export interface ExtractedMediaFileEntry { - handle: FileSystemFileHandle file: File mediaType: 'video' | 'audio' | 'image' | 'unknown' } @@ -12,57 +11,25 @@ export interface ExtractedMediaFileDropResult { errors: string[] } -export function supportsFileSystemDragDrop(dataTransfer: DataTransfer): boolean { - const firstItem = dataTransfer.items[0] - return !!firstItem && 'getAsFileSystemHandle' in firstItem -} - export async function extractValidMediaFileEntriesFromDataTransfer( dataTransfer: DataTransfer, ): Promise { - if (!supportsFileSystemDragDrop(dataTransfer)) { - return { - supported: false, - entries: [], - errors: [], - } - } + const files = Array.from(dataTransfer.files) - const items = Array.from(dataTransfer.items) - const handlePromises: Promise[] = [] - for (const item of items) { - if ('getAsFileSystemHandle' in item) { - handlePromises.push(item.getAsFileSystemHandle()) - } - } - - const rawHandles = await Promise.all(handlePromises) const entries: ExtractedMediaFileEntry[] = [] const errors: string[] = [] - for (const handle of rawHandles) { - if (handle?.kind !== 'file') { + for (const file of files) { + const validation = validateMediaFile(file) + if (!validation.valid) { + errors.push(`${file.name}: ${validation.error}`) continue } - const fileHandle = handle as FileSystemFileHandle - try { - const file = await fileHandle.getFile() - const validation = validateMediaFile(file) - if (!validation.valid) { - errors.push(`${file.name}: ${validation.error}`) - continue - } - - entries.push({ - handle: fileHandle, - file, - mediaType: getMediaType(getMimeType(file)), - }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to read file' - errors.push(`Unknown file: ${message}`) - } + entries.push({ + file, + mediaType: getMediaType(getMimeType(file)), + }) } return { diff --git a/src/features/media-library/utils/media-file-picker.test.ts b/src/features/media-library/utils/media-file-picker.test.ts index 717976e3c..722b0514a 100644 --- a/src/features/media-library/utils/media-file-picker.test.ts +++ b/src/features/media-library/utils/media-file-picker.test.ts @@ -1,41 +1,55 @@ import { describe, expect, it, vi } from 'vite-plus/test' -import { - hasMediaFilePickerSupport, - MEDIA_FILE_PICKER_TYPES, - showMediaFilePicker, -} from './media-file-picker' +import { hasMediaFilePickerSupport, showMediaFilePicker } from './media-file-picker' describe('media-file-picker', () => { - it('detects file picker support from window.showOpenFilePicker', () => { - const originalWindow = globalThis.window - - vi.stubGlobal('window', {} as Window & typeof globalThis) - expect(hasMediaFilePickerSupport()).toBe(false) - - vi.stubGlobal('window', { - showOpenFilePicker: vi.fn(), - } as unknown as Window & typeof globalThis) + it('always reports support since input[type=file] is universal', () => { expect(hasMediaFilePickerSupport()).toBe(true) - - vi.stubGlobal('window', originalWindow) }) - it('passes the shared media picker types to the browser file picker', async () => { - const showOpenFilePicker = vi.fn().mockResolvedValue(['handle-1']) - const originalWindow = globalThis.window + it('resolves with selected files when the user picks files', async () => { + const file = new File(['data'], 'clip.mp4', { type: 'video/mp4' }) - vi.stubGlobal('window', { - showOpenFilePicker, - } as unknown as Window & typeof globalThis) + // Intercept createElement to return a fake input + const fakeInput = { + type: '', + multiple: false, + accept: '', + files: { 0: file, length: 1, [Symbol.iterator]: [file][Symbol.iterator].bind([file]) }, + addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === 'change') setTimeout(cb, 0) + }), + click: vi.fn(), + } + + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + if (tag === 'input') return fakeInput as unknown as HTMLElement + return origCreate(tag) + }) - const result = await showMediaFilePicker({ multiple: false }) + const result = await showMediaFilePicker({ multiple: true }) + expect(result).toHaveLength(1) + expect(result[0]?.name).toBe('clip.mp4') + }) - expect(result).toEqual(['handle-1']) - expect(showOpenFilePicker).toHaveBeenCalledWith({ + it('resolves with empty array when the user cancels', async () => { + const fakeInput = { + type: '', multiple: false, - types: MEDIA_FILE_PICKER_TYPES, + accept: '', + addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === 'cancel') setTimeout(cb, 0) + }), + click: vi.fn(), + } + + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + if (tag === 'input') return fakeInput as unknown as HTMLElement + return origCreate(tag) }) - vi.stubGlobal('window', originalWindow) + const result = await showMediaFilePicker() + expect(result).toEqual([]) }) }) diff --git a/src/features/media-library/utils/media-file-picker.ts b/src/features/media-library/utils/media-file-picker.ts index 15b8bdcad..b0c2de8d4 100644 --- a/src/features/media-library/utils/media-file-picker.ts +++ b/src/features/media-library/utils/media-file-picker.ts @@ -1,23 +1,47 @@ -export const MEDIA_FILE_PICKER_TYPES = [ - { - description: 'Media files', - accept: { - 'video/*': ['.mp4', '.webm', '.mov', '.avi', '.mkv'], - 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a', '.aac'], - 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], - }, - }, -] satisfies FilePickerAcceptType[] +const MEDIA_ACCEPT = + 'video/*,audio/*,image/*,.mp4,.webm,.mov,.avi,.mkv,.mp3,.wav,.ogg,.m4a,.aac,.jpg,.jpeg,.png,.gif,.webp,.svg' -export function hasMediaFilePickerSupport(): boolean { - return typeof window !== 'undefined' && 'showOpenFilePicker' in window +/** Open a browser file-input dialog and return the selected Files. */ +export async function showMediaFilePicker(options?: { multiple?: boolean }): Promise { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.multiple = options?.multiple ?? true + input.accept = MEDIA_ACCEPT + + let resolved = false + const done = (files: File[]) => { + if (resolved) return + resolved = true + resolve(files) + } + + input.addEventListener('change', () => done(Array.from(input.files ?? []))) + input.addEventListener('cancel', () => done([])) + + input.click() + }) } -export async function showMediaFilePicker(options?: { +/** @deprecated Only for legacy re-linking of handle-based media from old projects. */ +export async function showFileHandlePicker(options?: { multiple?: boolean }): Promise { return window.showOpenFilePicker({ - multiple: options?.multiple ?? true, - types: MEDIA_FILE_PICKER_TYPES, + multiple: options?.multiple ?? false, + types: [ + { + description: 'Media files', + accept: { + 'video/*': ['.mp4', '.webm', '.mov', '.avi', '.mkv'], + 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a', '.aac'], + 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], + }, + }, + ], }) } + +export function hasMediaFilePickerSupport(): boolean { + return true +} diff --git a/src/features/preview/deps/media-library-contract.ts b/src/features/preview/deps/media-library-contract.ts index 6f13dda05..97bb7f31c 100644 --- a/src/features/preview/deps/media-library-contract.ts +++ b/src/features/preview/deps/media-library-contract.ts @@ -20,7 +20,6 @@ export { } from '@/features/media-library/services/media-library-service' export { extractValidMediaFileEntriesFromDataTransfer, - supportsFileSystemDragDrop, } from '@/features/media-library/utils/file-drop' export { getMediaDragData, diff --git a/src/features/preview/hooks/use-canvas-media-drop.ts b/src/features/preview/hooks/use-canvas-media-drop.ts index bce8f7134..cf5006a6c 100644 --- a/src/features/preview/hooks/use-canvas-media-drop.ts +++ b/src/features/preview/hooks/use-canvas-media-drop.ts @@ -409,7 +409,7 @@ export function useCanvasMediaDrop({ coordParams, projectSize }: UseCanvasMediaD const importedMedia = await useMediaLibraryStore .getState() - .importHandlesForPlacement([entry.handle]) + .importHandlesForPlacement([entry.file]) const imported = importedMedia[0] if (!imported) { toast.error(i18n.t('preview.canvasDrop.unableToImportFile')) diff --git a/src/features/timeline/deps/media-library-resolver.ts b/src/features/timeline/deps/media-library-resolver.ts index 41b1ea166..9bc4f6aed 100644 --- a/src/features/timeline/deps/media-library-resolver.ts +++ b/src/features/timeline/deps/media-library-resolver.ts @@ -6,7 +6,6 @@ export { mediaProcessorService, getMediaDragData, extractValidMediaFileEntriesFromDataTransfer, - supportsFileSystemDragDrop, setMediaDragData, clearMediaDragData, type CompositionDragData, diff --git a/src/features/timeline/utils/drop-execution.ts b/src/features/timeline/utils/drop-execution.ts index ca7b0ee8a..df2df0050 100644 --- a/src/features/timeline/utils/drop-execution.ts +++ b/src/features/timeline/utils/drop-execution.ts @@ -27,7 +27,7 @@ interface DropNotifications { interface ResolveDroppedMediaEntriesFromExternalFilesOptions { dataTransfer: DataTransfer - importHandlesForPlacement: (handles: FileSystemFileHandle[]) => Promise + importHandlesForPlacement: (files: File[]) => Promise notify: DropNotifications } @@ -171,7 +171,7 @@ export async function resolveDroppedMediaEntriesFromExternalFiles({ let importedMedia: Awaited> try { - importedMedia = await importHandlesForPlacement(entries.map((entry) => entry.handle)) + importedMedia = await importHandlesForPlacement(entries.map((entry) => entry.file)) } catch (error) { notify.error('Unable to import dropped files.', { description: error instanceof Error ? error.message : 'Please try again.', diff --git a/src/features/timeline/utils/external-file-project-match.test.ts b/src/features/timeline/utils/external-file-project-match.test.ts index e855322df..141baa34b 100644 --- a/src/features/timeline/utils/external-file-project-match.test.ts +++ b/src/features/timeline/utils/external-file-project-match.test.ts @@ -58,7 +58,6 @@ function makeVideoMedia(overrides: Partial = {}): MediaMetadata { function makeEntry(overrides: Partial = {}): ExtractedMediaFileEntry { return { - handle: {} as FileSystemFileHandle, file: new File(['video'], 'drop.mp4', { type: 'video/mp4' }), mediaType: 'video', ...overrides, diff --git a/src/features/workspace-gate/workspace-gate.tsx b/src/features/workspace-gate/workspace-gate.tsx index 677d12c86..705d53d78 100644 --- a/src/features/workspace-gate/workspace-gate.tsx +++ b/src/features/workspace-gate/workspace-gate.tsx @@ -1,182 +1,51 @@ /** * WorkspaceGate * - * Wraps the router and, when the current URL is a storage-dependent route - * (`/projects*` or `/editor*`), blocks it until the user has picked a - * workspace folder and granted read/write permission: - * - * 1. Check handles-db for a saved workspace handle - * 2. If missing → show splash prompting user to pick a folder - * 3. If present → queryPermission; if granted, set the active root and - * render the children. If revoked, show a Reconnect splash. - * - * The landing page (`/`) is not a storage-dependent route — it renders - * without waiting for the gate, so users see no splash flash on first - * visit. Navigating into a protected route after the handle is initialized - * falls through to the "ready" path without additional UI. - * - * Also listens for permission-lost signals from fs-primitives and flips - * back to the Reconnect state mid-session. + * Initializes the OPFS-backed workspace automatically on mount — no folder + * picker, no permission prompt, no splash screen. Blocks storage-dependent + * routes (`/projects*`, `/editor*`) until OPFS is ready, then renders the + * children immediately. */ -import { useCallback, useEffect, useState } from 'react' -import { - ensureKnownWorkspaceForCurrent, - getWorkspaceHandleRecord, - isFileSystemAccessSupported, - queryHandlePermission, - requestHandlePermission, - saveWorkspaceHandleRecord, -} from '@/infrastructure/storage/handles-db' -import { onPermissionLost, setWorkspaceRoot } from '@/infrastructure/storage/workspace-fs/root' +import { useEffect, useState } from 'react' +import { setWorkspaceRoot } from '@/infrastructure/storage/workspace-fs/root' import { bootstrapWorkspace } from '@/infrastructure/storage/workspace-fs/bootstrap' import { createLogger } from '@/shared/logging/logger' -import { WorkspaceGateSplash } from './workspace-gate-splash' import { usePathname } from './use-pathname' import { autoPurgeExpiredTrash } from './deps/trash-auto-purge' -/** - * Routes that read/write the workspace and therefore need the gate to be - * ready before their loaders run. Anything else renders freely without - * waiting on storage initialization. - */ function isStorageProtectedPath(pathname: string): boolean { return pathname.startsWith('/projects') || pathname.startsWith('/editor') } const logger = createLogger('WorkspaceGate') -type GateStatus = - | { kind: 'initializing' } - | { kind: 'unavailable' } // Non-Chromium browsers - | { kind: 'pick' } // No saved handle - | { kind: 'reconnect'; handleName: string } // Saved handle, permission revoked - | { kind: 'ready' } - export function WorkspaceGate({ children }: { children: React.ReactNode }) { - const [status, setStatus] = useState({ kind: 'initializing' }) + const [ready, setReady] = useState(false) const pathname = usePathname() const needsWorkspace = isStorageProtectedPath(pathname) - const activate = useCallback(async (handle: FileSystemDirectoryHandle) => { - setWorkspaceRoot(handle) - try { - await bootstrapWorkspace(handle) - } catch (error) { - logger.warn('bootstrapWorkspace failed', error) - } - // Fire the auto-purge sweep for long-trashed projects in the - // background — it touches disk and we don't want it to block the - // app render. Wrapped in setTimeout so it runs after first paint. - setTimeout(() => { - void autoPurgeExpiredTrash() - }, 0) - setStatus({ kind: 'ready' }) - }, []) - - // Initial load: check if we have a saved handle, check its permission. useEffect(() => { - let cancelled = false ;(async () => { - if (!isFileSystemAccessSupported()) { - if (!cancelled) setStatus({ kind: 'unavailable' }) - return - } - // Promote any legacy `workspace:current` into a proper known-workspace - // record before we read it, so the indicator's "known workspaces" list - // includes the one the user is about to use. - await ensureKnownWorkspaceForCurrent() - const record = await getWorkspaceHandleRecord() - if (!record) { - if (!cancelled) setStatus({ kind: 'pick' }) - return - } - const handle = record.handle as FileSystemDirectoryHandle - const permission = await queryHandlePermission(handle) - if (cancelled) return - if (permission === 'granted') { - await activate(handle) - } else { - setStatus({ kind: 'reconnect', handleName: record.name }) + try { + const handle = await navigator.storage.getDirectory() + setWorkspaceRoot(handle) + try { + await bootstrapWorkspace(handle) + } catch (error) { + logger.warn('bootstrapWorkspace failed', error) + } + setTimeout(() => { + void autoPurgeExpiredTrash() + }, 0) + } catch (error) { + logger.error('OPFS initialization failed', error) } - })().catch((error) => { - logger.error('Gate initialization failed', error) - if (!cancelled) setStatus({ kind: 'pick' }) - }) - return () => { - cancelled = true - } - }, [activate]) - - // Permission-lost mid-session → flip to reconnect. - useEffect(() => { - const unsubscribe = onPermissionLost(() => { - void (async () => { - const record = await getWorkspaceHandleRecord() - setStatus({ kind: 'reconnect', handleName: record?.name ?? 'workspace' }) - })() - }) - return () => { - unsubscribe() - } + setReady(true) + })() }, []) - const handlePick = useCallback(async () => { - try { - const handle = await window.showDirectoryPicker({ - id: 'freecut-workspace', - mode: 'readwrite', - startIn: 'documents', - }) - const queryState = await queryHandlePermission(handle) - const finalState = - queryState === 'granted' ? queryState : await requestHandlePermission(handle) - if (finalState !== 'granted') { - setStatus({ kind: 'reconnect', handleName: handle.name }) - return - } - await saveWorkspaceHandleRecord(handle) - await activate(handle) - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - // User cancelled the picker; stay on splash. - return - } - logger.error('Folder pick failed', error) - } - }, [activate]) - - const handleReconnect = useCallback(async () => { - const record = await getWorkspaceHandleRecord() - if (!record) { - setStatus({ kind: 'pick' }) - return - } - const handle = record.handle as FileSystemDirectoryHandle - const permission = await requestHandlePermission(handle) - if (permission === 'granted') { - await activate(handle) - } - }, [activate]) - - // Routes that don't touch storage never wait on the gate — no splash, no - // flash, even on first load while we're checking handles-db. - if (!needsWorkspace) { - return <>{children} - } - - if (status.kind === 'ready') { - return <>{children} - } - - // On protected routes during initialization, render a bare background - // block so the transition from "checking" to "ready" or "splash" is - // invisible instead of a logo+spinner flash. - if (status.kind === 'initializing') { - return