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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user