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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
48
src/App.css
48
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;
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${active ? " is-playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump, as a click on the card does.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}}
|
||||
aria-label={playing ? t`Pause` : t`Play`}
|
||||
>
|
||||
<Thumbnail
|
||||
src={fileArtworkUrl(dump, token)}
|
||||
className="rich-content-compact-thumbnail"
|
||||
placeholder={{ seed: dump.fileName ?? dump.id, glyph: mimeIcon(mime) }}
|
||||
placeholderClassName="rich-content-compact-thumbnail"
|
||||
/>
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
{playing ? "⏸" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Float32Array | null>(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<Element>) => {
|
||||
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 (
|
||||
<div
|
||||
className={`audio-file-preview${
|
||||
isActive ? " audio-file-preview--active" : ""
|
||||
active ? " audio-file-preview--active" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
onClick={handlePlayBtn}
|
||||
aria-label={isPlaying ? t`Pause` : t`Play`}
|
||||
>
|
||||
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
{peaks
|
||||
? (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleWaveformClick}
|
||||
>
|
||||
{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 (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${
|
||||
played ? " waveform-bar--played" : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<div className="waveform-skeleton" onClick={handleWaveformClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FilePlayButton dump={dump} />
|
||||
<Waveform
|
||||
src={fileUrl}
|
||||
current={active ? currentTime : 0}
|
||||
duration={active ? duration : 0}
|
||||
// The player has no media to seek yet, so a first click plays from the
|
||||
// start and ignores where it landed.
|
||||
onSeek={(time) => active ? seekTo(time) : toggle()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <span className="rich-content-compact-icon">{fallback}</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="rich-content-compact-thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => 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 <FilePlayButton dump={dump} />;
|
||||
if (artworkUrl) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump, as a click on the card does.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
<Thumbnail
|
||||
src={artworkUrl}
|
||||
className="rich-content-compact-thumbnail"
|
||||
placeholder={{
|
||||
seed: dump.fileName ?? dump.id,
|
||||
glyph: mimeIcon(mime),
|
||||
}}
|
||||
aria-label={t`Play`}
|
||||
>
|
||||
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
placeholderClassName="rich-content-compact-thumbnail"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump, as a click on the card does.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
aria-label={t`Play`}
|
||||
>
|
||||
{thumbOverride
|
||||
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
|
||||
: (
|
||||
<span className="rich-content-compact-icon">
|
||||
{mimeIcon(mime)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (thumbOverride) {
|
||||
return <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />;
|
||||
}
|
||||
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
||||
}
|
||||
|
||||
@@ -301,45 +170,33 @@ export function FilePreview(
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
if (useGlobal) {
|
||||
const videoActive = isPlaying;
|
||||
const videoPlaying = videoActive && playing;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
aria-label={videoPlaying ? t`Pause` : t`Play`}
|
||||
onClick={() =>
|
||||
videoActive
|
||||
? togglePlay()
|
||||
: play(filePlayerItem(dump, fileUrl, mime, token))}
|
||||
className={`file-preview-play-btn${active ? " is-playing" : ""}`}
|
||||
aria-label={playing ? t`Pause` : t`Play`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
poster={thumbOverride ?? undefined}
|
||||
poster={artworkUrl}
|
||||
preload="none"
|
||||
className="file-preview-video-thumb"
|
||||
muted
|
||||
/>
|
||||
<span className="rich-content-play-overlay">
|
||||
{videoPlaying ? "⏸" : "▶"}
|
||||
{playing ? "⏸" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MediaPlayer
|
||||
src={fileUrl}
|
||||
kind="video"
|
||||
mime={mime}
|
||||
poster={thumbOverride ?? undefined}
|
||||
/>
|
||||
<MediaPlayer src={fileUrl} kind="video" mime={mime} poster={artworkUrl} />
|
||||
);
|
||||
}
|
||||
|
||||
if (mime.startsWith("audio/")) {
|
||||
if (useGlobal) {
|
||||
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
|
||||
}
|
||||
if (useGlobal) return <AudioFilePreview dump={dump} />;
|
||||
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<Link
|
||||
@@ -183,11 +186,15 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
? (e) => {
|
||||
if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable);
|
||||
}
|
||||
: playsFile
|
||||
? (e) => {
|
||||
if (!handledAsNewTab(e)) file.toggle();
|
||||
}
|
||||
: cardProps.onClick}
|
||||
>
|
||||
<div className="journal-card-image">
|
||||
<Thumbnail
|
||||
src={thumbnailUrl ?? undefined}
|
||||
src={thumbnailUrl}
|
||||
placeholder={dump.kind === "file"
|
||||
// A video still fails without ffmpeg on the server, and the MIME
|
||||
// glyph says more than an initial taken from the file name.
|
||||
@@ -199,9 +206,9 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
siteName: dump.richContent?.siteName,
|
||||
}}
|
||||
/>
|
||||
{playable && (
|
||||
{(playable || playsFile) && (
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
▶
|
||||
{file.playing ? "⏸" : "▶"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
88
src/hooks/player/usePlayFile.ts
Normal file
88
src/hooks/player/usePlayFile.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -47,9 +47,9 @@ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user