Skip to content

Commit 25e8f26

Browse files
committed
fix: render audio object previews natively
1 parent 37cfdc0 commit 25e8f26

3 files changed

Lines changed: 103 additions & 20 deletions

File tree

components/object/preview-modal.tsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RiFullscreenExitLine, RiFullscreenLine } from "@remixicon/react"
1010
import { PdfViewer } from "@/components/object/pdf-viewer"
1111
import { ParquetViewer } from "@/components/object/parquet-viewer"
1212
import { TiffViewer } from "@/components/object/tiff-viewer"
13+
import { getObjectPreviewMode, normalizePreviewContentType } from "@/lib/object-preview"
1314
import Image from "next/image"
1415

1516
const SAFE_TEXT_MIMES = [
@@ -27,8 +28,6 @@ const SAFE_TEXT_EXTENSIONS = [".txt", ".json", ".jsonl", ".ndjson", ".xml", ".cs
2728
const SAFE_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", ".tiff"]
2829
const ALLOWED_SIZE = 1024 * 1024 * 2 // 2MB
2930

30-
type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download" | "tiff"
31-
3231
const PARQUET_MIMES = ["application/vnd.apache.parquet", "application/x-parquet", "application/parquet"]
3332
const PARQUET_EXTENSIONS = [".parquet", ".pq"]
3433

@@ -52,10 +51,6 @@ type FullscreenElement = HTMLElement & {
5251
webkitRequestFullscreen?: () => Promise<void> | void
5352
}
5453

55-
function normalizeContentType(contentType: string) {
56-
return contentType.split(";")[0]?.trim().toLowerCase() ?? ""
57-
}
58-
5954
function isSafeTextPreview(contentType: string, objectKey: string, objectSize: number) {
6055
if (objectSize > ALLOWED_SIZE) return false
6156
if (SAFE_TEXT_MIMES.includes(contentType)) return true
@@ -71,12 +66,6 @@ function isImagePreview(contentType: string, objectKey: string) {
7166
return SAFE_IMAGE_EXTENSIONS.some((ext) => keyLower.endsWith(ext))
7267
}
7368

74-
function getPreviewMode(hasPreviewUrl: boolean, canRenderText: boolean, canRenderImage: boolean): PreviewMode {
75-
if (!hasPreviewUrl) return "download"
76-
if (canRenderImage) return "image"
77-
return canRenderText ? "text" : "sandbox"
78-
}
79-
8069
function isPdfPreview(contentType: string) {
8170
return contentType === "application/pdf"
8271
}
@@ -124,6 +113,7 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
124113
const { t } = useTranslation()
125114
const [textContent, setTextContent] = React.useState("")
126115
const [loading, setLoading] = React.useState(false)
116+
const [audioLoadError, setAudioLoadError] = React.useState(false)
127117
const [isFormatted, setIsFormatted] = React.useState(true)
128118
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null)
129119
const [imageFitScale, setImageFitScale] = React.useState(1)
@@ -139,21 +129,23 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
139129
const objectSize = Number(object?.ContentLength ?? 0)
140130
const objectKey = object?.Key ?? ""
141131
const objectKeyLower = objectKey.toLowerCase()
142-
const normalizedContentType = normalizeContentType(contentType)
132+
const normalizedContentType = normalizePreviewContentType(contentType)
143133

144134
const isJson = normalizedContentType === "application/json" || objectKeyLower.endsWith(".json")
145135
const canRenderText = hasPreviewUrl && isSafeTextPreview(normalizedContentType, objectKey, objectSize)
146136
const canRenderImage = hasPreviewUrl && isImagePreview(normalizedContentType, objectKey)
147137
const canRenderPdf = hasPreviewUrl && isPdfPreview(normalizedContentType)
148138
const canRenderParquet = hasPreviewUrl && isParquetPreview(normalizedContentType, objectKey)
149139
const canRenderTiff = hasPreviewUrl && isTiffPreview(objectKey)
150-
const previewMode: PreviewMode = canRenderParquet
151-
? "parquet"
152-
: canRenderPdf
153-
? "pdf"
154-
: canRenderTiff
155-
? "tiff"
156-
: getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage)
140+
const previewMode = getObjectPreviewMode({
141+
hasPreviewUrl,
142+
contentType,
143+
canRenderText,
144+
canRenderImage,
145+
canRenderPdf,
146+
canRenderParquet,
147+
canRenderTiff,
148+
})
157149
const isImageMode = previewMode === "image"
158150
const isSelfScrollMode = isImageMode || previewMode === "parquet" || previewMode === "tiff"
159151

@@ -193,6 +185,10 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
193185
}
194186
}, [show, previewMode, previewUrl, t])
195187

