返回 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 { pickText } from "@/lib/i18n/dictionaries";
23 import { StatusBadge } from "./status-badge";
24
25 const MEDIA_PLAN_DOC =
26 "https://github.com/Hmbown/CodeWhale/blob/main/docs/releases/v0.9.2-media-plan.md";
27 const REPO_BLOB_BASE = "https://github.com/Hmbown/CodeWhale/blob/main";
28
29 export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; locale?: string }) {
30 const isZh = locale === "zh";
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 {isZh
44 ? "还没有录像。录好之后会放在这里,附字幕、文字稿和可选的 GIF 下载。"
45 : "There is no recording yet. When there is, it goes here with captions, a transcript, and an optional GIF download."}
46 </p>
47 </div>
48 <figcaption className="session-media-caption">
49 <strong>{pickText(asset.title, locale)}</strong>
50 <span>{pickText(asset.description, locale)}</span>
51 <a href={MEDIA_PLAN_DOC} target="_blank" rel="noreferrer">
52 {isZh ? "录制计划与验收清单 ↗" : "Recording plan and acceptance checklist ↗"}
53 </a>
54 </figcaption>
55 </figure>
56 );
57 }
58
59 // status === "published": manifest tests require every field and verify
60 // file presence/byte budgets plus poster dimensions. The human recording
61 // checklist remains authoritative for actual video duration/dimensions.
62 const poster = asset.poster!;
63 const video = asset.video!;
64 const captions = asset.captions ?? [];
65
66 return (
67 <figure
68 className="session-media"
69 data-media-id={asset.id}
70 data-media-status={asset.status}
71 data-reduced-motion-policy={REDUCED_MOTION_POLICY}
72 >
73 <video
74 controls
75 preload="none"
76 poster={`/${poster.src}`}
77 width={video.width}
78 height={video.height}
79 aria-label={pickText(poster.alt, locale)}
80 >
81 <source src={`/${video.src}`} type="video/mp4" />
82 {captions.map((track) => (
83 <track
84 key={track.srclang}
85 kind="captions"
86 src={`/${track.src}`}
87 srcLang={track.srclang}
88 label={track.label}
89 default={track.srclang === "en"}
90 />
91 ))}
92 </video>
93 <figcaption className="session-media-caption">
94 <strong>{pickText(asset.title, locale)}</strong>
95 <span>{pickText(asset.description, locale)}</span>
96 {asset.gifFallback && (
97 <a href={`/${asset.gifFallback.src}`}>
98 {isZh ? "GIF 下载回退(无视频环境)" : "GIF fallback download (no-video environments)"}
99 </a>
100 )}
101 {asset.transcript && (
102 <a href={`${REPO_BLOB_BASE}/${asset.transcript}`} target="_blank" rel="noreferrer">
103 {isZh ? "文字稿 ↗" : "Transcript ↗"}
104 </a>
105 )}
106 </figcaption>
107 </figure>
108 );
109 }
110
110 lines Plain Text