From 8f2c7911f195a5e4e44829d90f08e3d101ef70fb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 2 Sep 2026 01:45:09 +0800 Subject: [PATCH] feat(attachments): add inline audio playback Use native media decoding with range-aware resource responses and graceful fallback for unsupported formats.\n\nRefs #334 --- apps/api/src/resource-routes.test.mjs | 28 +++++++++ apps/api/src/resource-routes.ts | 7 ++- apps/api/src/share-routes.test.mjs | 31 +++++++++ apps/api/src/share-routes.ts | 7 ++- .../AudioAttachmentPlayer.test.mjs | 20 ++++++ .../attachments/AudioAttachmentPlayer.tsx | 44 +++++++++++++ .../src/components/editor/FileAttachment.tsx | 63 ++++++++++++------- packages/shared/src/attachment-kind.test.mjs | 11 +++- packages/shared/src/attachment-kind.ts | 27 +++++++- packages/shared/src/i18n/en-US.ts | 4 ++ packages/shared/src/i18n/zh-CN.ts | 4 ++ 11 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/attachments/AudioAttachmentPlayer.test.mjs create mode 100644 apps/web/src/components/attachments/AudioAttachmentPlayer.tsx diff --git a/apps/api/src/resource-routes.test.mjs b/apps/api/src/resource-routes.test.mjs index ba00115c..b635b597 100644 --- a/apps/api/src/resource-routes.test.mjs +++ b/apps/api/src/resource-routes.test.mjs @@ -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; diff --git a/apps/api/src/resource-routes.ts b/apps/api/src/resource-routes.ts index 1a27f067..d8f3aac4 100644 --- a/apps/api/src/resource-routes.ts +++ b/apps/api/src/resource-routes.ts @@ -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), ); diff --git a/apps/api/src/share-routes.test.mjs b/apps/api/src/share-routes.test.mjs index 70babadb..b48b2e8e 100644 --- a/apps/api/src/share-routes.test.mjs +++ b/apps/api/src/share-routes.test.mjs @@ -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(); + }); }); diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index 20cece08..fa7652c2 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -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) => { 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") { diff --git a/apps/web/src/components/attachments/AudioAttachmentPlayer.test.mjs b/apps/web/src/components/attachments/AudioAttachmentPlayer.test.mjs new file mode 100644 index 00000000..9f9897e8 --- /dev/null +++ b/apps/web/src/components/attachments/AudioAttachmentPlayer.test.mjs @@ -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(" { + const [failed, setFailed] = useState(false); + + const handlePlay = (event: SyntheticEvent) => { + const activePlayer = event.currentTarget; + activePlayer.ownerDocument + .querySelectorAll("audio[data-edgeever-audio-player]") + .forEach((player) => { + if (player !== activePlayer) player.pause(); + }); + }; + + if (failed) { + return ( + + {unavailableMessage} + + ); + } + + return ( +