188+
React.useEffect(() => {
189+
setAudioLoadError(false)
190+
}, [show, previewUrl])
191+
196192
React.useEffect(() => {
197193
const cachedSize = previewUrl ? imageSizeCacheRef.current[previewUrl] : undefined
198194
setImageNaturalSize(cachedSize ?? null)
@@ -375,6 +371,20 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
375371
</div>
376372
</div>
377373
)
374+
case "audio":
375+
return audioLoadError ? (
376+
<div className="my-auto text-center text-sm text-destructive" role="alert">
377+
{t("Preview unavailable")}
378+
</div>
379+
) : (
380+
<audio
381+
controls
382+
src={previewUrl}
383+
className="my-auto w-full"
384+
aria-label={objectKey || t("Preview")}
385+
onError={() => setAudioLoadError(true)}
386+
/>
387+
)
378388
case "sandbox":
379389
return (
380390
<iframe src={previewUrl} className="h-[70vh] w-full" frameBorder={0} title="Sandbox preview" sandbox="" />

lib/object-preview.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
type ObjectPreviewMode = "text" | "image" | "audio" | "pdf" | "parquet" | "sandbox" | "download" | "tiff"
2+
3+
interface ObjectPreviewOptions {
4+
hasPreviewUrl: boolean
5+
contentType: string
6+
canRenderText: boolean
7+
canRenderImage: boolean
8+
canRenderPdf: boolean
9+
canRenderParquet: boolean
10+
canRenderTiff: boolean
11+
}
12+
13+
export function normalizePreviewContentType(contentType: string) {
14+
return contentType.split(";")[0]?.trim().toLowerCase() ?? ""
15+
}
16+
17+
export function getObjectPreviewMode({
18+
hasPreviewUrl,
19+
contentType,
20+
canRenderText,
21+
canRenderImage,
22+
canRenderPdf,
23+
canRenderParquet,
24+
canRenderTiff,
25+
}: ObjectPreviewOptions): ObjectPreviewMode {
26+
if (!hasPreviewUrl) return "download"
27+
if (normalizePreviewContentType(contentType).startsWith("audio/")) return "audio"
28+
if (canRenderParquet) return "parquet"
29+
if (canRenderPdf) return "pdf"
30+
if (canRenderTiff) return "tiff"
31+
if (canRenderImage) return "image"
32+
return canRenderText ? "text" : "sandbox"
33+
}

tests/lib/object-preview-source.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
import test from "node:test"
22
import assert from "node:assert/strict"
33
import fs from "node:fs"
4+
import { getObjectPreviewMode } from "../../lib/object-preview.ts"
5+
6+
const previewOptions = {
7+
hasPreviewUrl: true,
8+
contentType: "application/octet-stream",
9+
canRenderText: false,
10+
canRenderImage: false,
11+
canRenderPdf: false,
12+
canRenderParquet: false,
13+
canRenderTiff: false,
14+
}
15+
16+
test("object preview dispatches normalized audio MIME types to the native audio mode", () => {
17+
assert.equal(getObjectPreviewMode({ ...previewOptions, contentType: "audio/wav" }), "audio")
18+
assert.equal(getObjectPreviewMode({ ...previewOptions, contentType: "audio/mpeg" }), "audio")
19+
assert.equal(getObjectPreviewMode({ ...previewOptions, contentType: " Audio/WAV; codecs=1 " }), "audio")
20+
assert.equal(
21+
getObjectPreviewMode({ ...previewOptions, contentType: "audio/wav", canRenderParquet: true, canRenderTiff: true }),
22+
"audio",
23+
)
24+
})
25+
26+
test("object preview preserves non-audio dispatch behavior", () => {
27+
assert.equal(getObjectPreviewMode(previewOptions), "sandbox")
28+
assert.equal(getObjectPreviewMode({ ...previewOptions, canRenderPdf: true }), "pdf")
29+
assert.equal(getObjectPreviewMode({ ...previewOptions, canRenderImage: true }), "image")
30+
assert.equal(getObjectPreviewMode({ ...previewOptions, canRenderText: true }), "text")
31+
})
32+
33+
test("object preview renders native audio controls without relaxing the fallback sandbox", () => {
34+
const source = fs.readFileSync("components/object/preview-modal.tsx", "utf8")
35+
36+
assert.match(source, /const previewMode = getObjectPreviewMode\(\{/)
37+
assert.match(source, /switch \(previewMode\)/)
38+
assert.match(source, /case "audio":[\s\S]*<audio[\s\S]*controls[\s\S]*src=\{previewUrl\}/)
39+
assert.match(source, /onError=\{\(\) => setAudioLoadError\(true\)\}/)
40+
assert.match(source, /role="alert"[\s\S]*\{t\("Preview unavailable"\)\}/)
41+
assert.match(source, /<iframe[^>]*sandbox=""/)
42+
assert.doesNotMatch(source, /allow-same-origin/)
43+
})
444

545
test("object preview modal falls back when standard fullscreen APIs are unavailable", () => {
646
const source = fs.readFileSync("components/object/preview-modal.tsx", "utf8")

0 commit comments

Comments
 (0)