All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s
Re-resolving a stream is a network round-trip, and nothing checked that the player still belonged to that item once it landed. Picking another dump — or closing the player — while a resolve was in flight let the stale result swap the queue back; from onError, which resolves with autoplay, it would also start playing over whatever was chosen instead. A generation counter, bumped by playQueue/advanceTo/stop, now drops any result that no longer owns the player, with a separate counter owning the resolving flag so a superseded resolve can't clear a newer one's spinner. A track that stays dead after a re-resolve is one track, not one album: the cooldown path now steps to the next entry and only falls back to the iframe on the last one. A failed resolve still goes straight to the embed, since that's an album-wide failure rather than a single bad URL. And a track that has vanished from the release, or lost its streamable flag to Bandcamp's free-play cap, no longer resumes track 1 at the dead track's offset — the findIndex miss resets the offset instead of seeking past the end of another track. Clicking the playing row rewound it but never resumed: seekTo only moves currentTime, so re-selecting a finished or paused track looked like a no-op. It now resumes as well. Same idea in the compact rich-content card, whose button advertises "Pause" while active but re-played on click — in native mode that meant a fresh /api/bandcamp/tracks and a restart from track 1. It pauses for streams now; embeds have no transport of their own and keep restarting, as before. playRichContent reports whether playback actually started, so a native-mode Bandcamp page with no streams and no stored embedUrl — an artist root, a /music index, a preorder — is no longer a dead click: the journal card navigates to the dump as it used to, and the rich-content card opens the source page. Also: the tralbum fetch releases the response body on its two error paths. The endpoint is unauthenticated, so random /track/<slug> URLs would leak a connection per request while also defeating the cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
223 lines
6.7 KiB
TypeScript
223 lines
6.7 KiB
TypeScript
import { Link, useNavigate } from "react-router";
|
|
import { Plural, Trans } from "@lingui/react/macro";
|
|
import type { Dump } from "../model.ts";
|
|
import { relativeTime } from "../utils/relativeTime.ts";
|
|
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
|
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
|
|
import { VoteButton } from "./VoteButton.tsx";
|
|
import { Markdown } from "./Markdown.tsx";
|
|
import { Tooltip } from "./Tooltip.tsx";
|
|
import Thumbnail from "./Thumbnail.tsx";
|
|
import {
|
|
canPlayRichContent,
|
|
usePlayRichContent,
|
|
} from "../hooks/usePlayRichContent.ts";
|
|
|
|
export type { JournalShape };
|
|
|
|
interface JournalCardProps {
|
|
dump: Dump;
|
|
shape: JournalShape;
|
|
voteCount: number;
|
|
voted: boolean;
|
|
canVote: boolean;
|
|
castVote: (id: string) => void;
|
|
removeVote: (id: string) => void;
|
|
isOwner?: boolean;
|
|
}
|
|
|
|
export function JournalCard(
|
|
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
|
JournalCardProps,
|
|
) {
|
|
const navigate = useNavigate();
|
|
const { token } = useAuth();
|
|
const { playRichContent } = usePlayRichContent();
|
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
|
!isDumpVisited(dump.id);
|
|
|
|
function handleNavigate() {
|
|
markDumpVisited(dump.id);
|
|
navigate(dumpUrl(dump));
|
|
}
|
|
|
|
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
|
// page with no streams and no embed to fall back to — the card must still do
|
|
// what an unplayable one does rather than swallowing the click.
|
|
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
|
if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
|
|
}
|
|
|
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
|
// still here too, rather than degrading to a text card with a 🎬.
|
|
const thumbnailUrl = dump.thumbnailMime
|
|
? dumpThumbnailUrl(dump, token)
|
|
: dump.kind === "file"
|
|
? (dump.fileMime?.startsWith("image/")
|
|
? dumpFileUrl(dump, token)
|
|
: dump.fileMime?.startsWith("video/")
|
|
? dumpThumbnailUrl(dump, token)
|
|
: null)
|
|
: (dump.richContent?.thumbnailUrl ?? null);
|
|
|
|
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
|
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
|
// everything else falls back to a typographic text card.
|
|
const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl
|
|
? "image"
|
|
: hasQuote(dump)
|
|
? "quote"
|
|
: "text";
|
|
|
|
const fallbackIcon = dump.kind === "file"
|
|
? (() => {
|
|
const m = dump.fileMime ?? "";
|
|
if (m.startsWith("video/")) return "🎬";
|
|
if (m.startsWith("audio/")) return "🎵";
|
|
return "📄";
|
|
})()
|
|
: "🔗";
|
|
|
|
const richContent = dump.richContent;
|
|
// In native mode a Bandcamp page is playable even with no stored embedUrl.
|
|
// The card's own thumbnail (dump upload or provider) becomes the player's
|
|
// header artwork.
|
|
const playable = richContent && canPlayRichContent(richContent)
|
|
? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined }
|
|
: null;
|
|
|
|
const titleLink = (
|
|
<Link
|
|
to={dumpUrl(dump)}
|
|
className="journal-card-title"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
markDumpVisited(dump.id);
|
|
}}
|
|
>
|
|
{unread && <span className="unread-dot" aria-hidden="true" />}
|
|
{dump.title}
|
|
</Link>
|
|
);
|
|
|
|
const meta = (
|
|
<div className="journal-card-meta">
|
|
<Tooltip text={dump.createdAt.toLocaleString()}>
|
|
<time dateTime={dump.createdAt.toISOString()}>
|
|
{relativeTime(dump.createdAt)}
|
|
</time>
|
|
</Tooltip>
|
|
{dump.commentCount > 0 && (
|
|
<span className="dump-card-comment-count">
|
|
<Plural
|
|
value={dump.commentCount}
|
|
one="# comment"
|
|
other="# comments"
|
|
/>
|
|
</span>
|
|
)}
|
|
{dump.isPrivate && isOwner && (
|
|
<span className="dump-card-private-badge">
|
|
<Trans>private</Trans>
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const vote = (
|
|
<div className="journal-card-vote" onClick={(e) => e.stopPropagation()}>
|
|
<VoteButton
|
|
dumpId={dump.id}
|
|
count={voteCount}
|
|
voted={voted}
|
|
disabled={!canVote}
|
|
onCast={castVote}
|
|
onRemove={removeVote}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const footer = (
|
|
<div className="journal-card-footer">
|
|
{meta}
|
|
{vote}
|
|
</div>
|
|
);
|
|
|
|
const className =
|
|
`journal-card journal-card--${shape} journal-card--${mode}`;
|
|
|
|
if (mode === "image") {
|
|
// feature shows its note; tall/wide/square keep the image as the subject.
|
|
const showComment = shape === "feature" && !!dump.comment;
|
|
// For playable media the whole card plays; the title link still navigates.
|
|
// Keeping the handler on the <li> means the text overlay can't cover it.
|
|
return (
|
|
<li
|
|
className={className}
|
|
onClick={playable
|
|
? () => void handlePlayOrNavigate(playable)
|
|
: handleNavigate}
|
|
>
|
|
<div className="journal-card-image">
|
|
<Thumbnail
|
|
src={thumbnailUrl ?? undefined}
|
|
placeholder={dump.kind === "file"
|
|
// No ffmpeg on the host means no still — the mime glyph says more
|
|
// about the dump than an initial taken from its filename.
|
|
? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
|
|
: {
|
|
url: dump.url,
|
|
accentColor: dump.richContent?.accentColor,
|
|
faviconUrl: dump.richContent?.faviconUrl,
|
|
siteName: dump.richContent?.siteName,
|
|
}}
|
|
/>
|
|
{playable && (
|
|
<span className="rich-content-play-overlay" aria-hidden="true">
|
|
▶
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="journal-card-overlay">
|
|
{titleLink}
|
|
{showComment && (
|
|
<Markdown className="journal-card-comment" inline>
|
|
{dump.comment!}
|
|
</Markdown>
|
|
)}
|
|
{footer}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
if (mode === "quote") {
|
|
return (
|
|
<li className={className} onClick={handleNavigate}>
|
|
<span className="journal-card-quotemark" aria-hidden="true">“</span>
|
|
<Markdown className="journal-card-quote-body" inline>
|
|
{dump.comment!}
|
|
</Markdown>
|
|
<div className="journal-card-attribution">
|
|
{titleLink}
|
|
{footer}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// text
|
|
return (
|
|
<li className={className} onClick={handleNavigate}>
|
|
<span className="journal-card-glyph" aria-hidden="true">
|
|
{fallbackIcon}
|
|
</span>
|
|
{titleLink}
|
|
{footer}
|
|
</li>
|
|
);
|
|
}
|