fix(preview): allow native media playback
This commit is contained in:
@@ -54,6 +54,18 @@ const pdfEntry: VaultEntry = {
|
||||
filename: 'report.pdf',
|
||||
title: 'report.pdf',
|
||||
}
|
||||
const audioEntry: VaultEntry = {
|
||||
...imageEntry,
|
||||
path: '/vault/Attachments/meeting.mp3',
|
||||
filename: 'meeting.mp3',
|
||||
title: 'meeting.mp3',
|
||||
}
|
||||
const videoEntry: VaultEntry = {
|
||||
...imageEntry,
|
||||
path: '/vault/Attachments/demo.mp4',
|
||||
filename: 'demo.mp4',
|
||||
title: 'demo.mp4',
|
||||
}
|
||||
|
||||
describe('FilePreview', () => {
|
||||
beforeEach(() => {
|
||||
@@ -110,6 +122,22 @@ describe('FilePreview', () => {
|
||||
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
|
||||
})
|
||||
|
||||
it('renders supported audio files through the media asset path', () => {
|
||||
render(<FilePreview entry={audioEntry} />)
|
||||
|
||||
expect(screen.getByTestId('audio-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/meeting.mp3')
|
||||
expect(screen.getByText('MP3 file')).toBeInTheDocument()
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'audio' })
|
||||
})
|
||||
|
||||
it('renders supported video files through the media asset path', () => {
|
||||
render(<FilePreview entry={videoEntry} />)
|
||||
|
||||
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/demo.mp4')
|
||||
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('title', 'demo.mp4')
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'video' })
|
||||
})
|
||||
|
||||
it('provides a graceful fallback when a PDF preview cannot render', () => {
|
||||
render(<FilePreview entry={pdfEntry} />)
|
||||
|
||||
@@ -128,4 +156,16 @@ describe('FilePreview', () => {
|
||||
expect.objectContaining({ path: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks media preview failures without leaking the file path', () => {
|
||||
render(<FilePreview entry={audioEntry} />)
|
||||
|
||||
fireEvent.error(screen.getByTestId('audio-file-preview'))
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_failed', { preview_kind: 'audio' })
|
||||
expect(trackEventMock).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ path: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
|
||||
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics'
|
||||
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
|
||||
@@ -55,6 +55,14 @@ function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind |
|
||||
return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
if (previewKind === 'audio') {
|
||||
return <SpeakerHigh size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
if (previewKind === 'video') {
|
||||
return <Video size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
@@ -179,6 +187,61 @@ function FilePreviewImage({
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewMediaFrame({
|
||||
children,
|
||||
video = false,
|
||||
}: {
|
||||
children: ReactNode
|
||||
video?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex h-full items-center justify-center ${video ? 'min-h-[320px] bg-black p-4' : 'min-h-[260px] p-6'}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewMedia({
|
||||
entry,
|
||||
mediaKind,
|
||||
mediaSrc,
|
||||
onMediaError,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
mediaKind: 'audio' | 'video'
|
||||
mediaSrc: string
|
||||
onMediaError: () => void
|
||||
}) {
|
||||
if (mediaKind === 'audio') {
|
||||
return (
|
||||
<FilePreviewMediaFrame>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
className="w-full max-w-2xl"
|
||||
data-testid="audio-file-preview"
|
||||
onError={onMediaError}
|
||||
/>
|
||||
</FilePreviewMediaFrame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FilePreviewMediaFrame video>
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
title={entry.title}
|
||||
className="max-h-full max-w-full"
|
||||
data-testid="video-file-preview"
|
||||
onError={onMediaError}
|
||||
/>
|
||||
</FilePreviewMediaFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
|
||||
return isImage && imageSrc !== null && !imageFailed
|
||||
}
|
||||
@@ -189,6 +252,8 @@ function FilePreviewBody({
|
||||
assetSrc,
|
||||
imageFailed,
|
||||
onImageError,
|
||||
onAudioError,
|
||||
onVideoError,
|
||||
onOpenExternal,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
@@ -196,6 +261,8 @@ function FilePreviewBody({
|
||||
assetSrc: string | null
|
||||
imageFailed: boolean
|
||||
onImageError: () => void
|
||||
onAudioError: () => void
|
||||
onVideoError: () => void
|
||||
onOpenExternal: () => void
|
||||
}) {
|
||||
if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) {
|
||||
@@ -206,6 +273,14 @@ function FilePreviewBody({
|
||||
return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} />
|
||||
}
|
||||
|
||||
if (previewKind === 'audio' && assetSrc !== null) {
|
||||
return <FilePreviewMedia entry={entry} mediaKind="audio" mediaSrc={assetSrc} onMediaError={onAudioError} />
|
||||
}
|
||||
|
||||
if (previewKind === 'video' && assetSrc !== null) {
|
||||
return <FilePreviewMedia entry={entry} mediaKind="video" mediaSrc={assetSrc} onMediaError={onVideoError} />
|
||||
}
|
||||
|
||||
const fallback = fallbackContentForPreviewKind(previewKind)
|
||||
|
||||
return (
|
||||
@@ -218,48 +293,96 @@ function FilePreviewBody({
|
||||
)
|
||||
}
|
||||
|
||||
function useFilePreviewFailureState(entryPath: string) {
|
||||
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
|
||||
const [failedMediaPath, setFailedMediaPath] = useState<string | null>(null)
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setFailedImagePath(entryPath)
|
||||
trackFilePreviewFailed('image')
|
||||
}, [entryPath])
|
||||
const handleAudioError = useCallback(() => {
|
||||
setFailedMediaPath(entryPath)
|
||||
trackFilePreviewFailed('audio')
|
||||
}, [entryPath])
|
||||
const handleVideoError = useCallback(() => {
|
||||
setFailedMediaPath(entryPath)
|
||||
trackFilePreviewFailed('video')
|
||||
}, [entryPath])
|
||||
|
||||
return {
|
||||
imageFailed: failedImagePath === entryPath,
|
||||
mediaFailed: failedMediaPath === entryPath,
|
||||
handleImageError,
|
||||
handleAudioError,
|
||||
handleVideoError,
|
||||
}
|
||||
}
|
||||
|
||||
function useFilePreviewActions({
|
||||
entryPath,
|
||||
onCopyFilePath,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
previewKind,
|
||||
}: {
|
||||
entryPath: string
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
previewKind: FilePreviewKind | null
|
||||
}) {
|
||||
const handleOpenExternal = useCallback(() => {
|
||||
trackFilePreviewAction('open_external', previewKind)
|
||||
if (onOpenExternalFile) {
|
||||
onOpenExternalFile(entryPath)
|
||||
return
|
||||
}
|
||||
|
||||
void openLocalFile(entryPath).catch((error) => {
|
||||
console.warn('Failed to open file with default app:', error)
|
||||
})
|
||||
}, [entryPath, onOpenExternalFile, previewKind])
|
||||
|
||||
const handleRevealFile = useCallback(() => {
|
||||
trackFilePreviewAction('reveal', previewKind)
|
||||
onRevealFile?.(entryPath)
|
||||
}, [entryPath, onRevealFile, previewKind])
|
||||
|
||||
const handleCopyFilePath = useCallback(() => {
|
||||
trackFilePreviewAction('copy_path', previewKind)
|
||||
onCopyFilePath?.(entryPath)
|
||||
}, [entryPath, onCopyFilePath, previewKind])
|
||||
|
||||
return { handleOpenExternal, handleRevealFile, handleCopyFilePath }
|
||||
}
|
||||
|
||||
function previewKindForBody(previewKind: FilePreviewKind | null, mediaFailed: boolean): FilePreviewKind | null {
|
||||
return mediaFailed ? null : previewKind
|
||||
}
|
||||
|
||||
export function FilePreview({
|
||||
entry,
|
||||
onCopyFilePath,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
}: FilePreviewProps) {
|
||||
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
|
||||
const previewKind = filePreviewKind(entry)
|
||||
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
|
||||
const fileTypeLabel = previewFileTypeLabel(entry)
|
||||
const imageFailed = failedImagePath === entry.path
|
||||
const handleImageError = useCallback(() => {
|
||||
setFailedImagePath(entry.path)
|
||||
trackFilePreviewFailed('image')
|
||||
}, [entry.path])
|
||||
const failures = useFilePreviewFailureState(entry.path)
|
||||
const actions = useFilePreviewActions({
|
||||
entryPath: entry.path,
|
||||
onCopyFilePath,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
previewKind,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
trackFilePreviewOpened(previewKind)
|
||||
}, [entry.path, previewKind])
|
||||
|
||||
const handleOpenExternal = useCallback(() => {
|
||||
trackFilePreviewAction('open_external', previewKind)
|
||||
if (onOpenExternalFile) {
|
||||
onOpenExternalFile(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
void openLocalFile(entry.path).catch((error) => {
|
||||
console.warn('Failed to open file with default app:', error)
|
||||
})
|
||||
}, [entry.path, onOpenExternalFile, previewKind])
|
||||
|
||||
const handleRevealFile = useCallback(() => {
|
||||
trackFilePreviewAction('reveal', previewKind)
|
||||
onRevealFile?.(entry.path)
|
||||
}, [entry.path, onRevealFile, previewKind])
|
||||
|
||||
const handleCopyFilePath = useCallback(() => {
|
||||
trackFilePreviewAction('copy_path', previewKind)
|
||||
onCopyFilePath?.(entry.path)
|
||||
}, [entry.path, onCopyFilePath, previewKind])
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
@@ -279,18 +402,20 @@ export function FilePreview({
|
||||
entry={entry}
|
||||
previewKind={previewKind}
|
||||
fileTypeLabel={fileTypeLabel}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
onRevealFile={onRevealFile ? handleRevealFile : undefined}
|
||||
onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined}
|
||||
onOpenExternal={actions.handleOpenExternal}
|
||||
onRevealFile={onRevealFile ? actions.handleRevealFile : undefined}
|
||||
onCopyFilePath={onCopyFilePath ? actions.handleCopyFilePath : undefined}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<FilePreviewBody
|
||||
entry={entry}
|
||||
previewKind={previewKind}
|
||||
previewKind={previewKindForBody(previewKind, failures.mediaFailed)}
|
||||
assetSrc={assetSrc}
|
||||
imageFailed={imageFailed}
|
||||
onImageError={handleImageError}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
imageFailed={failures.imageFailed}
|
||||
onImageError={failures.handleImageError}
|
||||
onAudioError={failures.handleAudioError}
|
||||
onVideoError={failures.handleVideoError}
|
||||
onOpenExternal={actions.handleOpenExternal}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -78,6 +78,37 @@ describe('NoteItem', () => {
|
||||
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf')
|
||||
})
|
||||
|
||||
it('renders audio and video files as clickable media preview rows', () => {
|
||||
const audioEntry = makeEntry({
|
||||
path: '/vault/attachments/interview.mp3',
|
||||
filename: 'interview.mp3',
|
||||
title: 'interview.mp3',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const videoEntry = makeEntry({
|
||||
path: '/vault/attachments/demo.mp4',
|
||||
filename: 'demo.mp4',
|
||||
title: 'demo.mp4',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const onClickNote = vi.fn()
|
||||
|
||||
render(
|
||||
<>
|
||||
<NoteItem entry={audioEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
|
||||
<NoteItem entry={videoEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('audio-file-item')).toHaveAttribute('title', 'Open audio preview')
|
||||
expect(screen.getByTestId('video-file-item')).toHaveAttribute('title', 'Open video preview')
|
||||
|
||||
fireEvent.click(screen.getByTestId('audio-file-item'))
|
||||
fireEvent.click(screen.getByTestId('video-file-item'))
|
||||
expect(onClickNote).toHaveBeenCalledWith(audioEntry, expect.any(Object))
|
||||
expect(onClickNote).toHaveBeenCalledWith(videoEntry, expect.any(Object))
|
||||
})
|
||||
|
||||
it('renders text files as clickable rows', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
File, FileDashed, FilePdf, ImageSquare,
|
||||
File, FileDashed, FilePdf, ImageSquare, SpeakerHigh, Video,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
@@ -205,6 +205,8 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
|
||||
const previewKind = filePreviewKind(entry)
|
||||
if (previewKind === 'image') return ImageSquare
|
||||
if (previewKind === 'pdf') return FilePdf
|
||||
if (previewKind === 'audio') return SpeakerHigh
|
||||
if (previewKind === 'video') return Video
|
||||
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
|
||||
return getTypeIcon(entry.isA, customIcon)
|
||||
}
|
||||
@@ -380,6 +382,8 @@ function resolveNoteItemTitle({
|
||||
}) {
|
||||
if (previewKind === 'image') return 'Open image preview'
|
||||
if (previewKind === 'pdf') return 'Open PDF preview'
|
||||
if (previewKind === 'audio') return 'Open audio preview'
|
||||
if (previewKind === 'video') return 'Open video preview'
|
||||
return isUnavailableBinary ? 'Cannot open this file type' : undefined
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user