feat(attachments): add inline audio playback
Use native media decoding with range-aware resource responses and graceful fallback for unsupported formats.\n\nRefs #334
This commit is contained in:
1 parent
ce674b8caf
commit
8f2c7911f1
11 files changed
+215
-31
No files matched your search
@@ -270,6 +270,34 @@ describe("resource route contracts", () => {
|
||||
expect(await response.text()).toBe("2345");
|
||||
});
|
||||
|
||||
test("serves filename-detected audio inline with a playable MIME type", async () => {
|
||||
const environment = createEnvironment();
|
||||
environment.storage.resources = {
|
||||
get: async () => ({
|
||||
body: new Blob([new Uint8Array(256)]).stream(),
|
||||
size: 256,
|
||||
writeHttpMetadata: () => {},
|
||||
}),
|
||||
};
|
||||
const response = await createApp(agentAuth, async () => ({
|
||||
...resourceRow,
|
||||
filename: "访谈.flac",
|
||||
mime_type: "application/octet-stream",
|
||||
storage_config_id: null,
|
||||
})).request(
|
||||
"/api/v1/resources/res_1/blob",
|
||||
{},
|
||||
environment,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("audio/flac");
|
||||
expect(response.headers.get("Content-Disposition")).toBe(
|
||||
"inline; filename=\"download.flac\"; filename*=UTF-8''%E8%AE%BF%E8%B0%88.flac",
|
||||
);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
});
|
||||
|
||||
test("rejects unsatisfiable ranges before object storage is read", async () => {
|
||||
const environment = createEnvironment();
|
||||
let reads = 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isPdfAttachment, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
|
||||
import { isPdfAttachment, resolveAudioMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Hono } from "hono";
|
||||
import { auditStatement } from "./audit";
|
||||
@@ -294,11 +294,12 @@ export const registerResourceRoutes = (
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
const audioMimeType = resolveAudioMimeType(resource.mime_type, resource.filename);
|
||||
headers.set(
|
||||
"Content-Type",
|
||||
isPdfAttachment(resource.mime_type, resource.filename)
|
||||
? "application/pdf"
|
||||
: resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
: audioMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
);
|
||||
headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600");
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
@@ -314,7 +315,7 @@ export const registerResourceRoutes = (
|
||||
}
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
resource.kind === "image" || isPdfAttachment(resource.mime_type, resource.filename)
|
||||
resource.kind === "image" || isPdfAttachment(resource.mime_type, resource.filename) || audioMimeType
|
||||
? contentDispositionInline(resource.filename)
|
||||
: contentDispositionAttachment(resource.filename),
|
||||
);
|
||||
|
||||
@@ -140,4 +140,35 @@ describe("public memo shares", () => {
|
||||
expect(await response.text()).toBe("2345");
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
test("serves filename-detected shared audio inline", async () => {
|
||||
const { sqlite, environment } = createDatabaseEnvironment();
|
||||
sqlite.query(
|
||||
`INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size)
|
||||
VALUES (?, ?, ?, 'attachment', 'application/octet-stream', 'recording.mp3', 10)`,
|
||||
).run("res_audio", "memo_source", "audio-key");
|
||||
environment.storage.resources = {
|
||||
get: async () => ({
|
||||
body: new Blob([new Uint8Array(10)]).stream(),
|
||||
size: 10,
|
||||
writeHttpMetadata: () => {},
|
||||
}),
|
||||
};
|
||||
const app = new Hono();
|
||||
registerPublicShareRoutes(app);
|
||||
|
||||
const response = await app.request(
|
||||
`/api/public/shares/${sourceToken}/resources/res_audio/blob`,
|
||||
{},
|
||||
environment,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
|
||||
expect(response.headers.get("Content-Disposition")).toBe(
|
||||
"inline; filename=\"recording.mp3\"; filename*=UTF-8''recording.mp3",
|
||||
);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { collectMemoLinkIds, isPdfAttachment, resolveMemoContentDoc, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
|
||||
import { collectMemoLinkIds, isPdfAttachment, resolveAudioMimeType, resolveMemoContentDoc, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
|
||||
import type { Hono } from "hono";
|
||||
import type { AppEnv } from "./api-context";
|
||||
import { audit } from "./audit";
|
||||
@@ -40,7 +40,7 @@ const mapMemoShare = (row: MemoShareRow): MemoShare => ({
|
||||
});
|
||||
|
||||
const contentDisposition = (kind: SharedResourceRow["kind"], mimeType: string | null, filename: string | null) => {
|
||||
const inline = kind === "image" || isPdfAttachment(mimeType, filename);
|
||||
const inline = kind === "image" || isPdfAttachment(mimeType, filename) || Boolean(resolveAudioMimeType(mimeType, filename));
|
||||
return inline ? contentDispositionInline(filename) : contentDispositionAttachment(filename);
|
||||
};
|
||||
|
||||
@@ -125,11 +125,12 @@ export const registerPublicShareRoutes = (app: Hono<AppEnv>) => {
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
const audioMimeType = resolveAudioMimeType(resource.mime_type, resource.filename);
|
||||
headers.set(
|
||||
"Content-Type",
|
||||
isPdfAttachment(resource.mime_type, resource.filename)
|
||||
? "application/pdf"
|
||||
: resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
: audioMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
if (byteRange.kind === "range") {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AudioAttachmentPlayer } from "./AudioAttachmentPlayer.tsx";
|
||||
|
||||
describe("AudioAttachmentPlayer", () => {
|
||||
test("uses the native streaming player without eagerly downloading the file", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AudioAttachmentPlayer, {
|
||||
src: "/api/v1/resources/audio-1/blob",
|
||||
label: "interview.flac",
|
||||
unavailableMessage: "Unavailable",
|
||||
}));
|
||||
|
||||
expect(markup).toContain("<audio");
|
||||
expect(markup).toContain('preload="metadata"');
|
||||
expect(markup).toContain('src="/api/v1/resources/audio-1/blob"');
|
||||
expect(markup).toContain('aria-label="interview.flac"');
|
||||
expect(markup).toContain("data-edgeever-audio-player");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, type SyntheticEvent } from "react";
|
||||
|
||||
export const AudioAttachmentPlayer = ({
|
||||
src,
|
||||
label,
|
||||
unavailableMessage,
|
||||
}: {
|
||||
src: string;
|
||||
label: string;
|
||||
unavailableMessage: string;
|
||||
}) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const handlePlay = (event: SyntheticEvent<HTMLAudioElement>) => {
|
||||
const activePlayer = event.currentTarget;
|
||||
activePlayer.ownerDocument
|
||||
.querySelectorAll<HTMLAudioElement>("audio[data-edgeever-audio-player]")
|
||||
.forEach((player) => {
|
||||
if (player !== activePlayer) player.pause();
|
||||
});
|
||||
};
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex min-h-10 items-center px-3 text-sm text-slate-500" role="status">
|
||||
{unavailableMessage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<audio
|
||||
className="h-10 w-full"
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
data-edgeever-audio-player
|
||||
preload="metadata"
|
||||
src={src}
|
||||
aria-label={label}
|
||||
onError={() => setFailed(true)}
|
||||
onPlay={handlePlay}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
FileAttachment as BaseFileAttachment,
|
||||
formatAttachmentMetadata,
|
||||
getAttachmentFilenameFromLabel,
|
||||
resolveAttachmentKind,
|
||||
} from "@edgeever/shared";
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react";
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AttachmentFileIcon } from "@/components/attachments/AttachmentFileIcon";
|
||||
import { AudioAttachmentPlayer } from "@/components/attachments/AudioAttachmentPlayer";
|
||||
import { COMPACT_ATTACHMENT_WIDTH_CLASS } from "@/components/attachments/attachment-layout";
|
||||
import { ButtonTooltip } from "@/components/ui/button-tooltip";
|
||||
import { useAttachmentByteSize } from "@/hooks/useAttachmentByteSize";
|
||||
import { isDesktopResourceRuntime, toApiResourceUrl } from "@/lib/desktop-resources";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -20,34 +23,48 @@ const FileAttachmentNodeView = ({ node }: NodeViewProps) => {
|
||||
? node.attrs.filename
|
||||
: getAttachmentFilenameFromLabel(label);
|
||||
const mimeType = typeof node.attrs.mimeType === "string" ? node.attrs.mimeType : "";
|
||||
const metadata = formatAttachmentMetadata(mimeType, filename || label, node.attrs.byteSize);
|
||||
const resolvedUrl = isDesktopResourceRuntime() ? url : toApiResourceUrl(url);
|
||||
const byteSize = useAttachmentByteSize(resolvedUrl, node.attrs.byteSize);
|
||||
const metadata = formatAttachmentMetadata(mimeType, filename || label, byteSize);
|
||||
const isAudio = resolveAttachmentKind(mimeType, filename || label) === "audio";
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="edgeever-file-attachment-node" contentEditable={false}>
|
||||
<span className={cn("edgeever-file-viewer flex min-h-12 items-center gap-2 overflow-hidden rounded-xl border border-slate-200 bg-white px-3 shadow-sm", COMPACT_ATTACHMENT_WIDTH_CLASS)}>
|
||||
<a
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left no-underline"
|
||||
>
|
||||
<AttachmentFileIcon mimeType={mimeType} filename={filename || label} className="h-5 w-5 shrink-0" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-slate-800">{label}</span>
|
||||
<span className="truncate text-xs font-medium text-slate-500">{metadata}</span>
|
||||
<span className={cn("edgeever-file-viewer flex min-h-12 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm", COMPACT_ATTACHMENT_WIDTH_CLASS)}>
|
||||
<span className="flex min-h-12 items-center gap-2 px-3">
|
||||
<a
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left no-underline"
|
||||
>
|
||||
<AttachmentFileIcon mimeType={mimeType} filename={filename || label} className="h-5 w-5 shrink-0" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-slate-800">{label}</span>
|
||||
<span className="truncate text-xs font-medium text-slate-500">{metadata}</span>
|
||||
</span>
|
||||
</a>
|
||||
<ButtonTooltip title={t("editor.resourceActions.download")}>
|
||||
<a className="pdf-viewer-action" href={resolvedUrl} download={filename || label} aria-label={t("editor.resourceActions.download")}>
|
||||
<Download aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip title={t("pdfViewer.openExternal")}>
|
||||
<a className="pdf-viewer-action" href={resolvedUrl} target="_blank" rel="noreferrer" aria-label={t("pdfViewer.openExternal")}>
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonTooltip>
|
||||
</span>
|
||||
{isAudio && resolvedUrl ? (
|
||||
<span className="border-t border-slate-200 px-3 py-2">
|
||||
<AudioAttachmentPlayer
|
||||
key={resolvedUrl}
|
||||
src={resolvedUrl}
|
||||
label={t("audioPlayer.label", { filename: filename || label })}
|
||||
unavailableMessage={t("audioPlayer.unavailable")}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<ButtonTooltip title={t("pdfViewer.download")}>
|
||||
<a className="pdf-viewer-action" href={resolvedUrl} download={filename || label} aria-label={t("pdfViewer.download")}>
|
||||
<Download aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip title={t("pdfViewer.openExternal")}>
|
||||
<a className="pdf-viewer-action" href={resolvedUrl} target="_blank" rel="noreferrer" aria-label={t("pdfViewer.openExternal")}>
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonTooltip>
|
||||
) : null}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveAttachmentKind } from "./attachment-kind.ts";
|
||||
import { resolveAttachmentKind, resolveAudioMimeType } from "./attachment-kind.ts";
|
||||
|
||||
describe("attachment kind", () => {
|
||||
test("uses MIME types for media and filenames for common documents", () => {
|
||||
expect(resolveAttachmentKind("audio/mpeg", "recording.bin")).toBe("audio");
|
||||
expect(resolveAttachmentKind("application/octet-stream", "recording.FLAC")).toBe("audio");
|
||||
expect(resolveAttachmentKind(null, "archive.ape")).toBe("audio");
|
||||
expect(resolveAttachmentKind(null, "report.PDF")).toBe("pdf");
|
||||
expect(resolveAttachmentKind(null, "budget.xlsx")).toBe("spreadsheet");
|
||||
expect(resolveAttachmentKind(null, "proposal.docx")).toBe("document");
|
||||
@@ -12,6 +14,13 @@ describe("attachment kind", () => {
|
||||
expect(resolveAttachmentKind(null, "backup.7z")).toBe("archive");
|
||||
});
|
||||
|
||||
test("normalizes common audio MIME types from filenames", () => {
|
||||
expect(resolveAudioMimeType("application/octet-stream", "voice.mp3")).toBe("audio/mpeg");
|
||||
expect(resolveAudioMimeType("", "lossless.flac")).toBe("audio/flac");
|
||||
expect(resolveAudioMimeType("audio/x-custom", "voice.mp3")).toBe("audio/x-custom");
|
||||
expect(resolveAudioMimeType("application/octet-stream", "payload.bin")).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back predictably for text and unknown files", () => {
|
||||
expect(resolveAttachmentKind("text/plain", "README")).toBe("text");
|
||||
expect(resolveAttachmentKind("application/octet-stream", "payload.bin")).toBe("file");
|
||||
|
||||
@@ -17,6 +17,31 @@ export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];
|
||||
const extensionOf = (filename: string | null | undefined) =>
|
||||
filename?.trim().toLowerCase().match(/\.([a-z0-9]+)(?:[?#].*)?$/)?.[1] ?? "";
|
||||
|
||||
const AUDIO_MIME_TYPES_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
aac: "audio/aac",
|
||||
aiff: "audio/aiff",
|
||||
ape: "audio/x-ape",
|
||||
flac: "audio/flac",
|
||||
m4a: "audio/mp4",
|
||||
mp3: "audio/mpeg",
|
||||
oga: "audio/ogg",
|
||||
ogg: "audio/ogg",
|
||||
opus: "audio/ogg",
|
||||
wav: "audio/wav",
|
||||
weba: "audio/webm",
|
||||
wma: "audio/x-ms-wma",
|
||||
};
|
||||
|
||||
/** Resolve an audio MIME type without overriding a specific type supplied by storage. */
|
||||
export const resolveAudioMimeType = (
|
||||
mimeType: string | null | undefined,
|
||||
filename: string | null | undefined,
|
||||
) => {
|
||||
const mime = mimeType?.trim().toLowerCase() ?? "";
|
||||
if (mime.startsWith("audio/")) return mime;
|
||||
return AUDIO_MIME_TYPES_BY_EXTENSION[extensionOf(filename)] ?? null;
|
||||
};
|
||||
|
||||
export const resolveAttachmentKind = (
|
||||
mimeType: string | null | undefined,
|
||||
filename: string | null | undefined,
|
||||
@@ -25,7 +50,7 @@ export const resolveAttachmentKind = (
|
||||
const extension = extensionOf(filename);
|
||||
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (resolveAudioMimeType(mime, filename)) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf" || extension === "pdf") return "pdf";
|
||||
|
||||
|
||||
@@ -1095,6 +1095,10 @@ export const enUS = {
|
||||
previous: "Previous PDF",
|
||||
next: "Next PDF",
|
||||
},
|
||||
audioPlayer: {
|
||||
label: "Audio player: {{filename}}",
|
||||
unavailable: "This audio format cannot be played on this device. You can still download it or open it externally.",
|
||||
},
|
||||
editor: {
|
||||
imageScale: "Image scale",
|
||||
scaleTo: "Scale to {{percent}}%",
|
||||
|
||||
@@ -1095,6 +1095,10 @@ export const zhCN = {
|
||||
previous: "上一个 PDF",
|
||||
next: "下一个 PDF",
|
||||
},
|
||||
audioPlayer: {
|
||||
label: "音频播放器:{{filename}}",
|
||||
unavailable: "当前设备无法播放此音频格式,你仍可下载或在外部打开。",
|
||||
},
|
||||
editor: {
|
||||
imageScale: "图片缩放",
|
||||
scaleTo: "缩放到 {{percent}}%",
|
||||
|
||||
Reference in new issue
Block a user