dsh-hackertube 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2568 @@
1
+ /**
2
+ * HackerTube Client 半 —— 经典脚本,经 __ModuleLoader__ 注入。
3
+ *
4
+ * 落点:左栏面板图标(sidebar.panellist,id=hackertube)与主区面板
5
+ * (main,key=hackertube)成对绑定,点图标即切到面板。
6
+ * 数据全部来自 Host 的 /hackertube/api/*,浏览器不直接碰上游。
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-hackertube',
10
+ factory(require) {
11
+ /* 最早的信号:factory 被调用本身就说明浏览器加载了这个模块 */
12
+ try {
13
+ fetch('/hackertube/api/beacon', {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({ at: Date.now(), phase: 'factory-enter' }),
17
+ keepalive: true,
18
+ }).catch(() => {})
19
+ } catch {
20
+ /* 回执失败不影响功能 */
21
+ }
22
+
23
+ function beacon(phase, extra) {
24
+ try {
25
+ fetch('/hackertube/api/beacon', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify(Object.assign({ at: Date.now(), phase }, extra || {})),
29
+ keepalive: true,
30
+ }).catch(() => {})
31
+ } catch {
32
+ /* 回执失败不影响功能 */
33
+ }
34
+ }
35
+
36
+ const React = require('react')
37
+ const h = React.createElement
38
+ const { useState, useEffect, useMemo, useRef } = React
39
+
40
+ beacon('factory-body', {
41
+ hasReact: !!React,
42
+ hasCreateElement: typeof h === 'function',
43
+ hasUseState: typeof useState === 'function',
44
+ })
45
+
46
+ /* 模块级缓存:面板卸载后重开不重复拉取 */
47
+ const mem = {
48
+ list: null,
49
+ listP: null,
50
+ tax: null,
51
+ taxP: null,
52
+ detail: new Map(),
53
+ origin: undefined,
54
+ lang: 'en',
55
+ mediaOrigin: null,
56
+ titles: {},
57
+ qMt: {},
58
+ catZh: null,
59
+ titlesP: null,
60
+ playhead: null,
61
+ taxLang: null,
62
+ }
63
+
64
+ const GRID_STEP = 60
65
+ /* 配合 CSS content-visibility 把离屏卡片的布局成本降到接近零 */
66
+ const GRID_CAP = 2000
67
+
68
+ /** 界面文案:中文原句 + 英文对照,调用处不必写三元表达式 */
69
+ const tx = (lang, zh, en) => (lang === 'zh' ? zh : en)
70
+
71
+ /* ---------------- 取数 ---------------- */
72
+
73
+ function getJSON(url) {
74
+ /* 不用 credentials: same-origin —— 自定义协议下它可能让请求发不出去;
75
+ 同源默认就会带 cookie */
76
+ return fetch(url).then((r) => {
77
+ if (!r.ok) throw new Error('HTTP ' + r.status)
78
+ /* Host 在每次响应头里下发真实 HTTP 地址,dsh-app:// 下也能拿到 */
79
+ try {
80
+ const o = r.headers.get('X-Hackertube-Origin')
81
+ if (o && !mem.origin) mem.origin = o
82
+ } catch {
83
+ /* 读不到头不影响数据 */
84
+ }
85
+ return r.json()
86
+ })
87
+ }
88
+
89
+ function loadList() {
90
+ if (mem.list) return Promise.resolve(mem.list)
91
+ if (!mem.listP) {
92
+ mem.listP = getJSON('/hackertube/api/list')
93
+ .then((d) => {
94
+ if (!Array.isArray(d) || !d.length) throw new Error('列表为空')
95
+ mem.list = d
96
+ return d
97
+ })
98
+ .catch((e) => {
99
+ mem.listP = null
100
+ throw e
101
+ })
102
+ }
103
+ return mem.listP
104
+ }
105
+
106
+ function loadTaxonomy(lang) {
107
+ const key = lang === 'zh' ? 'zh' : 'en'
108
+ if (mem.tax && mem.taxLang === key) return Promise.resolve(mem.tax)
109
+ if (!mem.taxP || mem.taxLang !== key) {
110
+ mem.taxLang = key
111
+ const q = key === 'zh' ? '/hackertube/api/taxonomy?lang=zh&translate=1' : '/hackertube/api/browse'
112
+ mem.taxP = getJSON(q)
113
+ .then((d) => {
114
+ mem.tax = d
115
+ return d
116
+ })
117
+ .catch((e) => {
118
+ mem.taxP = null
119
+ throw e
120
+ })
121
+ }
122
+ return mem.taxP
123
+ }
124
+
125
+ /** 标题译文表;后台按批生成,前端拿到多少用多少。中文搜索也依赖它,所以英文模式一样要拉 */
126
+ function loadTitles() {
127
+ if (mem.titlesP) return mem.titlesP
128
+ mem.titlesP = getJSON('/hackertube/api/titles?lang=zh&translate=1')
129
+ .then((d) => {
130
+ mem.titles = (d && d.entries) || {}
131
+ return mem.titles
132
+ })
133
+ .catch(() => {
134
+ mem.titlesP = null
135
+ return {}
136
+ })
137
+ return mem.titlesP
138
+ }
139
+
140
+ function titleOf(item, lang) {
141
+ if (lang === 'zh' && mem.titles && mem.titles[item.v]) return mem.titles[item.v]
142
+ return item.t || tx(lang, '(无标题)', '(untitled)')
143
+ }
144
+
145
+ function loadDetail(vid, lang) {
146
+ const key = lang === 'zh' ? vid + ':zh' : vid
147
+ const hit = mem.detail.get(key)
148
+ if (hit) return Promise.resolve(hit)
149
+ const q =
150
+ '/hackertube/api/video?vid=' +
151
+ encodeURIComponent(vid) +
152
+ (lang === 'zh' ? '&lang=zh' : '')
153
+ return getJSON(q).then((d) => {
154
+ if (mem.detail.size > 40) mem.detail.delete(mem.detail.keys().next().value)
155
+ mem.detail.set(key, d)
156
+ return d
157
+ })
158
+ }
159
+
160
+ /**
161
+ * 桌面端页面跑在 dsh-app:// 自定义协议下,Chromium 不把它当作安全上下文,
162
+ * 任何 iframe 请求都不会带 Referer,YouTube 就报 153。
163
+ * 拿到 Host 的 http origin 后,用绝对地址把中转页放回 HTTP 上下文。
164
+ */
165
+ function loadOrigin() {
166
+ if (mem.origin) return Promise.resolve(mem.origin)
167
+ /* 等任何一次数据响应把头里的 origin 带回来,最多两秒 */
168
+ return new Promise((resolve) => {
169
+ let tries = 0
170
+ const tick = () => {
171
+ if (mem.origin) return resolve(mem.origin)
172
+ if (tries >= 20) return resolve(null)
173
+ tries += 1
174
+ setTimeout(tick, 100)
175
+ }
176
+ tick()
177
+ })
178
+ }
179
+
180
+ /** 插件自起的免认证中转服务器地址,播放器要靠它落到 HTTP 安全上下文。
181
+ * 每次都向 Host 要一次,避免插件重载后端口变化时前端还拿着旧地址。 */
182
+ function loadMediaOrigin() {
183
+ return new Promise((resolve) => {
184
+ let tries = 0
185
+ const tick = () => {
186
+ if (tries >= 30) return resolve(mem.mediaOrigin || null)
187
+ tries += 1
188
+ setTimeout(() => {
189
+ getJSON('/hackertube/api/config')
190
+ .then((d) => {
191
+ if (d && d.mediaOrigin) {
192
+ mem.mediaOrigin = String(d.mediaOrigin)
193
+ return resolve(mem.mediaOrigin)
194
+ }
195
+ tick()
196
+ })
197
+ .catch(() => tick())
198
+ }, 150)
199
+ }
200
+ tick()
201
+ })
202
+ }
203
+
204
+ /* ---------------- 格式化 ---------------- */
205
+
206
+ function fmtDur(sec) {
207
+ const s = Math.max(0, Math.round(Number(sec) || 0))
208
+ const hh = Math.floor(s / 3600)
209
+ const mm = Math.floor((s % 3600) / 60)
210
+ const ss = s % 60
211
+ const pad = (n) => (n < 10 ? '0' + n : String(n))
212
+ return hh ? hh + ':' + pad(mm) + ':' + pad(ss) : mm + ':' + pad(ss)
213
+ }
214
+
215
+ function fmtCount(n, lang = 'en') {
216
+ const v = Number(n) || 0
217
+ /* 截断而非四舍五入:YouTube 把 12 988 516 显示成 12M,不是 13M */
218
+ const trim = (x) => {
219
+ const s = x.toFixed(1)
220
+ return s.endsWith('.0') ? s.slice(0, -2) : s
221
+ }
222
+ if (lang === 'zh') {
223
+ /* 中文用万/亿,不混用 K/M/B */
224
+ if (v >= 1e8) return trim(Math.floor(v / 1e6) / 100) + ' 亿'
225
+ if (v >= 1e4) return Math.floor(v / 1e4) + ' 万'
226
+ return String(v)
227
+ }
228
+ if (v >= 1e9) return trim(Math.floor(v / 1e8) / 10) + 'B'
229
+ if (v >= 1e6) return trim(Math.floor(v / 1e5) / 10) + 'M'
230
+ if (v >= 1e3) return trim(Math.floor(v / 1e2) / 10) + 'K'
231
+ return String(v)
232
+ }
233
+
234
+ function fmtAgo(iso, lang = 'zh') {
235
+ if (!iso) return ''
236
+ const t = Date.parse(iso)
237
+ if (!t) return ''
238
+ const days = Math.floor((Date.now() - t) / 86400000)
239
+ if (days < 1) return tx(lang, '今天', 'today')
240
+ if (days < 30) return tx(lang, days + ' 天前', days + (days === 1 ? ' day ago' : ' days ago'))
241
+ const months = Math.floor(days / 30)
242
+ if (months < 12) return tx(lang, months + ' 个月前', months + (months === 1 ? ' month ago' : ' months ago'))
243
+ const years = Math.floor(months / 12)
244
+ return tx(lang, years + ' 年前', years + (years === 1 ? ' year ago' : ' years ago'))
245
+ }
246
+
247
+ /* 简介卡头部用的两个枚举标签,中文下必须译,英文下原样 */
248
+ const CT_ZH = {
249
+ course: '课程',
250
+ talk: '演讲',
251
+ lecture: '讲座',
252
+ explainer: '科普',
253
+ documentary: '纪录片',
254
+ interview: '访谈',
255
+ tutorial: '教程',
256
+ commentary: '评论',
257
+ demo: '演示',
258
+ podcast: '播客',
259
+ comedy: '喜剧',
260
+ news: '新闻',
261
+ music: '音乐',
262
+ other: '其他',
263
+ }
264
+ const LV_ZH = { intro: '入门', intermediate: '进阶', general: '通用', advanced: '高阶' }
265
+
266
+ /* ---------------- 样式 ---------------- */
267
+
268
+ const CSS = `
269
+ .ht-root{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;
270
+ background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);
271
+ font:14px/1.4 Roboto,"Segoe UI",-apple-system,BlinkMacSystemFont,Arial,sans-serif}
272
+ .ht-head{flex:none;display:flex;align-items:center;gap:12px;padding:14px 24px;border-bottom:1px solid var(--dsw-alias-border-l1)}
273
+ .ht-brand{display:flex;align-items:center;gap:8px;font-weight:700;font-size:17px;letter-spacing:-.4px;white-space:nowrap}
274
+ .ht-brand b{color:#ff6600;font-weight:700}
275
+ .ht-lang{flex:none;height:32px;min-width:52px;padding:0 12px;border-radius:16px;cursor:pointer; font:inherit;font-size:13px;border:1px solid var(--dsw-alias-border-l1);
276
+ background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}
277
+ .ht-lang:hover{background:var(--dsw-alias-bg-layer-2)}
278
+ .ht-search{flex:1;max-width:640px;display:flex;min-width:0;position:relative;
279
+ border:1px solid var(--dsw-alias-border-l1);border-radius:18px;overflow:hidden;
280
+ background:var(--dsw-alias-bg-layer-1);
281
+ transition:border-color .18s cubic-bezier(.32,.72,0,1),box-shadow .18s cubic-bezier(.32,.72,0,1)}
282
+ .ht-search:hover{border-color:var(--dsw-alias-border-l2)}
283
+ /* 焦点画在整个输入框上,输入区与按钮不再各自描边,避免左右两截 */
284
+ .ht-search:focus-within{border-color:rgba(255,102,0,.55);box-shadow:0 0 0 3px rgba(255,102,0,.12)}
285
+ .ht-search input{flex:1;min-width:0;height:34px;padding:0 14px;font:inherit;font-size:14px;color:inherit;
286
+ background:none;border:0;outline:none}
287
+ .ht-search input::-webkit-search-cancel-button,
288
+ .ht-search input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none}
289
+ .ht-search button{width:48px;height:34px;border:0;cursor:pointer;background:none;
290
+ color:var(--dsw-alias-label-secondary);display:grid;place-items:center;
291
+ transition:color .16s cubic-bezier(.32,.72,0,1)}
292
+ .ht-search button:hover{color:var(--dsw-alias-label-primary)}
293
+ .ht-chips{flex:none;display:flex;gap:8px;overflow-x:auto;padding:12px 24px 0;
294
+ scrollbar-width:none;-ms-overflow-style:none}
295
+ .ht-chips::-webkit-scrollbar{display:none}
296
+ .ht-chip{flex:none;height:32px;padding:0 12px;border-radius:8px;border:0;cursor:pointer;
297
+ background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit;font-weight:500;white-space:nowrap;
298
+ display:inline-flex;align-items:center}
299
+ .ht-chip:hover{background:var(--dsw-alias-bg-layer-2)}
300
+ .ht-chip.on{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base)}
301
+ .ht-scroll{flex:1;min-height:0;overflow-y:auto;padding:0 24px 40px}
302
+ .ht-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr));gap:32px 20px;padding-top:16px}
303
+ .ht-card.kbd{outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px;border-radius:12px}
304
+ .ht-card{display:block;min-width:0;cursor:pointer;background:none;border:0;padding:0;
305
+ text-align:left;color:inherit;font:inherit;
306
+ content-visibility:auto;contain-intrinsic-size:auto 320px}
307
+ .ht-thumb{position:relative;aspect-ratio:16/9;border-radius:12px;overflow:hidden;
308
+ background:var(--dsw-alias-bg-layer-1)}
309
+ .ht-thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:border-radius .15s}
310
+ .ht-card:hover .ht-thumb img{border-radius:0}
311
+ .ht-dur{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.8);color:#fff;
312
+ font-size:12px;font-weight:500;padding:1px 4px;border-radius:4px}
313
+ .ht-info{padding-top:12px}
314
+ .ht-title{margin:0 0 4px;font-size:16px;font-weight:500;line-height:22px;height:44px;
315
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
316
+ .ht-sub{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:20px;
317
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
318
+ .ht-foot{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;margin-top:2px}
319
+ .ht-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:6px 0 0;font-size:13px;line-height:18px;
320
+ color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
321
+ .ht-meta > span + span:not(.ht-hn-mark)::before{content:"·";margin-right:8px;color:var(--dsw-alias-label-tertiary)}
322
+ .ht-meta .ht-chan{font-weight:500}
323
+ .ht-meta b{font-weight:400;color:var(--dsw-alias-label-primary)}
324
+ .ht-actions{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 0}
325
+ .ht-comments{margin:30px 0 8px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:20px}
326
+ .ht-comments-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:4px}
327
+ .ht-comments-head .ht-link{font-size:12.5px;color:var(--dsw-alias-label-tertiary)}
328
+ .ht-comments-head .ht-link:hover{color:var(--dsw-alias-label-primary)}
329
+ .ht-comments-acts{display:flex;align-items:baseline;gap:14px}
330
+ .ht-comment-tr{margin-left:auto;padding:0;border:0;background:none;cursor:pointer;font:inherit;
331
+ font-size:12px;color:var(--dsw-alias-label-tertiary)}
332
+ .ht-comment-tr:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}
333
+ .ht-comment-tr:disabled{opacity:.55;cursor:default;text-decoration:none}
334
+ .ht-comments h2{display:flex;align-items:baseline;gap:8px;margin:0 0 10px;font-size:14px;font-weight:600}
335
+ .ht-comments h2 span{font-size:12.5px;font-weight:500;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
336
+ .ht-comment{padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
337
+ .ht-comment:last-child{border-bottom:0}
338
+ .ht-comment-head{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
339
+ .ht-comment-head b{font-weight:600;color:var(--dsw-alias-label-primary)}
340
+ .ht-stance{font-size:11.5px;padding:1px 6px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover)}
341
+ .ht-stance-strong_rec{color:var(--dsw-alias-label-primary)}
342
+ .ht-comment-text{margin:0;font-size:13.5px;line-height:21px;color:var(--dsw-alias-label-primary);white-space:pre-wrap}
343
+ .ht-comment-link{display:inline-block;margin-top:6px;font-size:12px;color:var(--dsw-alias-label-tertiary);text-decoration:none}
344
+ .ht-comment-link:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}
345
+ .ht-state{color:var(--dsw-alias-label-secondary);padding:48px 0;text-align:center;font-size:14px}
346
+ .ht-sentinel{height:1px}
347
+ .ht-watch{flex:1;min-height:0;overflow-y:auto;padding:22px 24px 16px;container-type:inline-size}
348
+ .ht-watch-inner{max-width:1580px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr) 388px;
349
+ align-items:start;gap:28px;padding-top:2px}
350
+ .ht-watch .ht-toolbar{margin:0 auto 12px;max-width:1580px}
351
+ .ht-primary{min-width:0}
352
+ .ht-rail{position:sticky;top:0;align-self:start;display:flex;flex-direction:column;
353
+ height:calc(100vh - 70px);min-height:420px}
354
+ /* 面板变窄时右栏落到正文下方:容器查询,跟随面板宽度而不是视口宽度 */
355
+ @container (max-width:1024px){
356
+ .ht-watch-inner{grid-template-columns:minmax(0,1fr)}
357
+ .ht-rail{position:static;height:min(640px,70vh)}
358
+ }
359
+ .ht-rail-head{position:relative;flex:none;display:flex;align-items:center;gap:4px;padding:4px;border-radius:999px;
360
+ background:var(--dsw-alias-interactive-bg-hover)}
361
+ .ht-rail-ind{position:absolute;top:4px;bottom:4px;left:4px;width:calc(50% - 6px);border-radius:999px;
362
+ background:var(--dsw-alias-bg-base);box-shadow:0 1px 2px rgba(15,17,21,.08);
363
+ transition:transform .34s cubic-bezier(.32,.72,0,1)}
364
+ .ht-rail[data-pane="ai"] .ht-rail-ind{transform:translateX(calc(100% + 4px))}
365
+ .ht-rail-tab{position:relative;z-index:1;flex:1;height:32px;border-radius:999px;border:0;background:none;cursor:pointer;
366
+ font:inherit;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary);
367
+ display:inline-flex;align-items:center;justify-content:center;gap:6px;
368
+ transition:color .2s cubic-bezier(.32,.72,0,1)}
369
+ .ht-rail-tab[aria-selected="true"]{color:var(--dsw-alias-label-primary);font-weight:600}
370
+ .ht-rail-badge{font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
371
+ .ht-rail-viewport{position:relative;flex:1;min-height:0;margin-top:12px;overflow:hidden}
372
+ .ht-rail-track{display:flex;width:200%;height:100%;transition:transform .34s cubic-bezier(.32,.72,0,1);
373
+ will-change:transform}
374
+ .ht-rail[data-pane="ai"] .ht-rail-track{transform:translateX(-50%)}
375
+ .ht-rail-pane{width:50%;flex:none;height:100%;padding-right:2px;overflow-y:auto;overscroll-behavior:auto;
376
+ -webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 10px);
377
+ mask-image:linear-gradient(to bottom,transparent 0,#000 10px)}
378
+ .ht-rail-pane::-webkit-scrollbar{width:8px}
379
+ .ht-rail-pane::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2);border-radius:4px}
380
+ .ht-rail-empty{font-size:13px;color:var(--dsw-alias-label-tertiary);padding:12px 0}
381
+ .ht-rel{display:flex;gap:12px;width:100%;padding:10px 0;border:0;background:none;cursor:pointer;
382
+ text-align:left;font:inherit;color:inherit;-webkit-mask:linear-gradient(#000,#000);mask:linear-gradient(#000,#000)}
383
+ .ht-rel:hover .ht-rel-title{text-decoration:underline;text-underline-offset:2px}
384
+ .ht-rel-thumb{position:relative;width:156px;flex:none;aspect-ratio:16/9;border-radius:10px;overflow:hidden;
385
+ background:var(--dsw-alias-bg-layer-2)}
386
+ .ht-rel-thumb img{width:100%;height:100%;object-fit:cover;display:block}
387
+ .ht-rel-dur{position:absolute;right:6px;bottom:6px;padding:1px 5px;border-radius:4px;background:rgba(0,0,0,.78);
388
+ color:#fff;font-size:11px;font-variant-numeric:tabular-nums}
389
+ .ht-rel-body{min-width:0}
390
+ .ht-rel-title{margin:0;font-size:13.5px;line-height:1.4;font-weight:500;color:var(--dsw-alias-label-primary);
391
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
392
+ .ht-rel-meta{margin:6px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
393
+ .ht-rail-ai{display:flex;flex-direction:column;overflow:hidden}
394
+ .ht-rail-log{flex:1;min-height:0;overflow-y:auto;padding-right:4px}
395
+ .ht-rail-hint{margin:0 0 10px;font-size:12.5px;line-height:1.65;color:var(--dsw-alias-label-secondary)}
396
+ .ht-rail-suggest{display:flex;flex-direction:column;align-items:flex-start;gap:8px}
397
+ .ht-rail-suggest button{font:inherit;font-size:12.5px;padding:7px 12px;border-radius:999px;cursor:pointer;
398
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);
399
+ color:var(--dsw-alias-label-secondary)}
400
+ .ht-rail-suggest button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
401
+ .ht-rail-form{display:flex;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
402
+ .ht-rail-form input{flex:1;min-width:0;height:38px;padding:0 14px;border-radius:999px;
403
+ border:1px solid var(--dsw-alias-border-l2);background:transparent;font:inherit;font-size:13px;color:inherit;outline:none}
404
+ .ht-rail-form input:focus{border-color:var(--dsw-alias-label-tertiary);box-shadow:0 0 0 3px var(--dsw-alias-interactive-bg-hover)}
405
+ .ht-rail-form button{flex:none;height:38px;padding:0 16px;border-radius:999px;border:0;cursor:pointer;
406
+ background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base);font:inherit;font-size:13px;font-weight:600}
407
+ .ht-rail-form button:disabled{opacity:.5;cursor:default}
408
+ /* 详情页按钮组 */
409
+ .ht-pill{display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 16px;border-radius:999px;border:0;
410
+ cursor:pointer;font:inherit;font-size:13px;font-weight:500;white-space:nowrap;
411
+ background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);
412
+ transition:background .16s cubic-bezier(.32,.72,0,1)}
413
+ .ht-pill:hover{background:var(--dsw-alias-border-l2)}
414
+ .ht-pill.on{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base)}
415
+ .ht-pill svg{flex:none}
416
+ .ht-pill-ghost{background:transparent;border:1px solid var(--dsw-alias-border-l2)}
417
+ .ht-pill-ghost:hover{background:var(--dsw-alias-interactive-bg-hover)}
418
+ .ht-hn-mark{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;
419
+ background:rgba(255,102,0,.12);color:#b4460a;font-size:12px;font-weight:600}
420
+ .ht-hn-mark i{width:5px;height:5px;border-radius:50%;background:#ff6600;display:inline-block}
421
+ /* 评论区 */
422
+ .ht-comments{margin:30px 0 0;padding:22px 0 8px;border-top:1px solid var(--dsw-alias-border-l1)}
423
+ .ht-comments h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}
424
+ .ht-comments h2 span{font-size:12.5px;font-weight:500;color:var(--dsw-alias-label-tertiary);
425
+ font-variant-numeric:tabular-nums}
426
+ .ht-comments-sub{margin:0 0 12px;font-size:12.5px;color:var(--dsw-alias-label-tertiary)}
427
+ .ht-comment{padding:16px 0;border-bottom:1px solid var(--dsw-alias-border-l1);max-width:860px}
428
+ .ht-comment:last-child{border-bottom:0}
429
+ .ht-comment-head{display:flex;align-items:center;gap:10px;font-size:12.5px;
430
+ color:var(--dsw-alias-label-secondary);margin-bottom:6px}
431
+ .ht-comment-head b{color:var(--dsw-alias-label-primary);font-weight:600}
432
+ .ht-stance{font-size:11.5px;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);
433
+ color:var(--dsw-alias-label-secondary)}
434
+ .ht-stance-strong_rec{background:rgba(255,102,0,.12);color:#b4460a}
435
+ .ht-comment-text{margin:0;font-size:13.5px;line-height:1.7;color:var(--dsw-alias-label-primary)}
436
+ .ht-comment-link{display:inline-block;margin-top:8px;font-size:12.5px;color:var(--dsw-alias-label-tertiary);
437
+ text-decoration:none}
438
+ .ht-comment-link:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}
439
+ .ht-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;gap:12px}
440
+ .ht-bot{flex:none;display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 14px;
441
+ border-radius:17px;cursor:pointer;font:inherit;font-size:13px;font-weight:500;
442
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);
443
+ color:var(--dsw-alias-label-primary);
444
+ transition:background .18s cubic-bezier(.32,.72,0,1),transform .18s cubic-bezier(.32,.72,0,1)}
445
+ .ht-bot:hover{background:var(--dsw-alias-bg-layer-2)}
446
+ .ht-bot:active{transform:scale(.96)}
447
+ .ht-bot-on{background:var(--dsw-alias-brand-primary);color:#fff;border-color:transparent}
448
+ .ht-drawer{flex:none;width:376px;box-sizing:border-box;display:flex;flex-direction:column;
449
+ height:calc(100vh - 120px);max-height:720px;position:sticky;top:0;border-radius:20px;overflow:hidden;
450
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);
451
+ box-shadow:0 1px 2px rgba(15,17,21,.04),0 12px 40px -12px rgba(15,17,21,.18);
452
+ animation:ht-drawer-in .34s cubic-bezier(.32,.72,0,1)}
453
+ .ht-drawer::before{content:"";position:absolute;inset:0 0 auto;height:2px;
454
+ background:linear-gradient(90deg,rgba(255,102,0,0),rgba(255,102,0,.55),rgba(255,102,0,0));pointer-events:none}
455
+ @keyframes ht-drawer-in{from{opacity:0;transform:translateX(18px) scale(.995)}to{opacity:1;transform:none}}
456
+ .ht-drawer-head{display:flex;align-items:center;gap:10px;padding:12px 14px 12px 16px;
457
+ border-bottom:1px solid var(--dsw-alias-border-l1)}
458
+ .ht-drawer-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
459
+ .ht-caret{display:inline-block;width:7px;height:15px;margin-left:2px;border-radius:2px;background:var(--dsw-alias-label-primary);vertical-align:-2px;animation:ht-blink 1s steps(2,start) infinite}
460
+ @keyframes ht-blink{to{opacity:0}}
461
+ .ht-drawer-pos{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;
462
+ padding:2px 8px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover)}
463
+ .ht-drawer-dot{width:7px;height:7px;border-radius:50%;background:#ff6600;flex:none;
464
+ box-shadow:0 0 0 3px rgba(255,102,0,.14)}
465
+ .ht-drawer-close{margin-left:auto;width:26px;height:26px;border-radius:13px;border:0;cursor:pointer;
466
+ display:inline-grid;place-items:center;
467
+ background:transparent;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;
468
+ transition:background .16s cubic-bezier(.32,.72,0,1)}
469
+ .ht-drawer-close:hover{background:var(--dsw-alias-bg-layer-2)}
470
+ .ht-drawer .ht-chat-log{flex:1;max-height:none}
471
+ .ht-drawer-tabs{display:flex;align-items:center;gap:2px;min-width:0}
472
+ .ht-drawer-tab{position:relative;font:inherit;font-size:12.5px;font-weight:500;height:28px;padding:0 10px;border-radius:8px;
473
+ border:0;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;display:inline-flex;align-items:center;gap:6px;
474
+ transition:background .16s cubic-bezier(.32,.72,0,1),color .16s cubic-bezier(.32,.72,0,1)}
475
+ .ht-drawer-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
476
+ .ht-drawer-tab.on{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-weight:600}
477
+ .ht-drawer-tab span{font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
478
+ .ht-drawer-pane{flex:1;min-height:0;display:flex;flex-direction:column}
479
+ .ht-drawer-scroll{flex:1;min-height:0;overflow-y:auto;padding:8px 16px 20px}
480
+ .ht-drawer-scroll .ht-comment{padding:12px 0}
481
+ .ht-drawer-scroll .ht-comment:first-child{padding-top:4px}
482
+ /* ---- markdown ---- */
483
+ .ht-md{font-size:13px;line-height:1.72;color:var(--dsw-alias-label-primary)}
484
+ .ht-md-p{margin:0 0 10px}
485
+ .ht-md-p:last-child{margin-bottom:0}
486
+ .ht-md-h{margin:14px 0 6px;font-size:13.5px;font-weight:600;line-height:1.5}
487
+ .ht-md-h:first-child{margin-top:0}
488
+ .ht-md-list{margin:0 0 10px;padding-left:18px}
489
+ .ht-md-list li{margin:2px 0}
490
+ .ht-md-q{margin:8px 0;padding:2px 0 2px 10px;border-left:2px solid var(--dsw-alias-border-l3);
491
+ color:var(--dsw-alias-label-secondary)}
492
+ .ht-md-pre{margin:8px 0;padding:10px 12px;border-radius:10px;overflow-x:auto;
493
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:1.6}
494
+ .ht-md-pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
495
+ .ht-md-code{padding:1px 5px;border-radius:5px;background:var(--dsw-alias-bg-layer-2);
496
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
497
+ .ht-md a{color:var(--dsw-alias-label-primary);text-decoration:underline;text-underline-offset:2px}
498
+ .ht-md strong{font-weight:600}
499
+ .ht-md em{font-style:italic}
500
+ .ht-player{aspect-ratio:16/9;border-radius:12px;overflow:hidden;background:#000}
501
+ .ht-player iframe{width:100%;height:100%;border:0;display:block}
502
+ .ht-video{width:100%;height:100%;display:block;background:#000;object-fit:contain}
503
+ .ht-back{height:32px;padding:0 12px;border-radius:16px;border:0;cursor:pointer;font:inherit;
504
+ background:var(--dsw-alias-bg-layer-1);color:inherit;font-weight:500;display:inline-flex;
505
+ align-items:center;gap:6px;margin-bottom:16px}
506
+ .ht-back:hover{background:var(--dsw-alias-bg-layer-2)}
507
+ .ht-h1{font-size:21px;line-height:1.32;font-weight:600;letter-spacing:-.2px;margin:16px 0 0}
508
+ .ht-channel{margin:10px 0 0;font-size:14px;font-weight:500}
509
+ /* 简介卡:浅灰底、默认折叠三行、底部渐隐,展开后完整显示 */
510
+ .ht-desc{margin:18px 0 0;padding:16px 18px;border-radius:18px;
511
+ background:var(--dsw-alias-interactive-bg-hover)}
512
+ .ht-desc-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px;
513
+ font-size:13px;color:var(--dsw-alias-label-secondary)}
514
+ .ht-desc-head b{font-weight:600;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
515
+ .ht-desc-body{position:relative;overflow:hidden}
516
+ .ht-desc.clamped .ht-desc-body{max-height:84px;
517
+ -webkit-mask-image:linear-gradient(to bottom,#000 56px,transparent 84px);
518
+ mask-image:linear-gradient(to bottom,#000 56px,transparent 84px)}
519
+ .ht-desc-p{margin:0 0 10px;font-size:13.5px;line-height:1.72;color:var(--dsw-alias-label-primary)}
520
+ .ht-desc-p:last-child{margin-bottom:0}
521
+ .ht-desc-why{color:var(--dsw-alias-label-secondary)}
522
+ .ht-desc-quote{margin:12px 0 0;padding-left:12px;border-left:2px solid var(--dsw-alias-border-l3);
523
+ font-size:13.5px;line-height:1.7;font-style:italic;color:var(--dsw-alias-label-secondary)}
524
+ .ht-desc-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
525
+ .ht-desc-more{margin-top:10px;padding:0;border:0;background:none;cursor:pointer;font:inherit;
526
+ font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
527
+ .ht-desc-more:hover{text-decoration:underline;text-underline-offset:2px}
528
+ .ht-note{margin:14px 0 0;font-size:13px;color:var(--dsw-alias-label-secondary)}
529
+ .ht-stat{display:inline-flex;align-items:baseline;gap:4px;font-size:12px;padding:3px 9px;border-radius:999px;
530
+ background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary)}
531
+ .ht-stat b{font-weight:600;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
532
+ .ht-stat-strong{background:rgba(255,102,0,.14);color:#b4460a}
533
+ .ht-stat-strong b{color:#b4460a}
534
+ .ht-tag{font-size:12px;padding:3px 9px;border-radius:999px;background:var(--dsw-alias-bg-base);
535
+ color:var(--dsw-alias-label-secondary)}
536
+ .ht-chat{margin-top:20px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;overflow:hidden;
537
+ background:var(--dsw-alias-bg-layer-1)}
538
+ .ht-chat-head{padding:10px 14px;font-size:13px;font-weight:600;
539
+ border-bottom:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
540
+ .ht-chat-log{max-height:280px;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:12px;
541
+ -webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 12px,#000 calc(100% - 14px),transparent 100%);
542
+ mask-image:linear-gradient(to bottom,transparent 0,#000 12px,#000 calc(100% - 14px),transparent 100%)}
543
+ .ht-chat-hint{margin:0;font-size:13px;color:var(--dsw-alias-label-secondary)}
544
+ .ht-chat-suggest{display:flex;flex-direction:column;gap:8px;align-items:flex-start;margin-top:2px}
545
+ .ht-chat-suggest button{font:inherit;font-size:12.5px;padding:7px 12px;border-radius:999px;cursor:pointer;
546
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);
547
+ transition:border-color .16s cubic-bezier(.32,.72,0,1),color .16s cubic-bezier(.32,.72,0,1)}
548
+ .ht-chat-suggest button:hover{border-color:var(--dsw-alias-label-tertiary);color:var(--dsw-alias-label-primary)}
549
+ .ht-msg{font-size:13px;line-height:1.68;padding:8px 12px;border-radius:12px;white-space:pre-wrap;
550
+ animation:ht-msg-in .26s cubic-bezier(.32,.72,0,1) both;
551
+ max-width:92%}
552
+ .ht-msg-user{align-self:flex-end;background:var(--dsw-alias-interactive-bg-hover);
553
+ color:var(--dsw-alias-label-primary);border-bottom-right-radius:4px}
554
+ .ht-msg-ai{align-self:stretch;background:transparent;padding:0 2px;color:var(--dsw-alias-label-primary)}
555
+ @keyframes ht-msg-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
556
+ .ht-chat-form{display:flex;gap:8px;padding:12px 16px 16px;border-top:1px solid var(--dsw-alias-border-l1)}
557
+ .ht-chat-form input{flex:1;min-width:0;height:38px;padding:0 14px;font:inherit;font-size:13px;border-radius:999px;
558
+ border-radius:8px;border:1px solid var(--dsw-alias-border-l1);
559
+ background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}
560
+ .ht-chat-form button{flex:none;height:38px;padding:0 18px;border-radius:999px;cursor:pointer;
561
+ transition:transform .16s cubic-bezier(.32,.72,0,1),opacity .16s cubic-bezier(.32,.72,0,1);
562
+ font:inherit;font-size:13px;border:0;background:var(--dsw-alias-brand-primary);color:#fff}
563
+ .ht-chat-form button:disabled{opacity:.5;cursor:default}
564
+ .ht-chat-form input{transition:border-color .18s cubic-bezier(.32,.72,0,1),box-shadow .18s cubic-bezier(.32,.72,0,1)}
565
+ .ht-chat-form input:focus{outline:none;border-color:rgba(255,102,0,.45);box-shadow:0 0 0 3px rgba(255,102,0,.12)}
566
+ .ht-chat-form button:not(:disabled):hover{transform:translateY(-1px)}
567
+ @media (prefers-reduced-motion: reduce){
568
+ .ht-drawer,.ht-msg,.ht-caret,.ht-chat-form button{animation:none!important;transition:none!important}
569
+ }
570
+ .ht-link{color:var(--dsw-alias-brand-primary);text-decoration:none;background:none;border:0;
571
+ font:inherit;cursor:pointer;padding:0}
572
+ .ht-link:hover{text-decoration:underline}
573
+ .ht-link:hover{text-decoration:underline}
574
+ .ht-open{flex:none;height:32px;padding:0 12px;border-radius:16px;display:inline-flex;
575
+ align-items:center;font-size:13px;text-decoration:none;
576
+ background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary)}
577
+ .ht-open:hover{background:var(--dsw-alias-bg-layer-2)}
578
+ .ht-notice{color:var(--dsw-alias-label-secondary);font-size:13px;padding:20px 0;text-align:center}
579
+ /* ---- 视图层 ---- */
580
+ .ht-nav{flex:none;display:flex;align-items:center;justify-content:center;gap:20px;height:44px;
581
+ padding:0 24px;border-bottom:1px solid var(--dsw-alias-border-l1);position:relative}
582
+ .ht-nav .ht-tabs{display:flex;align-items:center;gap:20px}
583
+ .ht-tab{position:relative;height:44px;display:flex;align-items:center;font:inherit;font-size:13.5px;font-weight:500;
584
+ color:var(--dsw-alias-label-secondary);background:none;border:0;cursor:pointer;white-space:nowrap;padding:0}
585
+ .ht-tab:hover{color:var(--dsw-alias-label-primary)}
586
+ .ht-tab.on{color:var(--dsw-alias-label-primary)}
587
+ .ht-tab.on::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;border-radius:2px;
588
+ background:var(--dsw-alias-label-primary)}
589
+ .ht-tab-sep{width:1px;height:15px;background:var(--dsw-alias-border-l3)}
590
+ .ht-areas{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(360px,100%),1fr));gap:12px;padding:16px 24px 40px}
591
+ .ht-area{border:1px solid var(--dsw-alias-border-l1);border-radius:16px;padding:16px 20px;cursor:pointer;text-align:left;
592
+ background:none;font:inherit;color:inherit}
593
+ .ht-area:hover{border-color:var(--dsw-alias-border-l3)}
594
+ .ht-area h3{margin:0;display:flex;align-items:baseline;gap:8px;font-size:15px;font-weight:600}
595
+ .ht-area h3 em{font-style:normal;font-size:12.5px;font-weight:500;color:var(--dsw-alias-label-secondary)}
596
+ .ht-area .ht-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
597
+ .ht-area .ht-cat{font-size:12.5px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);
598
+ padding:2px 8px;border-radius:4px}
599
+ .ht-plgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));gap:24px 20px;padding:8px 24px 32px}
600
+ .ht-pl{display:flex;flex-direction:column;gap:8px;cursor:pointer;background:none;border:0;padding:0;text-align:left;
601
+ font:inherit;color:inherit}
602
+ .ht-pl .ht-mosaic{position:relative;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:1px;
603
+ aspect-ratio:16/9;border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-base)}
604
+ .ht-pl .ht-mosaic img{width:100%;height:100%;object-fit:cover;display:block}
605
+ .ht-pl .ht-count{position:absolute;right:8px;bottom:8px;padding:2px 8px;border-radius:4px;
606
+ background:rgba(11,12,14,.68);color:#f7f7f7;font-size:11.5px;font-variant-numeric:tabular-nums}
607
+ .ht-pl h3{margin:0;font-size:14px;font-weight:600}
608
+ .ht-pl p{margin:0;font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-secondary);
609
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
610
+ .ht-sechead{display:flex;align-items:baseline;gap:10px;padding:16px 24px 4px}
611
+ .ht-sechead h2{margin:0;font-size:15px;font-weight:600}
612
+ .ht-sechead p{margin:0;font-size:12.5px;color:var(--dsw-alias-label-secondary)}
613
+ .ht-skel{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr));gap:32px 20px;padding-top:16px}
614
+ .ht-skel div{display:flex;flex-direction:column;gap:8px}
615
+ .ht-skel i{display:block;background:var(--dsw-alias-bg-skeleton);border-radius:12px}
616
+ .ht-skel i.b{aspect-ratio:16/9}
617
+ .ht-skel i.l{height:12px;border-radius:6px;width:72%}
618
+ .ht-skel i.m{height:12px;border-radius:6px;width:44%}
619
+ .ht-empty{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:48px 24px}
620
+ .ht-empty b{font-size:14px;font-weight:600}
621
+ .ht-empty span{font-size:13px;color:var(--dsw-alias-label-secondary);max-width:52ch}
622
+ .ht-err{display:flex;align-items:center;gap:12px;margin:24px;padding:12px;border-radius:8px;
623
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-turn-trigger-bg);font-size:13px}
624
+ .ht-keys{position:fixed;inset:0;background:rgba(0,0,0,.24);display:grid;place-items:center;z-index:40}
625
+ .ht-keys .k{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;
626
+ padding:16px 20px;min-width:340px;box-shadow:0 18px 48px rgba(15,17,21,.18)}
627
+ .ht-keys h3{margin:0 0 10px;font-size:13.5px;font-weight:600}
628
+ .ht-keys table{border-collapse:collapse;font-size:12.5px}
629
+ .ht-keys td{padding:5px 0;color:var(--dsw-alias-label-secondary)}
630
+ .ht-keys td:first-child{width:110px;color:var(--dsw-alias-label-primary)}
631
+ .ht-keys kbd{display:inline-grid;place-items:center;min-width:20px;height:20px;padding:0 5px;border-radius:5px;
632
+ background:var(--dsw-alias-interactive-bg-hover);border:1px solid var(--dsw-alias-border-l2);font:inherit;font-size:11.5px}
633
+ .ht-row{display:flex;gap:16px;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:pointer;
634
+ background:none;border-left:0;border-right:0;border-top:0;width:100%;text-align:left;font:inherit;color:inherit}
635
+ .ht-row .ht-thumb{width:220px;flex:none;aspect-ratio:16/9}
636
+ .ht-row .ht-title{font-size:15px;line-height:21px;height:42px}
637
+ .ht-hgrid{display:grid;grid-template-columns:1fr 1fr;gap:0 28px;padding:0 24px}
638
+ .ht-hgrid .ht-row .ht-thumb{width:180px}`
639
+
640
+ /* ---------------- 图标 ---------------- */
641
+
642
+ function PlayGlyph(size) {
643
+ return h(
644
+ 'svg',
645
+ { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
646
+ h('rect', { x: 1, y: 1, width: 22, height: 22, rx: 6, fill: '#ff6600' }),
647
+ h('path', { d: 'M10 8.2v7.6l6-3.8z', fill: '#fff' }),
648
+ )
649
+ }
650
+
651
+ function PanelIcon() {
652
+ /* 图标一旦被渲染,说明 slot 注册和 UI 挂载都成了,不需要点开面板也能确认 */
653
+ if (!mem.iconPinged) {
654
+ mem.iconPinged = true
655
+ beacon('icon-rendered')
656
+ }
657
+ return h(
658
+ 'span',
659
+ { style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } },
660
+ PlayGlyph(20),
661
+ )
662
+ }
663
+
664
+ /* 操作按钮里的小图标,统一 16px 线性风格 */
665
+ function Ico({ fill = false, children }) {
666
+ const props = {
667
+ width: 16,
668
+ height: 16,
669
+ viewBox: '0 0 24 24',
670
+ 'aria-hidden': true,
671
+ fill: fill ? 'currentColor' : 'none',
672
+ stroke: fill ? 'none' : 'currentColor',
673
+ }
674
+ if (!fill) {
675
+ props.strokeWidth = 1.8
676
+ props.strokeLinecap = 'round'
677
+ props.strokeLinejoin = 'round'
678
+ }
679
+ return h('svg', props, children)
680
+ }
681
+
682
+ /* ---------------- 卡片 ---------------- */
683
+
684
+ function Card({ item, onOpen, lang }) {
685
+ return h(
686
+ 'button',
687
+ { type: 'button', className: 'ht-card', 'data-vid': item.v, onClick: () => onOpen(item) },
688
+ h(
689
+ 'div',
690
+ { className: 'ht-thumb' },
691
+ item.th ? h('img', { src: item.th, alt: '', loading: 'lazy' }) : null,
692
+ item.d ? h('span', { className: 'ht-dur' }, fmtDur(item.d)) : null,
693
+ ),
694
+ h(
695
+ 'div',
696
+ { className: 'ht-info' },
697
+ h('p', { className: 'ht-title' }, titleOf(item, lang)),
698
+ h(
699
+ 'div',
700
+ { className: 'ht-meta' },
701
+ item.c ? h('span', { className: 'ht-chan', key: 'c' }, item.c) : null,
702
+ item.r
703
+ ? h('span', { key: 'r' }, h('b', null, String(item.r)), tx(lang, ' 人推荐', item.r === 1 ? ' recommender' : ' recommenders'))
704
+ : null,
705
+ item.w ? h('span', { key: 'w' }, tx(lang, fmtCount(item.w, lang) + ' 次观看', fmtCount(item.w, lang) + ' views')) : null,
706
+ item.y ? h('span', { key: 'y' }, String(item.y)) : null,
707
+ ),
708
+ ),
709
+ )
710
+ }
711
+
712
+ /* ---------------- 分享卡片 ---------------- */
713
+
714
+ function wrapLines(g, text, maxWidth) {
715
+ const lines = []
716
+ let cur = ''
717
+ for (const ch of String(text)) {
718
+ const next = cur + ch
719
+ if (g.measureText(next).width > maxWidth && cur) {
720
+ lines.push(cur)
721
+ cur = ch
722
+ } else {
723
+ cur = next
724
+ }
725
+ }
726
+ if (cur) lines.push(cur)
727
+ return lines
728
+ }
729
+
730
+ async function makeShareCard(item, detail, quoteText) {
731
+ const W = 1200
732
+ const H = 675
733
+ const c = document.createElement('canvas')
734
+ c.width = W
735
+ c.height = H
736
+ const g = c.getContext('2d')
737
+ if (!g) return
738
+
739
+ g.fillStyle = '#0d1117'
740
+ g.fillRect(0, 0, W, H)
741
+
742
+ const img = new Image()
743
+ img.src = '/hackertube/api/thumb?vid=' + encodeURIComponent(item.v)
744
+ await new Promise((res) => {
745
+ img.onload = res
746
+ img.onerror = res
747
+ })
748
+ if (img.naturalWidth) {
749
+ const ih = Math.round((img.naturalHeight / img.naturalWidth) * W)
750
+ g.drawImage(img, 0, 0, W, ih)
751
+ }
752
+
753
+ const grad = g.createLinearGradient(0, 180, 0, 540)
754
+ grad.addColorStop(0, 'rgba(13,17,23,0)')
755
+ grad.addColorStop(0.65, 'rgba(13,17,23,0.94)')
756
+ grad.addColorStop(1, '#0d1117')
757
+ g.fillStyle = grad
758
+ g.fillRect(0, 180, W, 360)
759
+
760
+ const family = 'system-ui, -apple-system, "Segoe UI", sans-serif'
761
+
762
+ g.fillStyle = '#e6edf3'
763
+ g.font = 'bold 40px ' + family
764
+ const titleLines = wrapLines(g, item.t || '(untitled)', W - 128).slice(0, 2)
765
+ titleLines.forEach((line, i) => g.fillText(line, 64, 470 + i * 50))
766
+
767
+ const quoteY = 470 + titleLines.length * 50 + 24
768
+ if (quoteText) {
769
+ g.fillStyle = '#8b949e'
770
+ g.font = '24px ' + family
771
+ const qLines = wrapLines(g, '\u201c' + quoteText + '\u201d', W - 128).slice(0, 2)
772
+ qLines.forEach((line, i) => g.fillText(line, 64, quoteY + i * 34))
773
+ }
774
+
775
+ g.fillStyle = '#ff6600'
776
+ g.font = 'bold 24px ' + family
777
+ g.fillText('HackerTube', 64, H - 40)
778
+ g.fillStyle = '#8b949e'
779
+ g.font = '20px ' + family
780
+ const meta = [item.c, item.d ? Math.round(item.d / 60) + ' min' : null]
781
+ .filter(Boolean)
782
+ .join(' · ')
783
+ /* 右对齐到底部同一基线,避免依赖品牌文字的实测宽度 */
784
+ if (meta) {
785
+ const w = g.measureText(meta).width
786
+ g.fillText(meta, W - 64 - w, H - 40)
787
+ }
788
+
789
+ const blob = await new Promise((res) => c.toBlob(res, 'image/png'))
790
+ if (!blob) return
791
+ const a = document.createElement('a')
792
+ a.href = URL.createObjectURL(blob)
793
+ a.download = 'hackertube-' + item.v + '.png'
794
+ document.body.appendChild(a)
795
+ a.click()
796
+ document.body.removeChild(a)
797
+ setTimeout(() => URL.revokeObjectURL(a.href), 4000)
798
+ }
799
+
800
+ /* ---------------- 播放页 ---------------- */
801
+
802
+ /* ---------------- Markdown ---------------- */
803
+
804
+ /** 行内:`代码`、**粗体**、*斜体*、[链接](url)。流式时允许不完整,遇到未闭合的标记当普通文本。 */
805
+ function mdInline(text) {
806
+ const out = []
807
+ const s2 = String(text == null ? '' : text)
808
+ let buf = ''
809
+ let i = 0
810
+ let key = 0
811
+ const flush = () => {
812
+ if (buf) {
813
+ out.push(buf)
814
+ buf = ''
815
+ }
816
+ }
817
+ while (i < s2.length) {
818
+ const c = s2[i]
819
+ if (c === '`') {
820
+ const j = s2.indexOf('`', i + 1)
821
+ if (j > i) {
822
+ flush()
823
+ out.push(h('code', { key: 'c' + key++, className: 'ht-md-code' }, s2.slice(i + 1, j)))
824
+ i = j + 1
825
+ continue
826
+ }
827
+ }
828
+ if (c === '*' && s2[i + 1] === '*') {
829
+ const j = s2.indexOf('**', i + 2)
830
+ if (j > i) {
831
+ flush()
832
+ out.push(h('strong', { key: 'b' + key++ }, mdInline(s2.slice(i + 2, j))))
833
+ i = j + 2
834
+ continue
835
+ }
836
+ }
837
+ if (c === '*') {
838
+ const j = s2.indexOf('*', i + 1)
839
+ if (j > i + 1) {
840
+ flush()
841
+ out.push(h('em', { key: 'i' + key++ }, mdInline(s2.slice(i + 1, j))))
842
+ i = j + 1
843
+ continue
844
+ }
845
+ }
846
+ if (c === '[') {
847
+ const close = s2.indexOf(']', i)
848
+ const open = s2.indexOf('(', close + 1)
849
+ const end = s2.indexOf(')', open + 1)
850
+ if (close > i && open === close + 1 && end > open) {
851
+ flush()
852
+ out.push(
853
+ h(
854
+ 'a',
855
+ { key: 'a' + key++, href: s2.slice(open + 1, end), target: '_blank', rel: 'noreferrer noopener' },
856
+ s2.slice(i + 1, close),
857
+ ),
858
+ )
859
+ i = end + 1
860
+ continue
861
+ }
862
+ }
863
+ buf += c
864
+ i += 1
865
+ }
866
+ flush()
867
+ return out
868
+ }
869
+
870
+ /** 块级:标题、列表、代码块、引用、段落。未闭合的代码块按到结尾处理,保证流式时不崩。 */
871
+ function Markdown({ text }) {
872
+ const lines = String(text == null ? '' : text).split(/\r?\n/)
873
+ const blocks = []
874
+ let i = 0
875
+ let key = 0
876
+ const isItem = (line) => /^\s*([-*+]|\d+[.)])\s+/.test(line)
877
+ while (i < lines.length) {
878
+ const line = lines[i]
879
+ if (!line.trim()) {
880
+ i += 1
881
+ continue
882
+ }
883
+ if (/^\s*```/.test(line)) {
884
+ const code = []
885
+ i += 1
886
+ while (i < lines.length && !/^\s*```/.test(lines[i])) {
887
+ code.push(lines[i])
888
+ i += 1
889
+ }
890
+ i += 1
891
+ blocks.push(h('pre', { key: 'pre' + key++, className: 'ht-md-pre' }, h('code', null, code.join('\n'))))
892
+ continue
893
+ }
894
+ const head = /^(#{1,4})\s+(.*)$/.exec(line)
895
+ if (head) {
896
+ blocks.push(h('h4', { key: 'h' + key++, className: 'ht-md-h' }, mdInline(head[2])))
897
+ i += 1
898
+ continue
899
+ }
900
+ if (/^>\s?/.test(line)) {
901
+ const quoted = []
902
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
903
+ quoted.push(lines[i].replace(/^>\s?/, ''))
904
+ i += 1
905
+ }
906
+ blocks.push(h('blockquote', { key: 'q' + key++, className: 'ht-md-q' }, mdInline(quoted.join(' '))))
907
+ continue
908
+ }
909
+ if (isItem(line)) {
910
+ const ordered = /^\s*\d+[.)]\s+/.test(line)
911
+ const startNo = ordered ? parseInt(line.trim(), 10) : 1
912
+ const items = []
913
+ let cur = null
914
+ while (i < lines.length) {
915
+ const m2 = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i])
916
+ if (!m2) {
917
+ /* 列表项之间常空一行;只要下一段还是列表就接着读 */
918
+ let look = i
919
+ while (look < lines.length && !lines[look].trim()) look += 1
920
+ if (look < lines.length && look > i && /^(\s*)([-*+]|\d+[.)])\s+/.test(lines[look])) {
921
+ i = look
922
+ continue
923
+ }
924
+ break
925
+ }
926
+ const indent = m2[1].length
927
+ const text = m2[3]
928
+ /* 缩进两格以上算子项,挂到上一条目下面 */
929
+ if (indent >= 2 && cur) {
930
+ if (!cur.sub) cur.sub = []
931
+ cur.sub.push(h('li', { key: 'sub' + key++ }, mdInline(text)))
932
+ } else {
933
+ cur = { text, sub: null }
934
+ items.push(cur)
935
+ }
936
+ i += 1
937
+ }
938
+ blocks.push(
939
+ h(
940
+ ordered ? 'ol' : 'ul',
941
+ ordered
942
+ ? { key: 'l' + key++, className: 'ht-md-list', start: startNo }
943
+ : { key: 'l' + key++, className: 'ht-md-list' },
944
+ items.map((it) =>
945
+ h(
946
+ 'li',
947
+ { key: 'li' + key++ },
948
+ mdInline(it.text),
949
+ it.sub ? h('ul', { key: 'sub' + key++, className: 'ht-md-list' }, it.sub) : null,
950
+ ),
951
+ ),
952
+ ),
953
+ )
954
+ continue
955
+ }
956
+ const para = []
957
+ while (i < lines.length && lines[i].trim() && !/^(#{1,4}\s|>|```)/.test(lines[i]) && !isItem(lines[i])) {
958
+ para.push(lines[i])
959
+ i += 1
960
+ }
961
+ if (para.length) blocks.push(h('p', { key: 'p' + key++, className: 'ht-md-p' }, mdInline(para.join(' '))))
962
+ else i += 1
963
+ }
964
+ return h('div', { className: 'ht-md' }, blocks)
965
+ }
966
+
967
+ /**
968
+ * 讨论抽屉:AI 对话与 HN 评论共用一块侧栏,靠顶部 tab 切换。
969
+ * 评论不再挤在正文下面,视频始终可见。
970
+ */
971
+ /**
972
+ * 右栏:相关推荐与 AI 问答共用一栏,分段控件换页,各自独立滚动。
973
+ * 默认停在相关推荐,看视频的人不需要先关掉提问框。
974
+ */
975
+ function SideRail({ vid, lang = 'zh', related, pane, onPane, onOpen }) {
976
+ const [messages, setMessages] = useState([])
977
+ const [input, setInput] = useState('')
978
+ const [busy, setBusy] = useState(false)
979
+
980
+ const send = () => {
981
+ const q = input.trim()
982
+ if (!q || busy) return
983
+ setInput('')
984
+ setMessages((m) => m.concat([{ role: 'user', text: q }, { role: 'ai', text: '', pending: true }]))
985
+ setBusy(true)
986
+ const at = mem.playhead && Date.now() - mem.playhead.at < 15000 ? mem.playhead.t : 0
987
+
988
+ const append = (piece) =>
989
+ setMessages((m) => {
990
+ const next = m.slice()
991
+ for (let i = next.length - 1; i >= 0; i -= 1) {
992
+ if (next[i].role === 'ai') {
993
+ next[i] = { role: 'ai', text: (next[i].text || '') + piece, pending: true }
994
+ break
995
+ }
996
+ }
997
+ return next
998
+ })
999
+ const settle = (finalText) =>
1000
+ setMessages((m) => {
1001
+ const next = m.slice()
1002
+ for (let i = next.length - 1; i >= 0; i -= 1) {
1003
+ if (next[i].role === 'ai') {
1004
+ next[i] = { role: 'ai', text: finalText != null ? finalText : next[i].text || '', pending: false }
1005
+ break
1006
+ }
1007
+ }
1008
+ return next
1009
+ })
1010
+
1011
+ const run = async () => {
1012
+ let res
1013
+ try {
1014
+ res = await fetch('/hackertube/api/chat', {
1015
+ method: 'POST',
1016
+ headers: { 'Content-Type': 'application/json' },
1017
+ body: JSON.stringify({ vid, question: q, at, stream: true, lang }),
1018
+ })
1019
+ } catch (e) {
1020
+ settle(tx(lang, '请求失败:', 'Request failed: ') + String((e && e.message) || e))
1021
+ return
1022
+ }
1023
+ const type = res.headers.get('content-type') || ''
1024
+ if (!res.ok || !res.body || type.indexOf('text/event-stream') < 0) {
1025
+ const d = await res.json().catch(() => null)
1026
+ settle(
1027
+ d && d.answer
1028
+ ? d.answer
1029
+ : tx(lang, '(' + ((d && d.error) || '没有拿到回答') + ')', '(' + ((d && d.error) || 'no answer') + ')'),
1030
+ )
1031
+ return
1032
+ }
1033
+ const reader = res.body.getReader()
1034
+ const decoder = new TextDecoder()
1035
+ let buffer = ''
1036
+ let acc = ''
1037
+ for (;;) {
1038
+ const { done, value } = await reader.read()
1039
+ if (done) break
1040
+ buffer += decoder.decode(value, { stream: true })
1041
+ let cut = buffer.indexOf('\n\n')
1042
+ while (cut >= 0) {
1043
+ const raw = buffer.slice(0, cut).trim()
1044
+ buffer = buffer.slice(cut + 2)
1045
+ if (raw.indexOf('data:') === 0) {
1046
+ const payload = raw.slice(5).trim()
1047
+ if (payload === '[DONE]') continue
1048
+ try {
1049
+ const evt = JSON.parse(payload)
1050
+ if (evt.delta) {
1051
+ acc += evt.delta
1052
+ append(evt.delta)
1053
+ } else if (evt.error) {
1054
+ acc = tx(lang, '请求失败:', 'Request failed: ') + evt.error
1055
+ } else if (evt.done && typeof evt.answer === 'string') {
1056
+ acc = evt.answer
1057
+ }
1058
+ } catch {
1059
+ /* 半截 JSON,忽略 */
1060
+ }
1061
+ }
1062
+ cut = buffer.indexOf('\n\n')
1063
+ }
1064
+ }
1065
+ settle(acc)
1066
+ }
1067
+
1068
+ run()
1069
+ .catch((e) => settle(tx(lang, '请求失败:', 'Request failed: ') + String((e && e.message) || e)))
1070
+ .finally(() => setBusy(false))
1071
+ }
1072
+
1073
+ const tabBtn = (id, label, count) =>
1074
+ h(
1075
+ 'button',
1076
+ {
1077
+ key: id,
1078
+ type: 'button',
1079
+ className: 'ht-rail-tab',
1080
+ 'data-pane': id,
1081
+ 'aria-selected': String(pane === id),
1082
+ onClick: () => onPane(id),
1083
+ },
1084
+ label,
1085
+ count != null ? h('span', { className: 'ht-rail-badge' }, String(count)) : null,
1086
+ )
1087
+
1088
+ const relPane = h(
1089
+ 'section',
1090
+ { key: 'rel', className: 'ht-rail-pane' },
1091
+ related && related.length
1092
+ ? related.map((r) =>
1093
+ h(
1094
+ 'button',
1095
+ { key: r.v, type: 'button', className: 'ht-rel', onClick: () => onOpen(r) },
1096
+ h(
1097
+ 'div',
1098
+ { className: 'ht-rel-thumb' },
1099
+ r.th ? h('img', { src: r.th, alt: '', loading: 'lazy' }) : null,
1100
+ r.d ? h('span', { className: 'ht-rel-dur' }, fmtDur(r.d)) : null,
1101
+ ),
1102
+ h(
1103
+ 'div',
1104
+ { className: 'ht-rel-body' },
1105
+ h('p', { className: 'ht-rel-title' }, titleOf(r, lang)),
1106
+ h(
1107
+ 'p',
1108
+ { className: 'ht-rel-meta' },
1109
+ r.c || '',
1110
+ r.c ? h('br', null) : null,
1111
+ tx(lang, fmtCount(r.w || 0, lang) + ' 次观看', fmtCount(r.w || 0, lang) + ' views'),
1112
+ r.p ? tx(lang, ' · ' + fmtAgo(r.p, lang), ' · ' + fmtAgo(r.p, lang)) : null,
1113
+ ),
1114
+ ),
1115
+ ),
1116
+ )
1117
+ : h(
1118
+ 'div',
1119
+ { className: 'ht-rail-empty' },
1120
+ tx(lang, '正在取相关推荐…', 'Loading related videos…'),
1121
+ ),
1122
+ )
1123
+
1124
+ const aiPane = h(
1125
+ 'section',
1126
+ { key: 'ai', className: 'ht-rail-pane ht-rail-ai' },
1127
+ h(
1128
+ 'div',
1129
+ { className: 'ht-rail-log' },
1130
+ messages.length === 0
1131
+ ? h(
1132
+ 'div',
1133
+ null,
1134
+ h(
1135
+ 'p',
1136
+ { className: 'ht-rail-hint' },
1137
+ tx(
1138
+ lang,
1139
+ '模型会读当前播放位置附近的字幕,所以「这段在讲什么」这类问题最准。',
1140
+ 'The model reads the subtitles around your playhead, so "what is this part about" works best.',
1141
+ ),
1142
+ ),
1143
+ h(
1144
+ 'div',
1145
+ { className: 'ht-rail-suggest' },
1146
+ (lang === 'zh'
1147
+ ? ['这段在讲什么?', '这里的关键概念是什么?', '他为什么这么说?']
1148
+ : ['What is this part about?', "What's the key idea here?", 'Why does he say that?']
1149
+ ).map((q) => h('button', { key: q, type: 'button', onClick: () => setInput(q) }, q)),
1150
+ ),
1151
+ )
1152
+ : messages.map((m, i) =>
1153
+ h(
1154
+ 'div',
1155
+ { key: i, className: 'ht-msg ht-msg-' + m.role + (m.pending ? ' ht-msg-streaming' : '') },
1156
+ m.role === 'ai' ? h(Markdown, { text: m.text }) : m.text,
1157
+ m.pending ? h('span', { className: 'ht-caret', 'aria-hidden': 'true' }) : null,
1158
+ ),
1159
+ ),
1160
+ ),
1161
+ h(
1162
+ 'form',
1163
+ {
1164
+ className: 'ht-rail-form',
1165
+ onSubmit: (e) => {
1166
+ e.preventDefault()
1167
+ send()
1168
+ },
1169
+ },
1170
+ h('input', {
1171
+ value: input,
1172
+ placeholder: busy
1173
+ ? tx(lang, '思考中…', 'Thinking…')
1174
+ : tx(lang, '就当前进度提问', 'Ask about this moment'),
1175
+ onChange: (e) => setInput(e.target.value),
1176
+ disabled: busy,
1177
+ }),
1178
+ h('button', { type: 'submit', disabled: busy || !input.trim() }, busy ? '…' : tx(lang, '发送', 'Send')),
1179
+ ),
1180
+ )
1181
+
1182
+ return h(
1183
+ 'aside',
1184
+ { className: 'ht-rail', 'data-pane': pane },
1185
+ h(
1186
+ 'div',
1187
+ { className: 'ht-rail-head', role: 'tablist' },
1188
+ h('span', { className: 'ht-rail-ind', 'aria-hidden': 'true' }),
1189
+ tabBtn('rel', tx(lang, '相关推荐', 'Related'), null),
1190
+ tabBtn('ai', tx(lang, 'AI 问答', 'Ask AI'), null),
1191
+ ),
1192
+ h('div', { className: 'ht-rail-viewport' }, h('div', { className: 'ht-rail-track' }, relPane, aiPane)),
1193
+ )
1194
+ }
1195
+
1196
+ function WatchView({ item, onBack, lang, liked, savedLater, onToggle, onOpenItem }) {
1197
+ const [detail, setDetail] = useState(null)
1198
+ const [err, setErr] = useState('')
1199
+ const [origin, setOrigin] = useState(mem.mediaOrigin || null)
1200
+ const [related, setRelated] = useState(null)
1201
+ const [railPane, setRailPane] = useState('rel')
1202
+ const [descOpen, setDescOpen] = useState(false)
1203
+ const [descFit, setDescFit] = useState(true)
1204
+ const descRef = useRef(null)
1205
+ const [ctr, setCtr] = useState({})
1206
+ const [ctrOn, setCtrOn] = useState({})
1207
+ const [ctrBusy, setCtrBusy] = useState({})
1208
+
1209
+ useEffect(() => {
1210
+ let alive = true
1211
+ loadMediaOrigin().then((o) => {
1212
+ if (!alive) return
1213
+ setOrigin(o)
1214
+ beacon('watch-open', {
1215
+ vid: item.v,
1216
+ mediaOrigin: o || null,
1217
+ finalSrc: o ? o + '/embed?vid=' + encodeURIComponent(item.v) : null,
1218
+ })
1219
+ })
1220
+ return () => {
1221
+ alive = false
1222
+ }
1223
+ }, [item.v])
1224
+
1225
+ useEffect(() => {
1226
+ let alive = true
1227
+ setDetail(null)
1228
+ setErr('')
1229
+ setDescOpen(false)
1230
+ setCtr({})
1231
+ setCtrOn({})
1232
+ setCtrBusy({})
1233
+ loadDetail(item.v, lang)
1234
+ .then((d) => {
1235
+ if (alive) setDetail(d)
1236
+ })
1237
+ .catch((e) => {
1238
+ if (alive) setErr(String((e && e.message) || e))
1239
+ })
1240
+ return () => {
1241
+ alive = false
1242
+ }
1243
+ }, [item.v])
1244
+
1245
+ useEffect(() => {
1246
+ let alive = true
1247
+ setRelated(null)
1248
+ getJSON('/hackertube/api/related?vid=' + encodeURIComponent(item.v))
1249
+ .then((d) => {
1250
+ if (alive) setRelated(Array.isArray(d) ? d : [])
1251
+ })
1252
+ .catch(() => {
1253
+ if (alive) setRelated([])
1254
+ })
1255
+ return () => {
1256
+ alive = false
1257
+ }
1258
+ }, [item.v])
1259
+
1260
+ const hn = detail && detail.hn ? detail.hn : null
1261
+ const threadId = detail && detail.quote && detail.quote.id ? detail.quote.id : null
1262
+ /* 中文模式下优先用 Host 返回的译文,没有则退回原文 */
1263
+ const tr = (detail && detail.i18n) || {}
1264
+ const quoteText =
1265
+ (lang === 'zh' ? tr.quote : null) || (detail && detail.quote && detail.quote.text ? detail.quote.text : '')
1266
+ const whyHnText = (lang === 'zh' ? tr.why_hn : null) || (detail && detail.why_hn) || ''
1267
+ const summaryText = (lang === 'zh' && tr.summary) || (detail && detail.summary) || ''
1268
+ const comments = detail && Array.isArray(detail.comments) ? detail.comments : []
1269
+ const tags = detail && Array.isArray(detail.tags) ? detail.tags : []
1270
+ const ctLabel = item.ct ? tx(lang, CT_ZH[item.ct] || item.ct, item.ct) : ''
1271
+ const lvLabel = item.lv ? tx(lang, LV_ZH[item.lv] || item.lv, item.lv) : ''
1272
+ /* 简介卡内容不空才渲染,避免空灰条 */
1273
+ const hasDesc = !!(summaryText || whyHnText || quoteText || tags.length || (hn && hn.strong))
1274
+ const showMore = !descFit || descOpen
1275
+
1276
+ useEffect(() => {
1277
+ if (descOpen) return
1278
+ const el = descRef.current
1279
+ if (!el) return
1280
+ setDescFit(el.scrollHeight <= el.clientHeight + 2)
1281
+ }, [detail, lang, descOpen, item.v])
1282
+
1283
+ /* 评论翻译:Host 按评论 id 缓存,已有的直接切显示,没有才发请求 */
1284
+ const runCommentTr = (ids) => {
1285
+ const targets = ids || comments.map((c) => String(c.id))
1286
+ if (!targets.length) return
1287
+ const busy = {}
1288
+ for (const id of targets) busy[id] = true
1289
+ setCtrBusy(busy)
1290
+ const payload = { vid: item.v }
1291
+ if (ids) payload.ids = targets
1292
+ fetch('/hackertube/api/comments/translate', {
1293
+ method: 'POST',
1294
+ headers: { 'content-type': 'application/json' },
1295
+ body: JSON.stringify(payload),
1296
+ })
1297
+ .then((r) => r.json())
1298
+ .then((d) => {
1299
+ if (!d || !d.entries) return
1300
+ setCtr((prev) => Object.assign({}, prev, d.entries))
1301
+ const on = {}
1302
+ for (const id of Object.keys(d.entries)) on[id] = true
1303
+ setCtrOn((prev) => Object.assign({}, prev, on))
1304
+ })
1305
+ .catch(() => {})
1306
+ .then(() => setCtrBusy({}))
1307
+ }
1308
+ const toggleCommentTr = (id) => {
1309
+ const key = String(id)
1310
+ if (ctr[key]) {
1311
+ setCtrOn((prev) => Object.assign({}, prev, { [key]: !prev[key] }))
1312
+ return
1313
+ }
1314
+ runCommentTr([key])
1315
+ }
1316
+
1317
+ return h(
1318
+ 'div',
1319
+ { className: 'ht-watch' },
1320
+ h(
1321
+ 'div',
1322
+ { className: 'ht-toolbar' },
1323
+ h('button', { type: 'button', className: 'ht-back', onClick: onBack }, tx(lang, '← 返回列表', '← Back to list')),
1324
+ ),
1325
+ h(
1326
+ 'div',
1327
+ { className: 'ht-watch-inner' },
1328
+ h(
1329
+ 'div',
1330
+ { className: 'ht-primary' },
1331
+ h(
1332
+ 'div',
1333
+ { className: 'ht-player' },
1334
+ /* 中转页地址是异步拿到的;未就绪前不渲染 iframe,避免先请求一个必然 404 的相对路径 */
1335
+ origin
1336
+ ? h('iframe', {
1337
+ className: 'ht-video',
1338
+ title: item.t,
1339
+ src: origin + '/embed?vid=' + encodeURIComponent(item.v) + '&lang=' + (lang === 'zh' ? 'zh' : 'en'),
1340
+ allow:
1341
+ 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
1342
+ allowFullScreen: true,
1343
+ onLoad: () => beacon('iframe-loaded', { vid: item.v }),
1344
+ })
1345
+ : h('div', { className: 'ht-video ht-video-wait' }, tx(lang, '正在准备播放器…', 'Preparing player…')),
1346
+ ),
1347
+ h('h1', { className: 'ht-h1' }, titleOf(item, lang)),
1348
+ item.c ? h('p', { className: 'ht-channel' }, item.c) : null,
1349
+ h(
1350
+ 'div',
1351
+ { className: 'ht-meta' },
1352
+ item.w
1353
+ ? h('span', { key: 'w' }, tx(lang, fmtCount(item.w, lang) + ' 次观看', fmtCount(item.w, lang) + ' views'))
1354
+ : null,
1355
+ item.d ? h('span', { key: 'd' }, fmtDur(item.d)) : null,
1356
+ item.p ? h('span', { key: 'p' }, fmtAgo(item.p, lang)) : null,
1357
+ hn
1358
+ ? h(
1359
+ 'span',
1360
+ { key: 'hn', className: 'ht-hn-mark' },
1361
+ h('i', { 'aria-hidden': 'true' }),
1362
+ tx(
1363
+ lang,
1364
+ hn.mentions + ' 次被提及 · ' + (hn.recommenders || 0) + ' 人推荐' +
1365
+ (hn.first ? ' · ' + (hn.first === hn.last ? hn.first : hn.first + '–' + hn.last) : ''),
1366
+ hn.mentions + ' mentions · ' + (hn.recommenders || 0) + ' recommenders' +
1367
+ (hn.first ? ' · ' + (hn.first === hn.last ? hn.first : hn.first + '–' + hn.last) : ''),
1368
+ ),
1369
+ )
1370
+ : null,
1371
+ ),
1372
+ h(
1373
+ 'div',
1374
+ { className: 'ht-actions' },
1375
+ h(
1376
+ 'button',
1377
+ {
1378
+ type: 'button',
1379
+ className: 'ht-pill' + (liked ? ' on' : ''),
1380
+ onClick: () => onToggle('likes', item.v),
1381
+ },
1382
+ h(Ico, null, h('path', { d: 'M7 10v10H4V10zM7 20h9.2a2 2 0 0 0 2-1.6l1.2-6A2 2 0 0 0 17.4 10H13V6.5A2.5 2.5 0 0 0 10.5 4L7 10' })),
1383
+ liked ? tx(lang, '已喜欢', 'Liked') : tx(lang, '喜欢', 'Like'),
1384
+ ),
1385
+ h(
1386
+ 'button',
1387
+ {
1388
+ type: 'button',
1389
+ className: 'ht-pill' + (savedLater ? ' on' : ''),
1390
+ onClick: () => onToggle('later', item.v),
1391
+ },
1392
+ h(Ico, null, h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M12 7v5l3.2 2' })),
1393
+ savedLater ? tx(lang, '已加入稍后看', 'Saved') : tx(lang, '稍后看', 'Watch later'),
1394
+ ),
1395
+ h(
1396
+ 'button',
1397
+ {
1398
+ type: 'button',
1399
+ className: 'ht-pill',
1400
+ onClick: () => {
1401
+ void makeShareCard(item, detail, quoteText)
1402
+ },
1403
+ },
1404
+ h(Ico, null, h('path', { d: 'M12 16V4M8 8l4-4 4 4M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3' })),
1405
+ tx(lang, '分享卡片', 'Share card'),
1406
+ ),
1407
+ h(
1408
+ 'a',
1409
+ {
1410
+ className: 'ht-pill ht-pill-ghost',
1411
+ href: 'https://www.youtube.com/watch?v=' + item.v,
1412
+ target: '_blank',
1413
+ rel: 'noreferrer noopener',
1414
+ },
1415
+ h(Ico, { fill: true }, h('path', { d: 'M23 12s-4.2-6.5-11-6.5S1 12 1 12s4.2 6.5 11 6.5S23 12 23 12zm-11 4a4 4 0 1 1 0-8 4 4 0 0 1 0 8z' })),
1416
+ tx(lang, '在 YouTube 观看', 'Watch on YouTube'),
1417
+ ),
1418
+ ),
1419
+ err
1420
+ ? h('p', { className: 'ht-note' }, tx(lang, '详情加载失败:', 'Failed to load details: ') + err)
1421
+ : null,
1422
+ !detail && !err
1423
+ ? h('p', { className: 'ht-note' }, tx(lang, '正在读取 HN 讨论记录…', 'Reading the HN threads…'))
1424
+ : null,
1425
+ hasDesc
1426
+ ? h(
1427
+ 'section',
1428
+ { className: 'ht-desc' + (descOpen ? '' : ' clamped') },
1429
+ ctLabel || lvLabel
1430
+ ? h(
1431
+ 'div',
1432
+ { className: 'ht-desc-head' },
1433
+ ctLabel ? h('span', { key: 'ct' }, ctLabel) : null,
1434
+ ctLabel && lvLabel ? h('span', { key: 'dot' }, '·') : null,
1435
+ lvLabel ? h('span', { key: 'lv' }, lvLabel) : null,
1436
+ )
1437
+ : null,
1438
+ h(
1439
+ 'div',
1440
+ { className: 'ht-desc-body', ref: descRef },
1441
+ summaryText ? h('p', { className: 'ht-desc-p', key: 's' }, summaryText) : null,
1442
+ whyHnText ? h('p', { className: 'ht-desc-p ht-desc-why', key: 'w' }, whyHnText) : null,
1443
+ quoteText
1444
+ ? h(
1445
+ 'p',
1446
+ { className: 'ht-desc-quote', key: 'q' },
1447
+ tx(lang, '「' + quoteText + '」', '“' + quoteText + '”'),
1448
+ )
1449
+ : null,
1450
+ hn || tags.length
1451
+ ? h(
1452
+ 'div',
1453
+ { className: 'ht-desc-tags', key: 't' },
1454
+ hn && hn.strong
1455
+ ? h(
1456
+ 'span',
1457
+ { className: 'ht-stat ht-stat-strong', key: 'strong' },
1458
+ h('b', null, String(hn.strong)),
1459
+ tx(lang, ' 次强烈推荐', hn.strong === 1 ? ' strong rec' : ' strong recs'),
1460
+ )
1461
+ : null,
1462
+ tags.map((t) => h('span', { className: 'ht-tag', key: t }, t)),
1463
+ )
1464
+ : null,
1465
+ ),
1466
+ showMore
1467
+ ? h(
1468
+ 'button',
1469
+ { type: 'button', className: 'ht-desc-more', onClick: () => setDescOpen((v) => !v) },
1470
+ descOpen ? tx(lang, '收起', 'Show less') : tx(lang, '展开', 'Show more'),
1471
+ )
1472
+ : null,
1473
+ )
1474
+ : null,
1475
+ detail
1476
+ ? h(
1477
+ 'section',
1478
+ { className: 'ht-comments' },
1479
+ h(
1480
+ 'div',
1481
+ { className: 'ht-comments-head' },
1482
+ h(
1483
+ 'h2',
1484
+ null,
1485
+ tx(lang, 'HN 上的讨论', 'On Hacker News'),
1486
+ h('span', null, String(comments.length)),
1487
+ ),
1488
+ threadId || comments.length
1489
+ ? h(
1490
+ 'div',
1491
+ { className: 'ht-comments-acts' },
1492
+ comments.length && lang === 'zh'
1493
+ ? h(
1494
+ 'button',
1495
+ { type: 'button', className: 'ht-link', onClick: () => runCommentTr(null) },
1496
+ tx(lang, '翻译全部', 'Translate all'),
1497
+ )
1498
+ : null,
1499
+ threadId
1500
+ ? h(
1501
+ 'a',
1502
+ {
1503
+ className: 'ht-link',
1504
+ href: 'https://news.ycombinator.com/item?id=' + threadId,
1505
+ target: '_blank',
1506
+ rel: 'noreferrer noopener',
1507
+ },
1508
+ tx(lang, '查看原帖 →', 'View thread →'),
1509
+ )
1510
+ : null,
1511
+ )
1512
+ : null,
1513
+ ),
1514
+ h(
1515
+ 'p',
1516
+ { className: 'ht-comments-sub' },
1517
+ tx(lang, '评论按 HN 上的推荐力度排序,保留原文;可逐条机翻。', 'Sorted by how strongly HN recommends it, in the original language.'),
1518
+ ),
1519
+ comments.length
1520
+ ? comments.map((c) =>
1521
+ h(
1522
+ 'article',
1523
+ { className: 'ht-comment', key: c.id },
1524
+ h(
1525
+ 'div',
1526
+ { className: 'ht-comment-head' },
1527
+ h('b', null, c.author || 'anon'),
1528
+ c.ym ? h('span', null, c.ym) : null,
1529
+ c.stance
1530
+ ? h(
1531
+ 'span',
1532
+ { className: 'ht-stance ht-stance-' + c.stance },
1533
+ tx(
1534
+ lang,
1535
+ { strong_rec: '强烈推荐', rec: '推荐', reference: '引用' }[c.stance] || c.stance,
1536
+ c.stance.replace('_', ' '),
1537
+ ),
1538
+ )
1539
+ : null,
1540
+ c.text && lang === 'zh'
1541
+ ? h(
1542
+ 'button',
1543
+ {
1544
+ type: 'button',
1545
+ className: 'ht-comment-tr',
1546
+ onClick: () => toggleCommentTr(c.id),
1547
+ disabled: !!ctrBusy[c.id],
1548
+ },
1549
+ ctrBusy[c.id]
1550
+ ? tx(lang, '翻译中…', 'Translating…')
1551
+ : ctrOn[c.id]
1552
+ ? tx(lang, '原文', 'Original')
1553
+ : tx(lang, '翻译', 'Translate'),
1554
+ )
1555
+ : null,
1556
+ ),
1557
+ c.text
1558
+ ? h('p', { className: 'ht-comment-text' }, ctrOn[c.id] && ctr[c.id] ? ctr[c.id] : c.text)
1559
+ : null,
1560
+ h(
1561
+ 'a',
1562
+ {
1563
+ className: 'ht-comment-link',
1564
+ href: 'https://news.ycombinator.com/item?id=' + c.id,
1565
+ target: '_blank',
1566
+ rel: 'noreferrer noopener',
1567
+ },
1568
+ tx(lang, '看原帖', 'Open thread'),
1569
+ ),
1570
+ ),
1571
+ )
1572
+ : h(
1573
+ 'p',
1574
+ { className: 'ht-note' },
1575
+ tx(lang, 'Hacker News 上没有找到可展示的评论。', 'No comments to show from Hacker News.'),
1576
+ ),
1577
+ )
1578
+ : null,
1579
+ ),
1580
+ h(SideRail, {
1581
+ key: 'rail',
1582
+ vid: item.v,
1583
+ lang,
1584
+ related,
1585
+ pane: railPane,
1586
+ onPane: setRailPane,
1587
+ onOpen: onOpenItem,
1588
+ }),
1589
+ ),
1590
+ )
1591
+ }
1592
+
1593
+ /* ---------------- 主面板 ---------------- */
1594
+
1595
+
1596
+ /* ---------------- 本地集合 ---------------- */
1597
+
1598
+ function readArr(key) {
1599
+ try {
1600
+ const v = JSON.parse(localStorage.getItem(key))
1601
+ return Array.isArray(v) ? v : []
1602
+ } catch {
1603
+ return []
1604
+ }
1605
+ }
1606
+ function writeArr(key, arr) {
1607
+ try {
1608
+ localStorage.setItem(key, JSON.stringify(arr))
1609
+ } catch {
1610
+ /* 私密模式下写不进去,不影响本次会话 */
1611
+ }
1612
+ }
1613
+
1614
+ function useSaved() {
1615
+ const [saved, setSaved] = useState(() => ({
1616
+ likes: readArr('ht.likes'),
1617
+ later: readArr('ht.later'),
1618
+ seen: readArr('ht.seen'),
1619
+ }))
1620
+ const saveTimer = useRef(null)
1621
+ /* dsh-app:// 下的 localStorage 不保证可用或跨会话,真正的存档放在 host 端 */
1622
+ useEffect(() => {
1623
+ let alive = true
1624
+ getJSON('/hackertube/api/prefs')
1625
+ .then((d) => {
1626
+ if (!alive || !d) return
1627
+ setSaved({
1628
+ likes: Array.isArray(d.likes) ? d.likes : [],
1629
+ later: Array.isArray(d.later) ? d.later : [],
1630
+ seen: Array.isArray(d.seen) ? d.seen : [],
1631
+ })
1632
+ })
1633
+ .catch(() => {})
1634
+ return () => {
1635
+ alive = false
1636
+ }
1637
+ }, [])
1638
+ const update = (next) => {
1639
+ setSaved(next)
1640
+ writeArr('ht.likes', next.likes)
1641
+ writeArr('ht.later', next.later)
1642
+ writeArr('ht.seen', next.seen)
1643
+ if (saveTimer.current) clearTimeout(saveTimer.current)
1644
+ saveTimer.current = setTimeout(() => {
1645
+ try {
1646
+ void fetch('/hackertube/api/prefs', {
1647
+ method: 'POST',
1648
+ headers: { 'content-type': 'application/json' },
1649
+ body: JSON.stringify({ likes: next.likes, later: next.later, seen: next.seen }),
1650
+ }).catch(() => {})
1651
+ } catch {
1652
+ /* 离线时只留本地副本 */
1653
+ }
1654
+ }, 400)
1655
+ }
1656
+ const toggle = (bucket, vid) => {
1657
+ const cur = saved[bucket]
1658
+ const next = cur.indexOf(vid) >= 0 ? cur.filter((x) => x !== vid) : cur.concat(vid)
1659
+ update(Object.assign({}, saved, { [bucket]: next }))
1660
+ return next.indexOf(vid) >= 0
1661
+ }
1662
+ const markSeen = (vid) => {
1663
+ const next = saved.seen.filter((x) => x !== vid).concat(vid).slice(-200)
1664
+ update(Object.assign({}, saved, { seen: next }))
1665
+ }
1666
+ return { saved, toggle, markSeen }
1667
+ }
1668
+
1669
+ /* ---------------- 视图组件 ---------------- */
1670
+
1671
+ const NAV = {
1672
+ zh: [['home', '首页'], ['cats', '分类'], ['pl', '播放列表'], null, ['liked', '喜欢'], ['later', '稍后看'], ['history', '历史']],
1673
+ en: [['home', 'Home'], ['cats', 'Categories'], ['pl', 'Playlists'], null, ['liked', 'Liked'], ['later', 'Watch later'], ['history', 'History']],
1674
+ }
1675
+
1676
+ function ViewNav(props) {
1677
+ const tabs = NAV[props.lang] || NAV.zh
1678
+ return h(
1679
+ 'div',
1680
+ { className: 'ht-nav' },
1681
+ h(
1682
+ 'div',
1683
+ { className: 'ht-tabs' },
1684
+ tabs.map((t, i) =>
1685
+ t
1686
+ ? h(
1687
+ 'button',
1688
+ {
1689
+ key: t[0],
1690
+ type: 'button',
1691
+ 'data-view': t[0],
1692
+ className: 'ht-tab' + (props.view === t[0] ? ' on' : ''),
1693
+ onClick: () => props.onPick(t[0]),
1694
+ },
1695
+ t[1],
1696
+ )
1697
+ : h('span', { key: 'sep' + i, className: 'ht-tab-sep' }),
1698
+ ),
1699
+ ),
1700
+ )
1701
+ }
1702
+
1703
+ function Skeleton() {
1704
+ return h(
1705
+ 'div',
1706
+ { className: 'ht-scroll' },
1707
+ h(
1708
+ 'div',
1709
+ { className: 'ht-skel' },
1710
+ [0, 1, 2, 3, 4, 5].map((i) =>
1711
+ h('div', { key: i }, h('i', { className: 'b' }), h('i', { className: 'l' }), h('i', { className: 'm' })),
1712
+ ),
1713
+ ),
1714
+ )
1715
+ }
1716
+
1717
+ function EmptyState(props) {
1718
+ return h(
1719
+ 'div',
1720
+ { className: 'ht-empty' },
1721
+ h('b', null, props.title),
1722
+ props.hint ? h('span', null, props.hint) : null,
1723
+ props.action
1724
+ ? h('button', { type: 'button', className: 'ht-chip', onClick: props.onAction }, props.action)
1725
+ : null,
1726
+ )
1727
+ }
1728
+
1729
+ function AreaList(props) {
1730
+ const lang = props.lang
1731
+ return h(
1732
+ 'div',
1733
+ { className: 'ht-scroll' },
1734
+ h(
1735
+ 'div',
1736
+ { className: 'ht-areas' },
1737
+ props.areas.map((a) =>
1738
+ h(
1739
+ 'button',
1740
+ { key: a.id, type: 'button', className: 'ht-area', onClick: () => props.onPick(a.id) },
1741
+ h('h3', null, lang === 'zh' && a.nameZh ? a.nameZh : a.name, h('em', null, String(a.count || ''))),
1742
+ h(
1743
+ 'div',
1744
+ { className: 'ht-cats' },
1745
+ (a.categories || []).slice(0, 6).map((c) =>
1746
+ h('span', { key: c.id, className: 'ht-cat' }, lang === 'zh' && c.nameZh ? c.nameZh : c.name),
1747
+ ),
1748
+ ),
1749
+ ),
1750
+ ),
1751
+ ),
1752
+ )
1753
+ }
1754
+
1755
+ function PlaylistGrid(props) {
1756
+ const lang = props.lang
1757
+ const list = props.list || []
1758
+ const start = list.filter((p) => p.tier === 'start')
1759
+ const deep = list.filter((p) => p.tier !== 'start')
1760
+ const card = (p) =>
1761
+ h(
1762
+ 'button',
1763
+ { key: p.slug, type: 'button', className: 'ht-pl', onClick: () => props.onOpen(p.slug) },
1764
+ h(
1765
+ 'div',
1766
+ { className: 'ht-mosaic' },
1767
+ (p.cover || []).slice(0, 4).map((v, i) =>
1768
+ h('img', { key: i, src: 'https://i.ytimg.com/vi/' + v + '/hqdefault.jpg', alt: '', loading: 'lazy' }),
1769
+ ),
1770
+ h(
1771
+ 'span',
1772
+ { className: 'ht-count' },
1773
+ tx(lang, p.count + ' 个 · ' + Math.floor(p.minutes / 60) + ' 小时', p.count + ' videos · ' + Math.floor(p.minutes / 60) + 'h'),
1774
+ ),
1775
+ ),
1776
+ h('h3', null, lang === 'zh' && p.titleZh ? p.titleZh : p.title),
1777
+ h('p', null, lang === 'zh' && p.pitchZh ? p.pitchZh : p.pitch),
1778
+ )
1779
+ return h(
1780
+ 'div',
1781
+ { className: 'ht-scroll' },
1782
+ start.length ? h('div', { className: 'ht-sechead' }, h('h2', null, lang === 'zh' ? '从这里开始' : 'Start here')) : null,
1783
+ h('div', { className: 'ht-plgrid' }, start.map(card)),
1784
+ deep.length ? h('div', { className: 'ht-sechead' }, h('h2', null, lang === 'zh' ? '深挖' : 'Deep dives')) : null,
1785
+ h('div', { className: 'ht-plgrid' }, deep.map(card)),
1786
+ )
1787
+ }
1788
+
1789
+ function PlaylistDetail(props) {
1790
+ const pl = props.pl
1791
+ const lang = props.lang
1792
+ return h(
1793
+ 'div',
1794
+ { className: 'ht-scroll' },
1795
+ h(
1796
+ 'div',
1797
+ { style: { padding: '24px 24px 0' } },
1798
+ h('h2', { style: { margin: '0 0 6px', fontSize: '21px', fontWeight: 600 } },
1799
+ props.lang === 'zh' && pl.titleZh ? pl.titleZh : pl.title),
1800
+ h(
1801
+ 'p',
1802
+ { style: { margin: '0 0 8px', color: 'var(--dsw-alias-label-secondary)', fontSize: '13px', maxWidth: '72ch' } },
1803
+ lang === 'zh' ? pl.pitchZh || pl.pitch || pl.intro || '' : pl.intro || pl.pitch || '',
1804
+ ),
1805
+ h(
1806
+ 'div',
1807
+ { style: { fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '12px' } },
1808
+ tx(lang, (pl.videos || []).length + ' 个视频 · ' + pl.minutes + ' 分钟', (pl.videos || []).length + ' videos · ' + pl.minutes + ' min'),
1809
+ ),
1810
+ ),
1811
+ h(
1812
+ 'div',
1813
+ { style: { padding: '0 24px 40px' } },
1814
+ (pl.videos || []).map((v, i) =>
1815
+ h(
1816
+ 'button',
1817
+ { key: v.vid, type: 'button', className: 'ht-row', onClick: () => props.onOpen(v) },
1818
+ h(
1819
+ 'div',
1820
+ { className: 'ht-thumb' },
1821
+ h('img', {
1822
+ src: v.thumbnail || 'https://i.ytimg.com/vi/' + v.vid + '/mqdefault.jpg',
1823
+ alt: '',
1824
+ referrerPolicy: 'no-referrer',
1825
+ }),
1826
+ h('span', { className: 'ht-dur' }, fmtDur(v.duration_s)),
1827
+ ),
1828
+ h(
1829
+ 'div',
1830
+ { className: 'ht-info' },
1831
+ h('p', { className: 'ht-title' }, i + 1 + '. ' + titleOf({ v: v.vid, t: v.title }, props.lang)),
1832
+ h(
1833
+ 'div',
1834
+ { className: 'ht-meta' },
1835
+ v.channel ? h('span', { className: 'ht-chan' }, v.channel) : null,
1836
+ v.views ? h('span', null, tx(lang, fmtCount(v.views, lang) + ' 次观看', fmtCount(v.views, lang) + ' views')) : null,
1837
+ ),
1838
+ v.note
1839
+ ? h(
1840
+ 'p',
1841
+ { style: { margin: '4px 0 0', fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary)' } },
1842
+ v.note,
1843
+ )
1844
+ : null,
1845
+ ),
1846
+ ),
1847
+ ),
1848
+ ),
1849
+ )
1850
+ }
1851
+
1852
+ function SavedGrid(props) {
1853
+ const lang = props.lang
1854
+ if (!props.list.length) {
1855
+ return h(EmptyState, {
1856
+ title: props.emptyTitle,
1857
+ hint: props.emptyHint,
1858
+ action: lang === 'zh' ? '去首页看看' : 'Go to Home',
1859
+ onAction: () => props.onPick('home'),
1860
+ })
1861
+ }
1862
+ return h(
1863
+ 'div',
1864
+ { className: 'ht-scroll' },
1865
+ h(
1866
+ 'div',
1867
+ { className: 'ht-grid' },
1868
+ props.list.map((it) => h(Card, { key: it.v, item: it, lang, onOpen: props.onOpen })),
1869
+ ),
1870
+ )
1871
+ }
1872
+
1873
+ function HtRoot(props) {
1874
+ const [items, setItems] = useState(mem.list || [])
1875
+ const [tax, setTax] = useState(mem.tax || null)
1876
+ const [phase, setPhase] = useState(mem.list ? 'ready' : 'loading')
1877
+ const [err, setErr] = useState('')
1878
+ const [q, setQ] = useState('')
1879
+ /* 中文查询机翻成英文后的检索词:标签只有英文,靠它才能命中 */
1880
+ const [qMt, setQMt] = useState('')
1881
+ const [area, setArea] = useState('')
1882
+ const [limit, setLimit] = useState(GRID_STEP)
1883
+ const [watching, setWatching] = useState(null)
1884
+ const [lang, setLang] = useState(mem.lang || 'en')
1885
+ const [view, setView] = useState('home')
1886
+ const [playlists, setPlaylists] = useState(null)
1887
+ const [plDetail, setPlDetail] = useState(null)
1888
+ const [plSlug, setPlSlug] = useState(null)
1889
+ const [helpOpen, setHelpOpen] = useState(false)
1890
+ const { saved, toggle, markSeen } = useSaved()
1891
+ const [titlesVersion, setTitlesVersion] = useState(0)
1892
+
1893
+ /* 标题译文表在两种语言下都要:中文界面用来显示,英文界面用来支持中文搜索 */
1894
+ const ensureTitles = () => {
1895
+ void loadTitles().then((t) => {
1896
+ if (Object.keys(t).length) setTitlesVersion((v) => v + 1)
1897
+ })
1898
+ }
1899
+ useEffect(() => {
1900
+ ensureTitles()
1901
+ }, [])
1902
+
1903
+ /* 分类的中文名也参与检索:输入「数学」「安全」能命中整类,而不只是标题里带这两个字的视频 */
1904
+ const [taxReady, setTaxReady] = useState(0)
1905
+ useEffect(() => {
1906
+ if (mem.catZh) {
1907
+ setTaxReady((v) => v + 1)
1908
+ return undefined
1909
+ }
1910
+ let alive = true
1911
+ getJSON('/hackertube/api/taxonomy?lang=zh&translate=1')
1912
+ .then((d) => {
1913
+ if (!alive || !d) return
1914
+ const m = {}
1915
+ for (const a of d.areas || []) {
1916
+ if (a.nameZh) m['a:' + a.id] = a.nameZh
1917
+ for (const c of a.categories || []) if (c.nameZh) m[c.id] = c.nameZh
1918
+ }
1919
+ mem.catZh = m
1920
+ setTaxReady((v) => v + 1)
1921
+ })
1922
+ .catch(() => {})
1923
+ return () => {
1924
+ alive = false
1925
+ }
1926
+ }, [])
1927
+ const [reloadKey, setReloadKey] = useState(0)
1928
+ const [authUrl, setAuthUrl] = useState(null)
1929
+ const sentinelRef = useRef(null)
1930
+ const rootRef = useRef(null)
1931
+
1932
+ /* 桌面端拿一个带 launch token 的 HTTP 地址,用于在新窗口获得安全上下文 */
1933
+ useEffect(() => {
1934
+ if (typeof location !== 'undefined' && /^https?:$/.test(location.protocol)) return
1935
+ getJSON('/hackertube/api/config')
1936
+ .then((d) => {
1937
+ if (d && d.authUrl) setAuthUrl(String(d.authUrl))
1938
+ })
1939
+ .catch(() => {})
1940
+ }, [])
1941
+
1942
+ useEffect(() => {
1943
+ let alive = true
1944
+ Promise.all([loadList(), loadTaxonomy(lang)])
1945
+ .then(([list, taxonomy]) => {
1946
+ if (!alive) return
1947
+ setItems(list)
1948
+ setTax(taxonomy)
1949
+ setPhase('ready')
1950
+ })
1951
+ .catch((e) => {
1952
+ if (!alive) return
1953
+ setErr(String((e && e.message) || e))
1954
+ setPhase('error')
1955
+ })
1956
+ return () => {
1957
+ alive = false
1958
+ }
1959
+ }, [reloadKey, lang])
1960
+
1961
+ const retry = () => {
1962
+ setErr('')
1963
+ setPhase('loading')
1964
+ setReloadKey((k) => k + 1)
1965
+ }
1966
+
1967
+ const areas = useMemo(() => {
1968
+ const list = tax && Array.isArray(tax.areas) ? tax.areas : []
1969
+ return list.map((a) => ({
1970
+ id: a.id,
1971
+ name: a.name,
1972
+ nameZh: a.nameZh,
1973
+ count: a.count,
1974
+ categories: (a.categories || []).map((c) => ({
1975
+ id: c.id,
1976
+ name: c.name,
1977
+ nameZh: c.nameZh,
1978
+ })),
1979
+ }))
1980
+ }, [tax])
1981
+
1982
+ const catToArea = useMemo(() => {
1983
+ const m = new Map()
1984
+ const list = tax && Array.isArray(tax.areas) ? tax.areas : []
1985
+ for (const a of list) {
1986
+ for (const c of a.categories || []) m.set(c.id, a.id)
1987
+ }
1988
+ return m
1989
+ }, [tax])
1990
+
1991
+ const filtered = useMemo(() => {
1992
+ if (!items.length) return []
1993
+ /* 空格分词、全部命中才算命中;中文查译文、英文查原文,两种都能搜 */
1994
+ const tokens = q.trim().toLowerCase().split(/\s+/).filter(Boolean)
1995
+ const mtTokens = String(qMt || '').toLowerCase().split(/\s+/).filter(Boolean)
1996
+ let out = items
1997
+ if (area) {
1998
+ out = out.filter((it) =>
1999
+ Array.isArray(it.cats) && it.cats.some((id) => catToArea.get(id) === area),
2000
+ )
2001
+ }
2002
+ if (tokens.length) {
2003
+ out = out.filter((it) => {
2004
+ const zh = (mem.titles && mem.titles[it.v]) || ''
2005
+ let catText = ''
2006
+ if (mem.catZh && Array.isArray(it.cats)) {
2007
+ /* 只用二级分类名,不用大区名:「安全」不该把数据库、操作系统的视频一起带出来 */
2008
+ const parts = []
2009
+ for (const id of it.cats) if (mem.catZh[id]) parts.push(mem.catZh[id])
2010
+ catText = parts.join(' ')
2011
+ }
2012
+ const hay = (
2013
+ (it.t || '') +
2014
+ '\n' +
2015
+ (it.c || '') +
2016
+ '\n' +
2017
+ zh +
2018
+ '\n' +
2019
+ catText +
2020
+ '\n' +
2021
+ (Array.isArray(it.tags) ? it.tags.join(' ') : '')
2022
+ ).toLowerCase()
2023
+ /* 原文命中,或者机翻后的英文命中,都算 */
2024
+ if (tokens.every((tok) => hay.indexOf(tok) >= 0)) return true
2025
+ return mtTokens.length > 0 && mtTokens.every((tok) => hay.indexOf(tok) >= 0)
2026
+ })
2027
+ }
2028
+ return out
2029
+ }, [items, q, qMt, area, catToArea, titlesVersion, taxReady])
2030
+
2031
+ /* 查询里带汉字才需要机翻;结果按查询串缓存,重复输入不再请求 */
2032
+ useEffect(() => {
2033
+ const raw = q.trim()
2034
+ if (!raw || !/[\u4e00-\u9fff]/.test(raw)) {
2035
+ setQMt('')
2036
+ return undefined
2037
+ }
2038
+ if (mem.qMt[raw] != null) {
2039
+ setQMt(mem.qMt[raw])
2040
+ return undefined
2041
+ }
2042
+ let alive = true
2043
+ const timer = setTimeout(() => {
2044
+ fetch('/hackertube/api/mt', {
2045
+ method: 'POST',
2046
+ headers: { 'content-type': 'application/json' },
2047
+ body: JSON.stringify({ text: raw, to: 'en' }),
2048
+ })
2049
+ .then((r) => r.json())
2050
+ .then((d) => {
2051
+ if (!alive || !d || typeof d.text !== 'string') return
2052
+ mem.qMt[raw] = d.text
2053
+ setQMt(d.text)
2054
+ })
2055
+ .catch(() => {})
2056
+ }, 300)
2057
+ return () => {
2058
+ alive = false
2059
+ clearTimeout(timer)
2060
+ }
2061
+ }, [q])
2062
+
2063
+ useEffect(() => {
2064
+ setLimit(GRID_STEP)
2065
+ }, [q, area])
2066
+
2067
+ /* 上报一次实测渲染结果:布局尺寸与关键计算样式,这是唯一能客观验证视觉的途径 */
2068
+ useEffect(() => {
2069
+ if (phase !== 'ready') return
2070
+ const el = rootRef.current
2071
+ if (!el || typeof el.getBoundingClientRect !== 'function') return
2072
+ const rect = el.getBoundingClientRect()
2073
+ const q = (sel) => (typeof el.querySelector === 'function' ? el.querySelector(sel) : null)
2074
+ const cs = (node) =>
2075
+ node && typeof window !== 'undefined' && window.getComputedStyle
2076
+ ? window.getComputedStyle(node)
2077
+ : null
2078
+ const rootCs = cs(el)
2079
+ const thumbCs = cs(q('.ht-thumb'))
2080
+ const titleCs = cs(q('.ht-title'))
2081
+ const gridCs = cs(q('.ht-grid'))
2082
+ const chipCs = cs(q('.ht-chip'))
2083
+ const scroller = q('.ht-scroll')
2084
+ beacon('panel-measured', {
2085
+ w: Math.round(rect.width),
2086
+ h: Math.round(rect.height),
2087
+ scrollH: scroller ? Math.round(scroller.getBoundingClientRect().height) : null,
2088
+ cards: typeof el.querySelectorAll === 'function' ? el.querySelectorAll('.ht-card').length : -1,
2089
+ items: items.length,
2090
+ shown: Math.min(limit, filtered.length),
2091
+ bg: rootCs ? rootCs.backgroundColor : null,
2092
+ fg: rootCs ? rootCs.color : null,
2093
+ thumbRadius: thumbCs ? thumbCs.borderRadius : null,
2094
+ titleSize: titleCs ? titleCs.fontSize : null,
2095
+ titleWeight: titleCs ? titleCs.fontWeight : null,
2096
+ titleLH: titleCs ? titleCs.lineHeight : null,
2097
+ gridGap: gridCs ? gridCs.gap : null,
2098
+ gridCols: gridCs ? gridCs.gridTemplateColumns.split(' ').length : null,
2099
+ chipH: chipCs ? chipCs.height : null,
2100
+ })
2101
+ }, [phase])
2102
+
2103
+ useEffect(() => {
2104
+ const el = sentinelRef.current
2105
+ if (!el) return
2106
+ const io = new IntersectionObserver(
2107
+ (entries) => {
2108
+ if (entries.some((e) => e.isIntersecting)) {
2109
+ setLimit((n) => Math.min(n + GRID_STEP, GRID_CAP))
2110
+ }
2111
+ },
2112
+ { rootMargin: '500px' },
2113
+ )
2114
+ io.observe(el)
2115
+ return () => io.disconnect()
2116
+ }, [filtered.length, watching])
2117
+
2118
+ /* 播放列表按需拉取:进过分类/列表页才会请求 */
2119
+ useEffect(() => {
2120
+ if (view !== 'pl' || playlists) return
2121
+ let alive = true
2122
+ getJSON('/hackertube/api/playlists')
2123
+ .then((d) => {
2124
+ if (alive) setPlaylists(Array.isArray(d) ? d : [])
2125
+ })
2126
+ .catch(() => {
2127
+ if (alive) setPlaylists([])
2128
+ })
2129
+ return () => {
2130
+ alive = false
2131
+ }
2132
+ }, [view, playlists])
2133
+
2134
+ const openPlaylist = (slug) => {
2135
+ setPlSlug(slug)
2136
+ setPlDetail(null)
2137
+ setView('playlist')
2138
+ getJSON('/hackertube/api/playlist?slug=' + encodeURIComponent(slug))
2139
+ .then((d) => setPlDetail(d && d.videos ? d : { videos: [], title: slug }))
2140
+ .catch(() => setPlDetail({ videos: [], title: slug }))
2141
+ }
2142
+
2143
+ const openItem = (it) => {
2144
+ if (!it) return
2145
+ markSeen(it.v)
2146
+ setWatching(it)
2147
+ }
2148
+
2149
+ /* 键盘快捷键:与站点一致的那一套 */
2150
+ useEffect(() => {
2151
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
2152
+ const onKey = (e) => {
2153
+ if (e.metaKey || e.ctrlKey || e.altKey) return
2154
+ const el = document.activeElement
2155
+ const typing = el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)
2156
+ if (e.key === 'Escape') {
2157
+ if (typing) el.blur()
2158
+ setHelpOpen(false)
2159
+ return
2160
+ }
2161
+ if (typing) return
2162
+ if (e.key === '/') {
2163
+ e.preventDefault()
2164
+ const input = rootRef.current && rootRef.current.querySelector('.ht-search input')
2165
+ if (input) input.focus()
2166
+ return
2167
+ }
2168
+ if (e.key === '?') {
2169
+ e.preventDefault()
2170
+ setHelpOpen((v) => !v)
2171
+ return
2172
+ }
2173
+ if (e.key === 'h' || e.key === 'H') {
2174
+ setView('home')
2175
+ setWatching(null)
2176
+ return
2177
+ }
2178
+ if (e.key === 'j' || e.key === 'k' || e.key === 'Enter') {
2179
+ const root = rootRef.current
2180
+ const cards = root ? Array.prototype.slice.call(root.querySelectorAll('.ht-card')) : []
2181
+ if (!cards.length) return
2182
+ if (e.key === 'Enter') {
2183
+ const cur = cards.findIndex((c) => c.classList.contains('kbd'))
2184
+ if (cur >= 0) {
2185
+ e.preventDefault()
2186
+ const vid = cards[cur].getAttribute('data-vid')
2187
+ const hit = items.find((it) => it.v === vid)
2188
+ if (hit) openItem(hit)
2189
+ }
2190
+ return
2191
+ }
2192
+ e.preventDefault()
2193
+ const cur = cards.findIndex((c) => c.classList.contains('kbd'))
2194
+ const next = e.key === 'j' ? Math.min(cards.length - 1, cur + 1) : Math.max(0, cur - 1)
2195
+ cards.forEach((c, i) => c.classList.toggle('kbd', i === next))
2196
+ cards[next].scrollIntoView({ block: 'nearest' })
2197
+ return
2198
+ }
2199
+ if (watching && (e.key === 'l' || e.key === 'w')) {
2200
+ e.preventDefault()
2201
+ toggle(e.key === 'l' ? 'likes' : 'later', watching.v)
2202
+ }
2203
+ }
2204
+ document.addEventListener('keydown', onKey)
2205
+ return () => document.removeEventListener('keydown', onKey)
2206
+ }, [watching, toggle])
2207
+
2208
+ if (watching) {
2209
+ return h(WatchView, {
2210
+ item: watching,
2211
+ lang,
2212
+ onBack: () => setWatching(null),
2213
+ liked: saved.likes.indexOf(watching.v) >= 0,
2214
+ savedLater: saved.later.indexOf(watching.v) >= 0,
2215
+ onToggle: toggle,
2216
+ onOpenItem: (r) => {
2217
+ markSeen(r.v)
2218
+ setWatching({ v: r.v, t: r.t, c: r.c, d: r.d, w: r.w, th: r.th, p: r.p })
2219
+ },
2220
+ })
2221
+ }
2222
+
2223
+ const pickView = (next) => {
2224
+ setView(next)
2225
+ if (next !== 'playlist') setPlDetail(null)
2226
+ }
2227
+
2228
+ const savedList = (bucket) =>
2229
+ saved[bucket]
2230
+ .map((id) => items.find((it) => it.v === id))
2231
+ .filter(Boolean)
2232
+ .slice()
2233
+ .reverse()
2234
+
2235
+ const body = (() => {
2236
+ if (view === 'cats') {
2237
+ return h(AreaList, {
2238
+ areas,
2239
+ lang,
2240
+ onPick: (id) => {
2241
+ setArea(id)
2242
+ setView('home')
2243
+ },
2244
+ })
2245
+ }
2246
+ if (view === 'pl') {
2247
+ if (!playlists) return h(Skeleton)
2248
+ return h(PlaylistGrid, { list: playlists, lang, onOpen: openPlaylist })
2249
+ }
2250
+ if (view === 'playlist') {
2251
+ if (!plDetail) return h(Skeleton)
2252
+ return h(PlaylistDetail, {
2253
+ pl: plDetail,
2254
+ lang,
2255
+ onOpen: (v) =>
2256
+ openItem({
2257
+ v: v.vid,
2258
+ t: v.title,
2259
+ c: v.channel,
2260
+ d: v.duration_s,
2261
+ w: v.views,
2262
+ th: v.thumbnail,
2263
+ p: v.published,
2264
+ }),
2265
+ })
2266
+ }
2267
+ if (view === 'liked' || view === 'later' || view === 'history') {
2268
+ const list = savedList(view === 'liked' ? 'likes' : view === 'later' ? 'later' : 'seen')
2269
+ const copy = {
2270
+ liked: [
2271
+ tx(lang, '还没有喜欢过任何视频', 'No liked videos yet'),
2272
+ tx(lang, '在详情页点喜欢,这里就会出现,并影响首页排序。', 'Like a video on its page and it shows up here.'),
2273
+ ],
2274
+ later: [
2275
+ tx(lang, '稍后看是空的', 'Watch later is empty'),
2276
+ tx(lang, '在详情页点稍后看,视频会留在这里等你。', 'Save a video for later and it waits here.'),
2277
+ ],
2278
+ history: [
2279
+ tx(lang, '还没有观看记录', 'No watch history yet'),
2280
+ tx(lang, '打开过的视频会出现在这里,并从首页推荐里避开。', 'Videos you open land here and drop out of the home feed.'),
2281
+ ],
2282
+ }[view]
2283
+ return h(SavedGrid, {
2284
+ list,
2285
+ lang,
2286
+ emptyTitle: copy[0],
2287
+ emptyHint: copy[1],
2288
+ onPick: pickView,
2289
+ onOpen: openItem,
2290
+ })
2291
+ }
2292
+ return null
2293
+ })()
2294
+
2295
+ const shown = filtered.slice(0, limit)
2296
+
2297
+ return h(
2298
+ 'div',
2299
+ { className: 'ht-root', ref: rootRef },
2300
+ h(
2301
+ 'div',
2302
+ { className: 'ht-head' },
2303
+ h('div', { className: 'ht-brand' }, 'Hacker', h('b', null, 'Tube')),
2304
+ h(
2305
+ 'button',
2306
+ {
2307
+ type: 'button',
2308
+ className: 'ht-lang',
2309
+ onClick: () => {
2310
+ const next = lang === 'zh' ? 'en' : 'zh'
2311
+ mem.lang = next
2312
+ setLang(next)
2313
+ },
2314
+ title: lang === 'zh' ? '切换到英文' : '切换到中文',
2315
+ },
2316
+ lang === 'zh' ? 'EN' : '中文',
2317
+ ),
2318
+ h(
2319
+ 'form',
2320
+ {
2321
+ className: 'ht-search',
2322
+ onSubmit: (e) => e.preventDefault(),
2323
+ },
2324
+ h('input', {
2325
+ type: 'search',
2326
+ value: q,
2327
+ placeholder: tx(lang, '搜索标题、频道、标签,中英文均可', 'Search titles (English or Chinese), channels, tags'),
2328
+ onChange: (e) => setQ(e.target.value),
2329
+ 'aria-label': tx(lang, '搜索视频', 'Search videos'),
2330
+ }),
2331
+ h(
2332
+ 'button',
2333
+ { type: 'submit', 'aria-label': tx(lang, '搜索', 'Search') },
2334
+ h(
2335
+ 'svg',
2336
+ { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
2337
+ h('circle', {
2338
+ cx: 11,
2339
+ cy: 11,
2340
+ r: 7,
2341
+ stroke: 'currentColor',
2342
+ strokeWidth: 2,
2343
+ }),
2344
+ h('path', {
2345
+ d: 'M16.5 16.5 21 21',
2346
+ stroke: 'currentColor',
2347
+ strokeWidth: 2,
2348
+ strokeLinecap: 'round',
2349
+ }),
2350
+ ),
2351
+ ),
2352
+ ),
2353
+ h(
2354
+ 'span',
2355
+ { className: 'ht-foot', style: { whiteSpace: 'nowrap' } },
2356
+ phase === 'ready'
2357
+ ? tx(
2358
+ lang,
2359
+ filtered.length + ' 个视频',
2360
+ filtered.length.toLocaleString('en-US') + (filtered.length === 1 ? ' video' : ' videos'),
2361
+ )
2362
+ : '',
2363
+ ),
2364
+ authUrl
2365
+ ? h(
2366
+ 'a',
2367
+ {
2368
+ className: 'ht-open',
2369
+ href: authUrl,
2370
+ target: '_blank',
2371
+ rel: 'noreferrer noopener',
2372
+ title: tx(lang, '桌面端的自定义协议无法内嵌播放器,用浏览器打开即恢复正常', 'The desktop custom protocol cannot embed the player; open it in a browser instead'),
2373
+ },
2374
+ tx(lang, '浏览器打开', 'Open in browser'),
2375
+ )
2376
+ : null,
2377
+ ),
2378
+ h(ViewNav, { view, lang, onPick: pickView }),
2379
+ view === 'home' ? (h(
2380
+ 'div',
2381
+ { className: 'ht-chips' },
2382
+ h(
2383
+ 'button',
2384
+ {
2385
+ type: 'button',
2386
+ className: 'ht-chip' + (area === '' ? ' on' : ''),
2387
+ onClick: () => setArea(''),
2388
+ },
2389
+ tx(lang, '全部', 'All'),
2390
+ ),
2391
+ areas.map((a) =>
2392
+ h(
2393
+ 'button',
2394
+ {
2395
+ type: 'button',
2396
+ key: a.id,
2397
+ className: 'ht-chip' + (area === a.id ? ' on' : ''),
2398
+ onClick: () => setArea(a.id),
2399
+ },
2400
+ lang === 'zh' && a.nameZh ? a.nameZh : a.name,
2401
+ ),
2402
+ ),
2403
+ )) : null,
2404
+ view === 'home' && phase === 'loading' ? h(Skeleton) : null,
2405
+ view === 'home' ? (h(
2406
+ 'div',
2407
+ { className: 'ht-scroll' },
2408
+ phase === 'loading'
2409
+ ? null
2410
+ : null,
2411
+ phase === 'error'
2412
+ ? h(
2413
+ 'div',
2414
+ { className: 'ht-state' },
2415
+ h('div', null, tx(lang, '数据加载失败:', 'Failed to load: ') + err),
2416
+ h(
2417
+ 'button',
2418
+ { type: 'button', className: 'ht-back', onClick: retry, style: { marginTop: 12 } },
2419
+ tx(lang, '重试', 'Retry'),
2420
+ ),
2421
+ )
2422
+ : null,
2423
+ phase === 'ready' && !filtered.length
2424
+ ? h('div', { className: 'ht-state' }, tx(lang, '没有匹配的视频', 'No matching videos'))
2425
+ : null,
2426
+ phase === 'ready' && filtered.length
2427
+ ? h(
2428
+ 'div',
2429
+ { className: 'ht-grid' },
2430
+ shown.map((it) => h(Card, { key: it.v, item: it, lang, onOpen: openItem })),
2431
+ )
2432
+ : null,
2433
+ phase === 'ready' && filtered.length > limit && limit < GRID_CAP
2434
+ ? h('div', { ref: sentinelRef, className: 'ht-sentinel' })
2435
+ : null,
2436
+ phase === 'ready' && filtered.length > GRID_CAP && limit >= GRID_CAP
2437
+ ? h(
2438
+ 'div',
2439
+ { className: 'ht-notice' },
2440
+ tx(lang, '已显示 ' + GRID_CAP + ' 条,用搜索或分类缩小范围', 'Showing ' + GRID_CAP + ' items; narrow it down with search or a category'),
2441
+ )
2442
+ : null,
2443
+ )) : body,
2444
+ helpOpen
2445
+ ? h(
2446
+ 'div',
2447
+ { className: 'ht-keys', onClick: () => setHelpOpen(false) },
2448
+ h(
2449
+ 'div',
2450
+ { className: 'k' },
2451
+ h('h3', null, lang === 'zh' ? '键盘快捷键' : 'Keyboard shortcuts'),
2452
+ h(
2453
+ 'table',
2454
+ null,
2455
+ (lang === 'zh'
2456
+ ? [['/', '聚焦搜索'], ['j / k', '上下移动'], ['Enter', '打开'], ['l', '喜欢'], ['w', '稍后看'], ['h', '首页'], ['?', '这些快捷键'], ['Esc', '关闭']]
2457
+ : [['/', 'Search'], ['j / k', 'Move'], ['Enter', 'Open'], ['l', 'Like'], ['w', 'Watch later'], ['h', 'Home'], ['?', 'Shortcuts'], ['Esc', 'Close']]
2458
+ ).map((row) => h('tr', { key: row[0] }, h('td', null, h('kbd', null, row[0])), h('td', null, row[1]))),
2459
+ ),
2460
+ ),
2461
+ )
2462
+ : null,
2463
+ )
2464
+ }
2465
+
2466
+ /* ---------------- 注册 ---------------- */
2467
+
2468
+ return {
2469
+ inject: ['slots'],
2470
+ apply(ctx) {
2471
+ beacon('apply-enter', { hasCtx: !!ctx, hasSlots: !!(ctx && ctx.slots) })
2472
+ const slots = ctx && ctx.slots
2473
+ if (!slots || typeof slots.register !== 'function') {
2474
+ console.warn('[dsh-hackertube] slots 服务不可用,面板未注册')
2475
+ beacon('no-slots', { ctxKeys: ctx ? Object.keys(ctx).slice(0, 20) : null })
2476
+ if (typeof window !== 'undefined') {
2477
+ window.__dshHackertube = { loaded: true, registered: false, reason: 'no-slots' }
2478
+ }
2479
+ return
2480
+ }
2481
+
2482
+ ctx.effect(() => {
2483
+ const el = document.createElement('style')
2484
+ el.setAttribute('data-dsh-hackertube', '')
2485
+ el.textContent = CSS
2486
+ document.head.appendChild(el)
2487
+ return () => {
2488
+ el.remove()
2489
+ }
2490
+ }, 'dsh-hackertube: styles')
2491
+
2492
+ ctx.effect(() => {
2493
+ const d1 = slots.inject('sidebar.panellist', () =>
2494
+ slots.register(
2495
+ {
2496
+ name: 'sidebar.panellist',
2497
+ id: 'hackertube',
2498
+ order: 45,
2499
+ label: () => 'HackerTube',
2500
+ },
2501
+ PanelIcon,
2502
+ ),
2503
+ )
2504
+ const d2 = slots.inject('main', () =>
2505
+ slots.register({ name: 'main', key: 'hackertube' }, HtRoot),
2506
+ )
2507
+ return () => {
2508
+ for (const d of [d1, d2]) {
2509
+ if (typeof d === 'function') {
2510
+ try {
2511
+ d()
2512
+ } catch {
2513
+ /* 已被清理 */
2514
+ }
2515
+ }
2516
+ }
2517
+ }
2518
+ }, 'dsh-hackertube: native panel')
2519
+
2520
+ /* 刷新后可在控制台输入 __dshHackertube 直接看注册结果 */
2521
+ if (typeof window !== 'undefined') {
2522
+ window.__dshHackertube = {
2523
+ loaded: true,
2524
+ registered: true,
2525
+ at: Date.now(),
2526
+ panelId: 'hackertube',
2527
+ }
2528
+ }
2529
+ console.log('[dsh-hackertube] 面板已注册:sidebar.panellist + main')
2530
+ beacon('registered', { panelId: 'hackertube' })
2531
+
2532
+ /* 中转页在 http:// 下无法直接回连 Host(跨源无 cookie),用 postMessage 递状态 */
2533
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2534
+ const onRelayMessage = (event) => {
2535
+ const data = event && event.data
2536
+ if (!data || typeof data !== 'object' || !data.__ht) return
2537
+ /* 播放位置每秒回传一次,问答时作为上下文,不写进 beacon 免得刷屏 */
2538
+ if (data.__ht === 'time') {
2539
+ mem.playhead = { t: Number(data.t) || 0, d: Number(data.d) || 0, at: Date.now() }
2540
+ return
2541
+ }
2542
+ beacon('relay-msg', data)
2543
+ }
2544
+ window.addEventListener('message', onRelayMessage)
2545
+ ctx.effect(
2546
+ () => () => window.removeEventListener('message', onRelayMessage),
2547
+ 'dsh-hackertube: relay message',
2548
+ )
2549
+ }
2550
+
2551
+ /* 查清楚宿主有没有把 HTTP 地址暴露给前端,决定播放器怎么落到安全上下文 */
2552
+ try {
2553
+ const boot = typeof window !== 'undefined' ? window.__DSH_BOOT__ : undefined
2554
+ beacon('boot-probe', {
2555
+ hasBoot: boot !== undefined && boot !== null,
2556
+ bootType: typeof boot,
2557
+ bootKeys: boot && typeof boot === 'object' ? Object.keys(boot).slice(0, 24) : null,
2558
+ bootSample: boot ? JSON.stringify(boot).slice(0, 600) : null,
2559
+ href: typeof location !== 'undefined' ? String(location.href) : null,
2560
+ base: typeof document !== 'undefined' ? String(document.baseURI) : null,
2561
+ })
2562
+ } catch (err) {
2563
+ beacon('boot-probe-error', { message: String(err && err.message) })
2564
+ }
2565
+ },
2566
+ }
2567
+ },
2568
+ })