fix(preview): allow native media playback

This commit is contained in:
lucaronin
2026-05-05 01:08:33 +02:00
parent f5adbf9cf7
commit d347cda9e3
9 changed files with 256 additions and 44 deletions

View File

@@ -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) }),
)
})
})

View File

@@ -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>

View File

@@ -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',

View File

@@ -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
}