返回 CodeWhale
session-media.tsx
根目录 / web / components / session-media.tsx
1 /**
2 * <SessionMedia> — renders one entry from web/lib/media-manifest.ts.
3 *
4 * Two states, both honest:
5 *
6 * pending — a plainly labeled panel: "Recording pending release
7 * candidate". No mock terminal, no staged screenshot, no
8 * recycled clip. The panel exists so the absence of footage is
9 * visible and explained, not hidden.
10 *
11 * published — a <video> with the declared poster, per-locale WebVTT
12 * captions, a transcript link, and a GIF fallback link. The
13 * reduced-motion contract is structural (REDUCED_MOTION_POLICY
14 * = "static-poster-no-autoplay"): preload="none", no autoPlay,
15 * user-initiated playback only. The optional GIF is a link,
16 * not an autoplaying reduced-motion substitute.
17 *
18 * Server component: no client JS, SSG-safe.
19 */
20
21 import { REDUCED_MOTION_POLICY, type MediaAsset } from "@/lib/media-manifest";
22 import { getDocsShell, pickText } from "@/lib/i18n/dictionaries";
23 import { StatusBadge } from "./status-badge";
24
25 const MEDIA_PLAN_DOC =
26 "https://github.com/codewhale-hq/CodeWhale/blob/main/docs/releases/v0.9.2-media-plan.md";
27 const REPO_BLOB_BASE = "https://github.com/codewhale-hq/CodeWhale/blob/main";
28
29 export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; locale?: string }) {
30 const t = getDocsShell(locale);
31
32 if (asset.status === "pending") {
33 return (
34 <figure
35 className="session-media session-media-pending"
36 data-media-id={asset.id}
37 data-media-status={asset.status}
38 data-reduced-motion-policy={REDUCED_MOTION_POLICY}
39 >
40 <div className="session-media-stage">
41 <StatusBadge kind="pending" locale={locale} label={asset.pendingLabel} />
42 <p className="session-media-pending-note">
43 {t.mediaPendingNote}
44 </p>
45 </div>
46 <figcaption className="session-media-caption">
47 <strong>{pickText(asset.title, locale)}</strong>
48 <span>{pickText(asset.description, locale)}</span>
49 <a href={MEDIA_PLAN_DOC} target="_blank" rel="noreferrer">
50 {t.mediaPlanLink}
51 </a>
52 </figcaption>
53 </figure>
54 );
55 }
56
57 // status === "published": manifest tests require every field and verify
58 // file presence/byte budgets plus poster dimensions. The human recording
59 // checklist remains authoritative for actual video duration/dimensions.
60 const poster = asset.poster!;
61 const video = asset.video!;
62 const captions = asset.captions ?? [];
63 // The track matching the page's language is the default; English only
64 // when the page's language has no captions.
65 const defaultCaption = captions.some((track) => track.srclang === locale) ? locale : "en";
66
67 return (
68 <figure
69 className="session-media"
70 data-media-id={asset.id}
71 data-media-status={asset.status}
72 data-reduced-motion-policy={REDUCED_MOTION_POLICY}
73 >
74 <video
75 controls
76 preload="none"
77 poster={`/${poster.src}`}
78 width={video.width}
79 height={video.height}
80 aria-label={pickText(poster.alt, locale)}
81 >
82 <source src={`/${video.src}`} type="video/mp4" />
83 {captions.map((track) => (
84 <track
85 key={track.srclang}
86 kind="captions"
87 src={`/${track.src}`}
88 srcLang={track.srclang}
89 label={track.label}
90 default={track.srclang === defaultCaption}
91 />
92 ))}
93 </video>
94 <figcaption className="session-media-caption">
95 <strong>{pickText(asset.title, locale)}</strong>
96 <span>{pickText(asset.description, locale)}</span>
97 {asset.gifFallback && (
98 <a href={`/${asset.gifFallback.src}`}>
99 {t.mediaGifFallback}
100 </a>
101 )}
102 {asset.transcript && (
103 <a href={`${REPO_BLOB_BASE}/${asset.transcript}`} target="_blank" rel="noreferrer">
104 {t.mediaTranscript}
105 </a>
106 )}
107 </figcaption>
108 </figure>
109 );
110 }
111
111 lines Plain Text