v4: an uploaded audio file plays from its cover everywhere, and a card's play badge sits in the middle of its thumbnail — a link played from its thumbnail in every view, but an uploaded file only did on a list card: a journal tile opened the dump, an audio file's page showed a round button by its waveform and never its uploaded cover, and an uploaded video's page was a black box until played. The list card's badge sat in the thumbnail's bottom-left corner, where it looked misplaced, and clicking a playing file restarted it. The new usePlayFile hook builds the player's item and the file's artwork (fileArtworkUrl) for the card, the journal tile and the dump's page, and plays, or pauses once the file is the current one. A card's badge, ▶ or ⏸, is now centred, as is the ↗ on a link out; over a placeholder it takes the place of the glyph. An audio file without a cover shows a placeholder tinted from its file name, not a bare 🎵. In the journal, audio counts as having art (hasThumbnail), so it is an image tile, its glyph raised above the title, and a file tile plays and pauses the file. On the dump's page, the cover (96px, the card's badge on it) plays the file beside the shared Waveform, which replaces FilePreview's copy of it; a video takes the server's still as its poster. 674 frontend tests pass, and tsc, eslint, deno fmt, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Brutalist dark on the journal at 390px and Kanagawa dark at 390px, playing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 22:33:33 +00:00
parent 2c031b0102
commit 4ea192e0b1
11 changed files with 323 additions and 271 deletions

View File

@@ -65,6 +65,49 @@ describe("FilePreview", () => {
}));
});
test("compact audio without a cover shows a placeholder, not an image", () => {
const { container } = renderWithContexts(
<FilePreview dump={file("audio/mpeg")} compact />,
);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector(".thumb-placeholder")).not.toBeNull();
});
test("compact audio pauses once it's the one playing", async () => {
const play = vi.fn();
const togglePlay = vi.fn();
const d = file("audio/mpeg");
renderWithContexts(<FilePreview dump={d} compact />, {
player: {
play,
togglePlay,
playing: true,
current: {
kind: "file",
fileUrl: dumpFileUrl(d),
mimeType: "audio/mpeg",
},
},
});
const button = screen.getByRole("button", { name: "Pause" });
expect(button).toHaveTextContent("⏸");
await userEvent.click(button);
expect(togglePlay).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
test("the global audio cover is the uploaded thumbnail", () => {
route("GET /api/dumps/:id/file", () => new TypeError("no audio in jsdom"));
const d = file("audio/mpeg", { thumbnailMime: "image/png" });
const { container } = renderWithContexts(
<FilePreview dump={d} global />,
);
expect(container.querySelector("button img")).toHaveAttribute(
"src",
dumpThumbnailUrl(d),
);
});
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
const d = file("video/mp4");

View File

@@ -86,6 +86,31 @@ describe("JournalCard", () => {
expect(currentPath()).toBe("/@owner/dumps/p");
});
test("an audio file is an image tile that plays the file", async () => {
const play = vi.fn();
const dump = makeDump({
id: "a",
kind: "file",
fileMime: "audio/mpeg",
fileName: "a.mp3",
url: undefined,
});
const { container } = renderWithContexts(
<ul>
<JournalCard dump={dump} shape="square" {...base} />
</ul>,
{ player: { play } },
);
const card = container.querySelector("li")!;
expect(card).toHaveClass("journal-card--image");
expect(card.querySelector(".thumb-placeholder")).not.toBeNull();
await userEvent.click(card.querySelector(".journal-card-image")!);
expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "file", mimeType: "audio/mpeg" }),
);
expect(currentPath()).toBe("/");
});
test("a video file shows its generated still", () => {
const dump = makeDump({
id: "v",

View File

@@ -51,13 +51,16 @@ function feed(n: number, withArt: (i: number) => boolean): Dump[] {
}
describe("hasThumbnail / hasQuote", () => {
test("image and video files have art; other files don't", () => {
test("image, video and audio files have art; other files don't", () => {
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "video/mp4" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "audio/mpeg" })))
.toBe(false);
.toBe(true);
expect(
hasThumbnail(makeDump({ kind: "file", fileMime: "application/pdf" })),
).toBe(false);
});
test("an uploaded thumbnail or a rich preview image counts", () => {
expect(hasThumbnail(makeDump({ thumbnailMime: "image/webp" }))).toBe(true);