diff --git a/src/components/player/Waveform.tsx b/src/components/player/Waveform.tsx
index 65353bc6..47753803 100644
--- a/src/components/player/Waveform.tsx
+++ b/src/components/player/Waveform.tsx
@@ -1,5 +1,6 @@
/**
- * Renders an audio file's waveform, which MediaPlayer shows as its seek bar.
+ * Renders an audio file's waveform, which MediaPlayer shows as its seek bar and
+ * an audio file's dump page beside its cover.
*
* @module
*/
@@ -14,7 +15,10 @@ import {
WAVEFORM_H,
} from "../../utils/player/waveform.ts";
-/** Passed to {@link Waveform} by MediaPlayer. Times are in seconds. */
+/**
+ * Passed to {@link Waveform} by MediaPlayer and FilePreview. Times are in
+ * seconds.
+ */
export interface WaveformProps {
/** Fetched again, apart from the media element, to decode its peaks. */
src: string;
diff --git a/src/hooks/player/usePlayFile.ts b/src/hooks/player/usePlayFile.ts
new file mode 100644
index 00000000..32f9b953
--- /dev/null
+++ b/src/hooks/player/usePlayFile.ts
@@ -0,0 +1,88 @@
+/**
+ * Plays an uploaded audio or video file in the global player. FilePreview and
+ * JournalCard call it, so a file plays and pauses the same from a list card, a
+ * journal tile and its dump's page.
+ *
+ * @module
+ */
+
+import { useContext } from "react";
+import type { Dump } from "../../../shared/dump.ts";
+import {
+ PlayerContext,
+ type PlayerItem,
+} from "../../contexts/player/PlayerContext.ts";
+import { useAuth } from "../auth/useAuth.ts";
+import {
+ dumpFileUrl,
+ dumpThumbnailUrl,
+ dumpUrl,
+} from "../../utils/shared/urls.ts";
+
+/** Checks whether `dump` is an uploaded file the global player can play. */
+export function isPlayableFile(dump: Dump): boolean {
+ const mime = dump.fileMime ?? "";
+ return dump.kind === "file" &&
+ (mime.startsWith("audio/") || mime.startsWith("video/"));
+}
+
+/**
+ * Returns the artwork of an uploaded file, for its thumbnail and the player:
+ * the uploaded thumbnail, or the server's still of a video or scaled-down copy
+ * of an image. A bare audio file has none, since the thumbnail endpoint has
+ * nothing to serve for it, and shows a placeholder instead.
+ */
+export function fileArtworkUrl(
+ dump: Dump,
+ token?: string | null,
+): string | undefined {
+ const mime = dump.fileMime ?? "";
+ const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/") ||
+ mime.startsWith("image/");
+ return hasArtwork ? dumpThumbnailUrl(dump, token) : undefined;
+}
+
+/** Returned by {@link usePlayFile} to the views that play an uploaded file. */
+export interface UsePlayFileResult {
+ /** The file's URL, with the viewer's media token, as the player loads it. */
+ fileUrl: string;
+ /** True while the file is the player's current item, playing or paused. */
+ active: boolean;
+ /** True while the file is playing. */
+ playing: boolean;
+ /**
+ * Plays the file from the start, or pauses and resumes it once it is the
+ * player's current item, so a second click doesn't restart it.
+ */
+ toggle: () => void;
+}
+
+/**
+ * Returns the playback state of the uploaded file `dump` and a function that
+ * plays or pauses it. The player's title links to the dump's page.
+ */
+export function usePlayFile(dump: Dump): UsePlayFileResult {
+ const { current, playing, play, togglePlay } = useContext(PlayerContext);
+ const { mediaToken: token } = useAuth();
+ const fileUrl = dumpFileUrl(dump, token);
+ const active = current?.kind === "file" && current.fileUrl === fileUrl;
+
+ const toggle = () => {
+ if (active) {
+ togglePlay();
+ return;
+ }
+ const item: PlayerItem = {
+ kind: "file",
+ fileUrl,
+ mimeType: dump.fileMime ?? "",
+ title: dump.title,
+ dumpHref: dumpUrl(dump),
+ artworkUrl: fileArtworkUrl(dump, token),
+ subtitle: dump.fileName,
+ };
+ play(item);
+ };
+
+ return { fileUrl, active, playing: active && playing, toggle };
+}
diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx
index d41b800f..9b3fcbbe 100644
--- a/src/test/components/dump/FilePreview.test.tsx
+++ b/src/test/components/dump/FilePreview.test.tsx
@@ -65,6 +65,49 @@ describe("FilePreview", () => {
}));
});
+ test("compact audio without a cover shows a placeholder, not an image", () => {
+ const { container } = renderWithContexts(
+ ,
+ );
+ 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(, {
+ 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(
+ ,
+ );
+ 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");
diff --git a/src/test/components/dump/JournalCard.test.tsx b/src/test/components/dump/JournalCard.test.tsx
index 69adb63e..489d3f1c 100644
--- a/src/test/components/dump/JournalCard.test.tsx
+++ b/src/test/components/dump/JournalCard.test.tsx
@@ -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(
+
+
+
,
+ { 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",
diff --git a/src/test/utils/feed/journalLayout.test.ts b/src/test/utils/feed/journalLayout.test.ts
index d1100c49..d2bef8c8 100644
--- a/src/test/utils/feed/journalLayout.test.ts
+++ b/src/test/utils/feed/journalLayout.test.ts
@@ -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);
diff --git a/src/utils/feed/journalLayout.ts b/src/utils/feed/journalLayout.ts
index 9862f1c4..c50fc4a9 100644
--- a/src/utils/feed/journalLayout.ts
+++ b/src/utils/feed/journalLayout.ts
@@ -47,9 +47,9 @@ const FOOTPRINT: Record = {
};
/**
- * Checks whether `dump` has an image to show: an uploaded thumbnail, an image
- * or video file, or a link preview's thumbnail. Only such dumps get the large
- * tiles, and `JournalCard` shows them as image tiles.
+ * Checks whether `dump` has an image to show: an uploaded thumbnail, an image,
+ * video or audio file, or a link preview's thumbnail. Only such dumps get the
+ * large tiles, and `JournalCard` shows them as image tiles.
*/
export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file") {
@@ -57,6 +57,9 @@ export function hasThumbnail(dump: Dump): boolean {
// Videos count: GET /api/dumps/:dumpId/thumbnail grabs a still with ffmpeg on
// first request and caches it, so there's art to show without any upload.
if (mime.startsWith("image/") || mime.startsWith("video/")) return true;
+ // Audio counts too: without a cover, its tile is a placeholder tinted from
+ // the file name, which the tile plays like any other artwork.
+ if (mime.startsWith("audio/")) return true;
}
if (dump.thumbnailMime) return true;
return !!dump.richContent?.thumbnailUrl;