From 4ea192e0b1371dc577f92ec7375b3542b84233d8 Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 8 Oct 2026 22:33:33 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20an=20uploaded=20audio=20file=20plays=20fr?= =?UTF-8?q?om=20its=20cover=20everywhere,=20and=20a=20card's=20play=20badg?= =?UTF-8?q?e=20sits=20in=20the=20middle=20of=20its=20thumbnail=20=E2=80=94?= =?UTF-8?q?=20a=20link=20played=20from=20its=20thumbnail=20in=20every=20vi?= =?UTF-8?q?ew,=20but=20an=20uploaded=20file=20only=20did=20on=20a=20list?= =?UTF-8?q?=20card:=20a=20journal=20tile=20opened=20the=20dump,=20an=20aud?= =?UTF-8?q?io=20file's=20page=20showed=20a=20round=20button=20by=20its=20w?= =?UTF-8?q?aveform=20and=20never=20its=20uploaded=20cover,=20and=20an=20up?= =?UTF-8?q?loaded=20video's=20page=20was=20a=20black=20box=20until=20playe?= =?UTF-8?q?d.=20The=20list=20card's=20badge=20sat=20in=20the=20thumbnail's?= =?UTF-8?q?=20bottom-left=20corner,=20where=20it=20looked=20misplaced,=20a?= =?UTF-8?q?nd=20clicking=20a=20playing=20file=20restarted=20it.=20The=20ne?= =?UTF-8?q?w=20usePlayFile=20hook=20builds=20the=20player's=20item=20and?= =?UTF-8?q?=20the=20file's=20artwork=20(fileArtworkUrl)=20for=20the=20card?= =?UTF-8?q?,=20the=20journal=20tile=20and=20the=20dump's=20page,=20and=20p?= =?UTF-8?q?lays,=20or=20pauses=20once=20the=20file=20is=20the=20current=20?= =?UTF-8?q?one.=20A=20card's=20badge,=20=E2=96=B6=20or=20=E2=8F=B8,=20is?= =?UTF-8?q?=20now=20centred,=20as=20is=20the=20=E2=86=97=20on=20a=20link?= =?UTF-8?q?=20out;=20over=20a=20placeholder=20it=20takes=20the=20place=20o?= =?UTF-8?q?f=20the=20glyph.=20An=20audio=20file=20without=20a=20cover=20sh?= =?UTF-8?q?ows=20a=20placeholder=20tinted=20from=20its=20file=20name,=20no?= =?UTF-8?q?t=20a=20bare=20=F0=9F=8E=B5.=20In=20the=20journal,=20audio=20co?= =?UTF-8?q?unts=20as=20having=20art=20(hasThumbnail),=20so=20it=20is=20an?= =?UTF-8?q?=20image=20tile,=20its=20glyph=20raised=20above=20the=20title,?= =?UTF-8?q?=20and=20a=20file=20tile=20plays=20and=20pauses=20the=20file.?= =?UTF-8?q?=20On=20the=20dump's=20page,=20the=20cover=20(96px,=20the=20car?= =?UTF-8?q?d's=20badge=20on=20it)=20plays=20the=20file=20beside=20the=20sh?= =?UTF-8?q?ared=20Waveform,=20which=20replaces=20FilePreview's=20copy=20of?= =?UTF-8?q?=20it;=20a=20video=20takes=20the=20server's=20still=20as=20its?= =?UTF-8?q?=20poster.=20674=20frontend=20tests=20pass,=20and=20tsc,=20esli?= =?UTF-8?q?nt,=20deno=20fmt,=20doc:lint=20and=20i18n:compile=20--strict=20?= =?UTF-8?q?are=20clean;=20checked=20in=20screenshots=20in=20Smooth=20light?= =?UTF-8?q?=20at=201280=20and=20390px,=20Brutalist=20dark=20on=20the=20jou?= =?UTF-8?q?rnal=20at=20390px=20and=20Kanagawa=20dark=20at=20390px,=20playi?= =?UTF-8?q?ng.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- locales/en.po | 16 +- locales/fr.po | 16 +- src/App.css | 48 ++- src/components/dump/FilePreview.tsx | 293 +++++------------- src/components/dump/JournalCard.tsx | 41 ++- src/components/player/Waveform.tsx | 8 +- src/hooks/player/usePlayFile.ts | 88 ++++++ src/test/components/dump/FilePreview.test.tsx | 43 +++ src/test/components/dump/JournalCard.test.tsx | 25 ++ src/test/utils/feed/journalLayout.test.ts | 7 +- src/utils/feed/journalLayout.ts | 9 +- 11 files changed, 323 insertions(+), 271 deletions(-) create mode 100644 src/hooks/player/usePlayFile.ts diff --git a/locales/en.po b/locales/en.po index bfdaa392..e05a6d8a 100644 --- a/locales/en.po +++ b/locales/en.po @@ -367,7 +367,7 @@ msgstr "[deleted]" #. placeholder {0}: dump.commentCount #: src/components/dump/DumpCard.tsx:139 -#: src/components/dump/JournalCard.tsx:133 +#: src/components/dump/JournalCard.tsx:136 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" @@ -853,7 +853,7 @@ msgid "Done" msgstr "Done" #. placeholder {0}: dump.fileName -#: src/components/dump/FilePreview.tsx:358 +#: src/components/dump/FilePreview.tsx:215 msgid "Download {0}" msgstr "Download {0}" @@ -1441,8 +1441,8 @@ msgstr "Passwords do not match" msgid "Paste a link…" msgstr "Paste a link…" -#: src/components/dump/FilePreview.tsx:134 -#: src/components/dump/FilePreview.tsx:310 +#: src/components/dump/FilePreview.tsx:65 +#: src/components/dump/FilePreview.tsx:177 #: src/components/dump/RichContentCard.tsx:103 #: src/components/player/MediaPlayer.tsx:330 msgid "Pause" @@ -1452,10 +1452,8 @@ msgstr "Pause" msgid "Picked up where you left off." msgstr "Picked up where you left off." -#: src/components/dump/FilePreview.tsx:134 -#: src/components/dump/FilePreview.tsx:253 -#: src/components/dump/FilePreview.tsx:275 -#: src/components/dump/FilePreview.tsx:310 +#: src/components/dump/FilePreview.tsx:65 +#: src/components/dump/FilePreview.tsx:177 #: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:150 #: src/components/player/MediaPlayer.tsx:330 @@ -1491,7 +1489,7 @@ msgid "Previous track" msgstr "Previous track" #: src/components/dump/DumpCard.tsx:148 -#: src/components/dump/JournalCard.tsx:142 +#: src/components/dump/JournalCard.tsx:145 #: src/components/playlist/PlaylistCard.tsx:104 #: src/components/playlist/PlaylistHeader.tsx:76 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 diff --git a/locales/fr.po b/locales/fr.po index 3dd14d9c..1c275f14 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -367,7 +367,7 @@ msgstr "[supprimé]" #. placeholder {0}: dump.commentCount #: src/components/dump/DumpCard.tsx:139 -#: src/components/dump/JournalCard.tsx:133 +#: src/components/dump/JournalCard.tsx:136 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" @@ -853,7 +853,7 @@ msgid "Done" msgstr "Terminé" #. placeholder {0}: dump.fileName -#: src/components/dump/FilePreview.tsx:358 +#: src/components/dump/FilePreview.tsx:215 msgid "Download {0}" msgstr "Télécharger {0}" @@ -1441,8 +1441,8 @@ msgstr "Les mots de passe ne correspondent pas" msgid "Paste a link…" msgstr "Collez un lien…" -#: src/components/dump/FilePreview.tsx:134 -#: src/components/dump/FilePreview.tsx:310 +#: src/components/dump/FilePreview.tsx:65 +#: src/components/dump/FilePreview.tsx:177 #: src/components/dump/RichContentCard.tsx:103 #: src/components/player/MediaPlayer.tsx:330 msgid "Pause" @@ -1452,10 +1452,8 @@ msgstr "Pause" msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." -#: src/components/dump/FilePreview.tsx:134 -#: src/components/dump/FilePreview.tsx:253 -#: src/components/dump/FilePreview.tsx:275 -#: src/components/dump/FilePreview.tsx:310 +#: src/components/dump/FilePreview.tsx:65 +#: src/components/dump/FilePreview.tsx:177 #: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:150 #: src/components/player/MediaPlayer.tsx:330 @@ -1491,7 +1489,7 @@ msgid "Previous track" msgstr "Piste précédente" #: src/components/dump/DumpCard.tsx:148 -#: src/components/dump/JournalCard.tsx:142 +#: src/components/dump/JournalCard.tsx:145 #: src/components/playlist/PlaylistCard.tsx:104 #: src/components/playlist/PlaylistHeader.tsx:76 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 diff --git a/src/App.css b/src/App.css index 86fce443..071594bd 100644 --- a/src/App.css +++ b/src/App.css @@ -1064,8 +1064,18 @@ fill: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%); } -.audio-file-preview--active .audio-player-btn { - background: var(--color-accent-hover); +/* The cover plays the file, as a card's thumbnail does, and wears the same + badge (see "Two targets, one card"). */ +.audio-file-preview .rich-content-thumbnail-btn { + width: 96px; + height: 96px; + border-radius: var(--radius-md); +} + +.audio-file-preview .rich-content-play-overlay { + width: 34px; + height: 34px; + font-size: var(--text-sm); } /* ── Video player ── */ @@ -4045,7 +4055,8 @@ body.has-player .chat-fab { /* The initial has no intrinsic size, so scale it per surface. */ .dump-card-preview .thumb-placeholder-initials, -.playlist-card-preview .thumb-placeholder-initials { +.playlist-card-preview .thumb-placeholder-initials, +.audio-file-preview .thumb-placeholder-initials { font-size: var(--text-3xl); } @@ -4053,6 +4064,13 @@ body.has-player .chat-fab { font-size: var(--text-display); } +/* The title and meta cover the tile's foot, so the glyph is centred in the + space above them, where a one-row tile has room for it. */ +.journal-card-image .thumb-placeholder { + box-sizing: border-box; + padding-bottom: 5rem; +} + .rich-content-thumbnail.thumb-placeholder .thumb-placeholder-initials { font-size: var(--text-display); } @@ -4086,16 +4104,23 @@ body.has-player .chat-fab { The card opens the dump page; a playable (or source-linked) thumbnail does its own thing. Only one of them may look armed at a time: over the thumb the card's "open" cue (the accent title) stands down and the thumb's own - dim and badge take over. At rest the thumb carries only a small corner - badge, so it reads as "playable" without claiming the whole card. */ + dim and badge take over. At rest the thumb carries only a small badge in + its centre, so it reads as "playable" without claiming the whole card. An + audio file's cover on its dump's page wears the same badge. */ .dump-card-inner:has(.dump-card-preview :is(button, a):hover) .dump-card-title { color: var(--color-text); } -.dump-card-preview .rich-content-play-overlay, +/* Over a placeholder, the badge stands in for its glyph. */ +:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn .thumb-placeholder-initials { + visibility: hidden; +} + +:is(.dump-card-preview, .audio-file-preview) .rich-content-play-overlay, .rich-content-open-overlay { position: absolute; - inset: auto auto 5px 5px; + inset: 0; + margin: auto; z-index: 1; width: 22px; height: 22px; @@ -4121,6 +4146,7 @@ body.has-player .chat-fab { } .dump-card-preview .rich-content-thumbnail-btn::after, +.audio-file-preview .rich-content-thumbnail-btn::after, .dump-card-preview .rich-content-compact::after { content: ""; position: absolute; @@ -4136,12 +4162,12 @@ body.has-player .chat-fab { opacity: 0; } -.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible)::after, +:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn:is(:hover, :focus-visible)::after, .dump-card-preview .rich-content-compact:is(:hover, :focus-visible)::after { opacity: 1; } -.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay, +:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay, .rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay { transform: scale(1.2); background: rgba(0, 0, 0, 0.8); @@ -4149,12 +4175,12 @@ body.has-player .chat-fab { } /* The one playing keeps its badge lit, in the accent, showing ⏸. */ -.dump-card-preview .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay { +:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay { background: var(--color-accent); color: var(--color-on-accent); } -.dump-card-preview .rich-content-compact-thumbnail { +:is(.dump-card-preview, .audio-file-preview) .rich-content-compact-thumbnail { width: 100%; height: 100%; object-fit: cover; diff --git a/src/components/dump/FilePreview.tsx b/src/components/dump/FilePreview.tsx index d1fd0099..e34f5047 100644 --- a/src/components/dump/FilePreview.tsx +++ b/src/components/dump/FilePreview.tsx @@ -5,30 +5,23 @@ * @module */ -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import type { Dump } from "../../../shared/dump.ts"; import { formatBytes } from "../../utils/shared/format.ts"; -import { - dumpFileUrl, - dumpThumbnailUrl, - dumpUrl, -} from "../../utils/shared/urls.ts"; +import { dumpFileUrl, dumpThumbnailUrl } from "../../utils/shared/urls.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; -import { IconPause, IconPlay } from "../player/PlayerIcons.tsx"; +import { + fileArtworkUrl, + isPlayableFile, + usePlayFile, +} from "../../hooks/player/usePlayFile.ts"; import { MediaPlayer } from "../player/MediaPlayer.tsx"; +import { Waveform } from "../player/Waveform.tsx"; +import { Thumbnail } from "../shared/Thumbnail.tsx"; import { PlayerContext, - type PlayerItem, PlayerTimeContext, } from "../../contexts/player/PlayerContext.ts"; -import { - BAR_GAP, - BAR_W, - extractPeaks, - NUM_BARS, - VIEWBOX_W, - WAVEFORM_H, -} from "../../utils/player/waveform.ts"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -49,150 +42,69 @@ export interface FilePreviewProps { } /** - * Returns the global player's item for an uploaded file, the same from a card - * and from the dump's page. A bare audio file gets no artwork URL, since the - * thumbnail endpoint has nothing to serve for it. + * Renders an audio or video file's artwork as a button that plays it in the + * global player, and pauses it once it plays: a card's thumbnail, and the + * cover beside an audio file's waveform on its dump's page. A file without + * artwork, or whose video still fails, shows a placeholder tinted from its + * name. */ -function filePlayerItem( - dump: Dump, - fileUrl: string, - mime: string, - token?: string | null, -): PlayerItem { - const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/"); - return { - kind: "file", - fileUrl, - mimeType: mime, - title: dump.title, - dumpHref: dumpUrl(dump), - artworkUrl: hasArtwork ? dumpThumbnailUrl(dump, token) : undefined, - subtitle: dump.fileName, - }; +function FilePlayButton({ dump }: { dump: Dump }) { + const { mediaToken: token } = useAuth(); + const { active, playing, toggle } = usePlayFile(dump); + const mime = dump.fileMime ?? ""; + return ( + + ); } /** - * Renders an audio file on its dump's page as a waveform that plays in the - * global player and shows its progress there. + * Renders an audio file on its dump's page as its cover, which plays it in the + * global player, beside a waveform that shows its progress there and seeks. */ -function AudioFilePreview( - { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, -) { - const { mediaToken: token } = useAuth(); - const { current, playing, play, togglePlay, seekTo } = useContext( - PlayerContext, - ); +function AudioFilePreview({ dump }: { dump: Dump }) { + const { seekTo } = useContext(PlayerContext); const { currentTime, duration } = useContext(PlayerTimeContext); - const [peaks, setPeaks] = useState(null); - const isActive = current?.kind === "file" && current.fileUrl === fileUrl; - const progress = isActive && duration > 0 ? currentTime / duration : 0; - - useEffect(() => { - let cancelled = false; - extractPeaks(fileUrl, NUM_BARS) - .then((p) => { - if (!cancelled) setPeaks(p); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [fileUrl]); - - const handlePlayBtn = () => { - if (isActive) togglePlay(); - else play(filePlayerItem(dump, fileUrl, mime, token)); - }; - - const handleWaveformClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const ratio = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - if (isActive) { - seekTo(ratio * duration); - } else { - // The player has no media to seek yet, so a first click plays from the - // start and ignores where it landed. - play(filePlayerItem(dump, fileUrl, mime, token)); - } - }; - - const isPlaying = isActive && playing; + const { fileUrl, active, toggle } = usePlayFile(dump); return (
- - {peaks - ? ( - - {Array.from(peaks).map((p, i) => { - const barH = Math.max(p * WAVEFORM_H, 2); - const x = i * (BAR_W + BAR_GAP); - const y = (WAVEFORM_H - barH) / 2; - const played = i / NUM_BARS <= progress; - return ( - - ); - })} - - ) - : ( -
-
-
- )} + + active ? seekTo(time) : toggle()} + />
); } -/** Renders a video's still, or `fallback` when it fails to load. */ -function VideoThumb({ src, fallback }: { src: string; fallback: string }) { - const [failed, setFailed] = useState(false); - if (failed) { - return {fallback}; - } - return ( - setFailed(true)} - /> - ); -} - /** Returns the icon for a file of type `mime`. */ function mimeIcon(mime: string): string { if (mime.startsWith("video/")) return "🎬"; @@ -211,14 +123,11 @@ function mimeIcon(mime: string): string { export function FilePreview( { dump, compact = false, global: useGlobal = false }: FilePreviewProps, ): React.JSX.Element { - const { current, playing, play, togglePlay } = useContext(PlayerContext); const { mediaToken: token } = useAuth(); + const { active, playing, toggle } = usePlayFile(dump); const fileUrl = dumpFileUrl(dump, token); const mime = dump.fileMime ?? ""; - const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; - const thumbOverride = dump.thumbnailMime - ? dumpThumbnailUrl(dump, token) - : null; + const artworkUrl = fileArtworkUrl(dump, token); if (compact) { if (mime.startsWith("image/")) { @@ -236,60 +145,20 @@ export function FilePreview( /> ); } - if (mime.startsWith("video/")) { - const thumbUrl = dumpThumbnailUrl(dump, token); + if (isPlayableFile(dump)) return ; + if (artworkUrl) { return ( - + placeholderClassName="rich-content-compact-thumbnail" + /> ); } - if (mime.startsWith("audio/")) { - return ( - - ); - } - if (thumbOverride) { - return ; - } return {mimeIcon(mime)}; } @@ -301,45 +170,33 @@ export function FilePreview( if (mime.startsWith("video/")) { if (useGlobal) { - const videoActive = isPlaying; - const videoPlaying = videoActive && playing; return ( ); } return ( - + ); } if (mime.startsWith("audio/")) { - if (useGlobal) { - return ; - } + if (useGlobal) return ; return ; } diff --git a/src/components/dump/JournalCard.tsx b/src/components/dump/JournalCard.tsx index f8accded..5026be3b 100644 --- a/src/components/dump/JournalCard.tsx +++ b/src/components/dump/JournalCard.tsx @@ -9,7 +9,7 @@ import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../../../shared/dump.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; -import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts"; +import { dumpUrl } from "../../utils/shared/urls.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts"; import { @@ -30,6 +30,11 @@ import { canPlayRichContent, usePlayRichContent, } from "../../hooks/player/usePlayRichContent.ts"; +import { + fileArtworkUrl, + isPlayableFile, + usePlayFile, +} from "../../hooks/player/usePlayFile.ts"; /** * Passed to {@link JournalCard} by `JournalFeed`, with the vote state `Index` @@ -52,7 +57,8 @@ export interface JournalCardProps { /** * Renders a dump as an image, a pull-quote or a text tile, depending on * whether it has a thumbnail or a note. Clicking an image tile plays its media - * when it can, and any other tile opens the dump. It is memoized like + * when it can, a link's or an uploaded audio or video file, and pauses a file + * once it plays; any other tile opens the dump. It is memoized like * `DumpCard`, so its vote callbacks must be stable. */ export const JournalCard: React.MemoExoticComponent< @@ -64,6 +70,7 @@ export const JournalCard: React.MemoExoticComponent< const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump); const { mediaToken } = useAuth(); const { playRichContent } = usePlayRichContent(); + const file = usePlayFile(dump); const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); @@ -73,18 +80,13 @@ export const JournalCard: React.MemoExoticComponent< if (!await playRichContent(rc, dumpUrl(dump))) go(); } - // Like FilePreview's compact mode: a video shows the server's still and an - // image its scaled-down copy, not the full upload. - const thumbnailUrl = dump.thumbnailMime - ? dumpThumbnailUrl(dump, mediaToken) - : dump.kind === "file" - ? (dump.fileMime?.startsWith("image/") || - dump.fileMime?.startsWith("video/") - ? dumpThumbnailUrl(dump, mediaToken) - : null) - : (dump.richContent?.thumbnailUrl ?? null); + // As in FilePreview's compact mode, a file shows the server's copy, not the + // full upload, and an audio file without a cover shows the placeholder. + const thumbnailUrl = dump.kind === "file" || dump.thumbnailMime + ? fileArtworkUrl(dump, mediaToken) + : dump.richContent?.thumbnailUrl; - const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl + const mode: "image" | "quote" | "text" = hasThumbnail(dump) ? "image" : hasQuote(dump) ? "quote" @@ -103,8 +105,9 @@ export const JournalCard: React.MemoExoticComponent< // The tile's thumbnail, the dump's own before the provider's, becomes the // player's artwork. const playable = richContent && canPlayRichContent(richContent) - ? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined } + ? { ...richContent, thumbnailUrl } : null; + const playsFile = isPlayableFile(dump); const titleLink = ( { if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable); } + : playsFile + ? (e) => { + if (!handledAsNewTab(e)) file.toggle(); + } : cardProps.onClick} >
- {playable && ( + {(playable || playsFile) && ( )}
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;