Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/features/media-library/components/media-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/features/media-library/components/media-library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/features/media-library/contracts/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions src/features/media-library/services/media-library-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaMetadata & { isDuplicate?: boolean; hasUnsupportedCodec?: boolean }> {
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.
*
Expand Down
64 changes: 11 additions & 53 deletions src/features/media-library/stores/media-import-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand Down Expand Up @@ -73,7 +73,7 @@ type ImportUpdater =
function makeMedia(overrides: Partial<MediaMetadata> = {}): MediaMetadata {
return {
id: 'media-1',
storageType: 'handle',
storageType: 'opfs',
fileName: 'clip.mp4',
fileSize: 1024,
mimeType: 'video/mp4',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) => {
Expand All @@ -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])
Expand Down Expand Up @@ -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,
})
Expand All @@ -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([])
Expand All @@ -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,
})
Expand All @@ -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([])
Expand All @@ -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) => {
Expand All @@ -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())
})
})
Loading