dsh-hackertube 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.en.md +154 -0
- package/README.md +153 -2
- package/client.js +2568 -0
- package/cordis.patch.yml +5 -0
- package/data/zh-taxonomy.json +1 -0
- package/data/zh-titles.json +1 -0
- package/icon.svg +5 -0
- package/index.js +3142 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
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
|
+
})
|