@orderg-x/dsh-video-preview 0.0.0-stage → 0.4.0
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.md +134 -2
- package/assets/demo.gif +0 -0
- package/assets/hero.png +0 -0
- package/assets/hero.svg +70 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/auto-present.js +267 -0
- package/lib/client.js +696 -0
- package/lib/index.js +455 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-video-preview 的客户端部分 —— 三处挂载,把播放器送到用户眼前:
|
|
3
|
+
*
|
|
4
|
+
* conversation.chat.turnTail(链式)→ 这一轮 present 过的媒体,
|
|
5
|
+
* 播放器直接渲染在最终输出正下方(主显示位,不用点任何东西)
|
|
6
|
+
* documentPreviews + sidebar.right.tab.document → 侧边栏预览面板变播放器
|
|
7
|
+
* tool.call.toolview(key: play_video)→ 展开工具块后的小播放器(兜底)
|
|
8
|
+
*
|
|
9
|
+
* 回复下方的卡片:
|
|
10
|
+
* 视频 只露画面(200 高、按原比例),角上标时长;鼠标移上去静音预览、浮出文件名和下载,
|
|
11
|
+
* 点一下才从头带声音播,换成系统播放条
|
|
12
|
+
* 音频 自己画的细条:圆形播放键 + 文件名 + 时长 + 可拖的进度条
|
|
13
|
+
* 多个 一个一行,超过 3 个先折叠;同一时间只有一个在出声
|
|
14
|
+
* 配色、圆角、字体全用 DSH 自己的设计变量(--dsw-*),深浅色主题跟着宿主走。
|
|
15
|
+
*
|
|
16
|
+
* 走官方客户端插件机制:window.__ModuleLoader__.load({ id, factory }),
|
|
17
|
+
* factory 里 apply(ctx) → ctx.slots.register(...)。
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: '@orderg-x/dsh-video-preview',
|
|
21
|
+
factory: (require) => {
|
|
22
|
+
var module = { exports: {} }
|
|
23
|
+
var exports = module.exports
|
|
24
|
+
|
|
25
|
+
let react = null
|
|
26
|
+
try { react = require('react') } catch (_) { react = null }
|
|
27
|
+
if (!react) {
|
|
28
|
+
// 拿不到 react 就啥也别干了,安静退出,别把宿主搞崩
|
|
29
|
+
exports.apply = () => {}
|
|
30
|
+
exports.inject = ['slots', 'locale']
|
|
31
|
+
return module.exports
|
|
32
|
+
}
|
|
33
|
+
const h = react.createElement
|
|
34
|
+
|
|
35
|
+
// 0.4.0 起包名是 @orderg-x/dsh-video-preview(npm 上 dsh-video-preview 是别人的包)。
|
|
36
|
+
// 槽位的 key / id 还用老名字:旧版装着时两份里只有一份会挂(见 apply 里的 whoServes)
|
|
37
|
+
const PKG = '@orderg-x/dsh-video-preview'
|
|
38
|
+
const NS = PKG
|
|
39
|
+
|
|
40
|
+
const ZH = {
|
|
41
|
+
preparing: '准备播放…',
|
|
42
|
+
noUrl: '拿不到播放地址',
|
|
43
|
+
noPath: '拿不到文件路径',
|
|
44
|
+
failed: '播放失败',
|
|
45
|
+
media: '媒体',
|
|
46
|
+
previewTitle: '媒体预览',
|
|
47
|
+
notFound: '文件找不到了,可能被移走或删掉了',
|
|
48
|
+
denied: '没有权限读这个文件(只能播本机的视频、音频)',
|
|
49
|
+
noServer: '连不上播放服务。刚装或刚更新插件的话,重启一下 DSH',
|
|
50
|
+
unsupported: '这个文件的编码 DSH 的播放器放不了,转一下再播:',
|
|
51
|
+
download: '下载',
|
|
52
|
+
play: '播放',
|
|
53
|
+
pause: '暂停',
|
|
54
|
+
seek: '播放进度',
|
|
55
|
+
more: '还有 {n} 个媒体文件',
|
|
56
|
+
less: '收起',
|
|
57
|
+
}
|
|
58
|
+
const EN = {
|
|
59
|
+
preparing: 'Preparing…',
|
|
60
|
+
noUrl: 'No media URL found',
|
|
61
|
+
noPath: 'No file path',
|
|
62
|
+
failed: 'Playback failed',
|
|
63
|
+
media: 'Media',
|
|
64
|
+
previewTitle: 'Media preview',
|
|
65
|
+
notFound: 'File not found — it may have been moved or deleted',
|
|
66
|
+
denied: 'Not allowed to read this file (only local video and audio)',
|
|
67
|
+
noServer: 'Cannot reach the player service. Restart DSH after installing or updating the plugin',
|
|
68
|
+
unsupported: 'The DSH player cannot decode this file. Convert it first:',
|
|
69
|
+
download: 'Download',
|
|
70
|
+
play: 'Play',
|
|
71
|
+
pause: 'Pause',
|
|
72
|
+
seek: 'Playback position',
|
|
73
|
+
more: '{n} more media files',
|
|
74
|
+
less: 'Show less',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const VIDEO_RE = /\.(mp4|m4v|mov|webm|ogv|mkv|3gp)$/i
|
|
78
|
+
const AUDIO_RE = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i
|
|
79
|
+
const FOLD_AT = 3
|
|
80
|
+
|
|
81
|
+
// ── 样式:一张样式表,:hover / 过渡这些内联样式做不了 ────────────────────────
|
|
82
|
+
const FONT = 'var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "PingFang SC", system-ui, sans-serif)'
|
|
83
|
+
// 强调色照 DSH 自己「实心标签」的做法:底用正文色、图标用底色,深浅主题都清楚
|
|
84
|
+
// (brand-primary 在深色主题里是白的,配白图标会看不见)
|
|
85
|
+
const INK = 'var(--dsw-alias-label-primary, #1f1f1f)'
|
|
86
|
+
const PAPER = 'var(--dsw-alias-bg-layer-1, #fff)'
|
|
87
|
+
// 画面外的底:跟主题走,浅色下不会在圆角边上露一圈黑线
|
|
88
|
+
const LETTERBOX = 'var(--dsw-alias-bg-layer-2, #111)'
|
|
89
|
+
const CSS = `
|
|
90
|
+
.dvp-list{display:flex;flex-direction:column;align-items:flex-start;gap:8px;margin:6px 0 10px}
|
|
91
|
+
.dvp-video{position:relative;display:inline-block;vertical-align:top;max-width:100%;height:200px;border-radius:var(--dsw-radius-lg,12px);overflow:hidden;background:${LETTERBOX};border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.28));cursor:pointer;isolation:isolate;outline:none}
|
|
92
|
+
.dvp-video:focus-visible{box-shadow:0 0 0 2px ${INK}}
|
|
93
|
+
.dvp-video video{display:block;height:100%;width:auto;max-width:100%;min-width:200px;object-fit:contain;background:${LETTERBOX}}
|
|
94
|
+
.dvp-video.is-active{cursor:auto}
|
|
95
|
+
.dvp-video.is-active .dvp-play,.dvp-video.is-active .dvp-top,.dvp-video.is-active .dvp-badge,.dvp-video.is-active .dvp-bar{display:none}
|
|
96
|
+
.dvp-play{position:absolute;left:50%;top:50%;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;background:rgba(0,0,0,.42);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:inset 0 0 0 .5px rgba(255,255,255,.22);transition:transform .16s ease,background .16s ease,opacity .16s ease;pointer-events:none}
|
|
97
|
+
.dvp-video:hover .dvp-play{transform:scale(1.06);background:rgba(0,0,0,.58)}
|
|
98
|
+
.dvp-video.is-previewing .dvp-play{opacity:.85}
|
|
99
|
+
.dvp-top{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:6px;padding:8px 8px 22px 12px;background:linear-gradient(rgba(0,0,0,.62),rgba(0,0,0,0));color:#fff;font:12px/18px ${FONT};opacity:0;transition:opacity .16s ease}
|
|
100
|
+
.dvp-video:hover .dvp-top,.dvp-video:focus-visible .dvp-top{opacity:1}
|
|
101
|
+
.dvp-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
102
|
+
.dvp-sub{flex:none;opacity:.72;font-variant-numeric:tabular-nums}
|
|
103
|
+
.dvp-dl{flex:none;width:24px;height:24px;border-radius:var(--dsw-radius-sm,6px);display:flex;align-items:center;justify-content:center;color:inherit;opacity:.8;transition:background .12s ease,opacity .12s ease}
|
|
104
|
+
.dvp-dl:hover{opacity:1;background:rgba(255,255,255,.16)}
|
|
105
|
+
.dvp-badge{position:absolute;right:8px;bottom:8px;padding:1px 6px;border-radius:4px;background:rgba(0,0,0,.62);color:#fff;font:500 11px/16px ${FONT};font-variant-numeric:tabular-nums;pointer-events:none;transition:opacity .16s ease}
|
|
106
|
+
.dvp-bar{position:absolute;left:0;bottom:0;height:2px;background:rgba(255,255,255,.92);transition:width .25s linear;pointer-events:none}
|
|
107
|
+
.dvp-audio{display:flex;align-items:center;gap:10px;width:340px;max-width:100%;box-sizing:border-box;padding:8px 8px 8px 8px;border-radius:var(--dsw-radius-lg,12px);border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.28));background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.06));font:12px/16px ${FONT}}
|
|
108
|
+
.dvp-btn{flex:none;width:32px;height:32px;padding:0;border:none;border-radius:50%;display:flex;align-items:center;justify-content:center;color:${PAPER};background:${INK};cursor:pointer;transition:transform .12s ease,opacity .12s ease}
|
|
109
|
+
.dvp-btn:hover{opacity:.86}
|
|
110
|
+
.dvp-btn:active{transform:scale(.94)}
|
|
111
|
+
.dvp-btn:focus-visible{outline:2px solid ${INK};outline-offset:2px}
|
|
112
|
+
.dvp-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
|
|
113
|
+
.dvp-row{display:flex;align-items:baseline;gap:8px}
|
|
114
|
+
.dvp-aname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,inherit)}
|
|
115
|
+
.dvp-time{flex:none;color:var(--dsw-alias-label-tertiary,#888);font-variant-numeric:tabular-nums}
|
|
116
|
+
.dvp-track{position:relative;height:12px;display:flex;align-items:center;cursor:pointer;outline:none;touch-action:none}
|
|
117
|
+
.dvp-rail{position:absolute;left:0;right:0;height:3px;border-radius:2px;background:var(--dsw-alias-border-l2,rgba(127,127,127,.3))}
|
|
118
|
+
.dvp-fill{position:absolute;left:0;height:3px;border-radius:2px;background:${INK}}
|
|
119
|
+
.dvp-knob{position:absolute;width:10px;height:10px;margin-left:-5px;border-radius:50%;background:${INK};opacity:0;transition:opacity .12s ease}
|
|
120
|
+
.dvp-track:hover .dvp-knob,.dvp-track:focus-visible .dvp-knob,.dvp-track.is-drag .dvp-knob{opacity:1}
|
|
121
|
+
.dvp-audio .dvp-dl{color:var(--dsw-alias-label-tertiary,#888);opacity:0}
|
|
122
|
+
.dvp-audio:hover .dvp-dl,.dvp-audio .dvp-dl:focus-visible{opacity:1}
|
|
123
|
+
.dvp-audio .dvp-dl:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit)}
|
|
124
|
+
.dvp-note{width:340px;max-width:100%;box-sizing:border-box;padding:9px 12px;border-radius:var(--dsw-radius-lg,12px);border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.28));background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.06));font:12px/18px ${FONT};color:var(--dsw-alias-label-secondary,#888)}
|
|
125
|
+
.dvp-note.is-wide{width:480px}
|
|
126
|
+
.dvp-note b{display:block;font-weight:500;color:var(--dsw-alias-label-primary,inherit);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
127
|
+
.dvp-err{color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
128
|
+
.dvp-code{display:block;margin-top:6px;padding:6px 8px;border-radius:var(--dsw-radius-sm,6px);font:11px/16px var(--dsw-font-markdown-code-font-family,ui-monospace,Menlo,monospace);background:var(--dsw-alias-markdown-code-block,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit);user-select:all;word-break:break-all}
|
|
129
|
+
.dvp-more{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.28));background:transparent;color:var(--dsw-alias-label-secondary,#888);font:12px/18px ${FONT};cursor:pointer;transition:background .12s ease,color .12s ease}
|
|
130
|
+
.dvp-more:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit)}
|
|
131
|
+
`
|
|
132
|
+
function ensureStyle() {
|
|
133
|
+
try {
|
|
134
|
+
if (typeof document === 'undefined' || document.getElementById('dsh-video-preview-style')) return
|
|
135
|
+
const el = document.createElement('style')
|
|
136
|
+
el.id = 'dsh-video-preview-style'
|
|
137
|
+
el.textContent = CSS
|
|
138
|
+
document.head.appendChild(el)
|
|
139
|
+
} catch (_) { /* 样式挂不上也别影响播放 */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 小工具 ────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function fmtTime(sec) {
|
|
145
|
+
if (!isFinite(sec) || sec < 0) return ''
|
|
146
|
+
const s = Math.floor(sec)
|
|
147
|
+
const hh = Math.floor(s / 3600)
|
|
148
|
+
const mm = Math.floor((s % 3600) / 60)
|
|
149
|
+
const ss = String(s % 60).padStart(2, '0')
|
|
150
|
+
return hh ? `${hh}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 总时长四舍五入;不到 1 秒的显示 0:01,不显示 0:00 */
|
|
154
|
+
function fmtTotal(sec) {
|
|
155
|
+
return sec > 0 && isFinite(sec) ? fmtTime(Math.max(1, Math.round(sec))) : ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fmtSize(n) {
|
|
159
|
+
if (!(n > 0)) return ''
|
|
160
|
+
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + ' GB'
|
|
161
|
+
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
|
|
162
|
+
return Math.max(1, Math.round(n / 1024)) + ' KB'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function statusReason(r) {
|
|
166
|
+
if (!r.headers.get('x-dsh-video-preview')) return 'noServer'
|
|
167
|
+
if (r.status === 404) return 'notFound'
|
|
168
|
+
if (r.status === 401 || r.status === 403) return 'denied'
|
|
169
|
+
return r.ok ? '' : 'failed'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 播放器出错时问一下服务端到底怎么了:文件没了 / 没权限 / 服务没起 / 编码不支持 */
|
|
173
|
+
function diagnose(url) {
|
|
174
|
+
return fetch(url, { method: 'HEAD', credentials: 'same-origin', cache: 'no-store' })
|
|
175
|
+
.then((r) => statusReason(r) || 'unsupported')
|
|
176
|
+
.catch(() => 'noServer')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 卡片一出来就 HEAD 一下:拿文件大小,文件已经没了也能马上说 */
|
|
180
|
+
function probe(url) {
|
|
181
|
+
return fetch(url, { method: 'HEAD', credentials: 'same-origin', cache: 'no-store' })
|
|
182
|
+
.then((r) => ({
|
|
183
|
+
reason: statusReason(r),
|
|
184
|
+
// 插件自己的头优先:Content-Length 可能被宿主的中间件拿掉
|
|
185
|
+
size: Number(r.headers.get('x-dsh-video-size')) || Number(r.headers.get('content-length')) || 0,
|
|
186
|
+
}))
|
|
187
|
+
.catch(() => ({ reason: '', size: 0 }))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function pathOfUrl(url) {
|
|
191
|
+
try { return new URL(url, location.href).searchParams.get('p') || '' } catch (_) { return '' }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** shell 单引号:文件名里的 $(...)、反引号、双引号都原样当字面,单引号写成 '\'' */
|
|
195
|
+
function shq(s) {
|
|
196
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 转码命令:输出名加后缀,不然 a.mp4 → a.mp4 输入输出同名,ffmpeg 不干 */
|
|
200
|
+
function convertCmd(p, kind) {
|
|
201
|
+
const src = p || 'input'
|
|
202
|
+
const base = src.replace(/\.[^./]+$/, '')
|
|
203
|
+
return kind === 'audio'
|
|
204
|
+
? `ffmpeg -i ${shq(src)} -c:a aac ${shq(base + '-aac.m4a')}`
|
|
205
|
+
: `ffmpeg -i ${shq(src)} -c:v libx264 -c:a aac -movflags +faststart ${shq(base + '-h264.mp4')}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 同一时间只让一个出声:带声音开播时把别的停掉(静音预览不算) */
|
|
209
|
+
function solo(el) {
|
|
210
|
+
try {
|
|
211
|
+
if (!el || el.muted || typeof document === 'undefined') return
|
|
212
|
+
document.querySelectorAll('.dvp-media').forEach((m) => { if (m !== el && !m.paused && !m.muted) m.pause() })
|
|
213
|
+
} catch (_) { /* ignore */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const icon = (d, size) => h('svg', { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true },
|
|
217
|
+
h('path', { d, fill: 'currentColor' }))
|
|
218
|
+
const PLAY = 'M5 3.2v9.6c0 .5.6.8 1 .5l7.2-4.8c.4-.3.4-.8 0-1L6 2.7c-.4-.3-1 0-1 .5z'
|
|
219
|
+
const PAUSE = 'M4.5 3h2.2v10H4.5zM9.3 3h2.2v10H9.3z'
|
|
220
|
+
const DL = 'M7.25 2h1.5v6.4l2.2-2.2 1.06 1.06L8 11.27 3.99 7.26l1.06-1.06 2.2 2.2zM3 12.5h10V14H3z'
|
|
221
|
+
|
|
222
|
+
function DownloadLink(props) {
|
|
223
|
+
return h('a', {
|
|
224
|
+
className: 'dvp-dl', href: props.url, download: props.name || 'media',
|
|
225
|
+
title: props.title, 'aria-label': props.title,
|
|
226
|
+
onClick: (e) => e.stopPropagation(),
|
|
227
|
+
}, icon(DL, 14))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 播放器出错 → 先诊断再给人话;换了地址就清掉旧错误 */
|
|
231
|
+
function useMediaError(url) {
|
|
232
|
+
const [reason, setReason] = react.useState('')
|
|
233
|
+
react.useEffect(() => { setReason('') }, [url])
|
|
234
|
+
return [reason, () => { if (url) diagnose(url).then(setReason) }, setReason]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 文件大小 + 一出来就发现的错误 */
|
|
238
|
+
function useProbe(url, setReason) {
|
|
239
|
+
const [meta, setMeta] = react.useState({ size: 0 })
|
|
240
|
+
react.useEffect(() => {
|
|
241
|
+
let alive = true
|
|
242
|
+
if (url) probe(url).then((r) => { if (!alive) return; setMeta({ size: r.size }); if (r.reason) setReason(r.reason) })
|
|
243
|
+
return () => { alive = false }
|
|
244
|
+
}, [url])
|
|
245
|
+
return meta
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 出错卡:说清楚怎么了,编码不支持的附上转码命令 */
|
|
249
|
+
function ErrorCard(props) {
|
|
250
|
+
const { reason, media, t } = props
|
|
251
|
+
const wide = reason === 'unsupported'
|
|
252
|
+
return h('div', { className: 'dvp-note' + (wide ? ' is-wide' : '') },
|
|
253
|
+
h('b', { title: media.name }, media.name || t('media', ZH.media)),
|
|
254
|
+
h('span', { className: 'dvp-err' }, t(reason, ZH[reason] || ZH.failed)),
|
|
255
|
+
wide ? h('code', { className: 'dvp-code' }, convertCmd(pathOfUrl(media.url), media.kind)) : null)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── 视频卡 ────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function VideoTile(props) {
|
|
261
|
+
const { media, t } = props
|
|
262
|
+
const ref = react.useRef(null)
|
|
263
|
+
const [reason, onError, setReason] = useMediaError(media.url)
|
|
264
|
+
const meta = useProbe(media.url, setReason)
|
|
265
|
+
const [state, setState] = react.useState({ active: false, previewing: false, duration: 0, at: 0 })
|
|
266
|
+
const set = (patch) => setState((s) => ({ ...s, ...patch }))
|
|
267
|
+
if (reason) return h(ErrorCard, { reason, media, t })
|
|
268
|
+
|
|
269
|
+
const v = () => ref.current
|
|
270
|
+
const activate = () => {
|
|
271
|
+
const el = v()
|
|
272
|
+
if (!el || state.active) return
|
|
273
|
+
set({ active: true, previewing: false })
|
|
274
|
+
el.loop = !!media.loop
|
|
275
|
+
el.muted = false
|
|
276
|
+
try { el.currentTime = 0 } catch (_) { /* ignore */ }
|
|
277
|
+
const p = el.play()
|
|
278
|
+
if (p && p.catch) p.catch(() => {})
|
|
279
|
+
}
|
|
280
|
+
const enter = () => {
|
|
281
|
+
const el = v()
|
|
282
|
+
if (!el || state.active) return
|
|
283
|
+
el.muted = true
|
|
284
|
+
el.loop = true
|
|
285
|
+
const p = el.play()
|
|
286
|
+
if (p && p.catch) p.catch(() => {})
|
|
287
|
+
set({ previewing: true })
|
|
288
|
+
}
|
|
289
|
+
const leave = () => {
|
|
290
|
+
const el = v()
|
|
291
|
+
if (!el || state.active) return
|
|
292
|
+
el.pause()
|
|
293
|
+
try { el.currentTime = 0.1 } catch (_) { /* ignore */ } // 回到封面那一帧
|
|
294
|
+
set({ previewing: false, at: 0 })
|
|
295
|
+
}
|
|
296
|
+
const sub = [fmtSize(meta.size)].filter(Boolean).join(' · ')
|
|
297
|
+
const playLabel = t('play', ZH.play) + ' ' + (media.name || '')
|
|
298
|
+
|
|
299
|
+
return h('div', {
|
|
300
|
+
className: 'dvp-video' + (state.active ? ' is-active' : '') + (state.previewing ? ' is-previewing' : ''),
|
|
301
|
+
role: state.active ? undefined : 'button',
|
|
302
|
+
tabIndex: state.active ? undefined : 0,
|
|
303
|
+
'aria-label': state.active ? undefined : playLabel,
|
|
304
|
+
onMouseEnter: enter,
|
|
305
|
+
onMouseLeave: leave,
|
|
306
|
+
onClick: activate,
|
|
307
|
+
onKeyDown: (e) => { if (!state.active && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); activate() } },
|
|
308
|
+
},
|
|
309
|
+
h('video', {
|
|
310
|
+
ref,
|
|
311
|
+
className: 'dvp-media',
|
|
312
|
+
// #t=0.1:没开播时也画出第一帧当封面,不是一块黑
|
|
313
|
+
src: media.url + '#t=0.1',
|
|
314
|
+
controls: state.active,
|
|
315
|
+
autoPlay: !!media.autoplay,
|
|
316
|
+
loop: !!media.loop,
|
|
317
|
+
playsInline: true,
|
|
318
|
+
preload: 'metadata',
|
|
319
|
+
onLoadedMetadata: (e) => set({ duration: e.currentTarget.duration || 0 }),
|
|
320
|
+
onDurationChange: (e) => set({ duration: e.currentTarget.duration || 0 }),
|
|
321
|
+
onTimeUpdate: (e) => { if (state.previewing) set({ at: e.currentTarget.currentTime || 0 }) },
|
|
322
|
+
onPlay: (e) => { if (!e.currentTarget.muted) { solo(e.currentTarget); if (!state.active) set({ active: true, previewing: false }) } },
|
|
323
|
+
onError,
|
|
324
|
+
}),
|
|
325
|
+
h('span', { className: 'dvp-play' }, icon(PLAY, 18)),
|
|
326
|
+
h('div', { className: 'dvp-top' },
|
|
327
|
+
h('span', { className: 'dvp-name' }, media.name || t('media', ZH.media)),
|
|
328
|
+
sub ? h('span', { className: 'dvp-sub' }, sub) : null,
|
|
329
|
+
h(DownloadLink, { url: media.url, name: media.name, title: t('download', ZH.download) })),
|
|
330
|
+
state.duration > 0 && isFinite(state.duration) ? h('span', { className: 'dvp-badge' }, fmtTotal(state.previewing ? Math.max(0.01, state.duration - state.at) : state.duration)) : null,
|
|
331
|
+
state.previewing && state.duration > 0 && isFinite(state.duration)
|
|
332
|
+
? h('span', { className: 'dvp-bar', style: { width: Math.min(100, (state.at / state.duration) * 100) + '%' } })
|
|
333
|
+
: null)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── 音频条 ────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
function AudioBar(props) {
|
|
339
|
+
const { media, t } = props
|
|
340
|
+
const ref = react.useRef(null)
|
|
341
|
+
const trackRef = react.useRef(null)
|
|
342
|
+
const [reason, onError, setReason] = useMediaError(media.url)
|
|
343
|
+
const meta = useProbe(media.url, setReason)
|
|
344
|
+
const [state, setState] = react.useState({ playing: false, duration: 0, at: 0, drag: false })
|
|
345
|
+
const set = (patch) => setState((s) => ({ ...s, ...patch }))
|
|
346
|
+
if (reason) return h(ErrorCard, { reason, media, t })
|
|
347
|
+
|
|
348
|
+
const a = () => ref.current
|
|
349
|
+
const toggle = () => {
|
|
350
|
+
const el = a()
|
|
351
|
+
if (!el) return
|
|
352
|
+
if (el.paused) { const p = el.play(); if (p && p.catch) p.catch(() => {}) } else el.pause()
|
|
353
|
+
}
|
|
354
|
+
const seekTo = (clientX) => {
|
|
355
|
+
const el = a()
|
|
356
|
+
const tr = trackRef.current
|
|
357
|
+
if (!el || !tr || !state.duration) return
|
|
358
|
+
const r = tr.getBoundingClientRect()
|
|
359
|
+
const ratio = Math.min(1, Math.max(0, (clientX - r.left) / r.width))
|
|
360
|
+
el.currentTime = ratio * state.duration
|
|
361
|
+
set({ at: el.currentTime })
|
|
362
|
+
}
|
|
363
|
+
const ratio = state.duration ? Math.min(1, state.at / state.duration) : 0
|
|
364
|
+
const total = fmtTotal(state.duration)
|
|
365
|
+
const time = state.at > 0 || state.playing
|
|
366
|
+
? (total ? `${fmtTime(state.at)} / ${total}` : fmtTime(state.at))
|
|
367
|
+
: total
|
|
368
|
+
|
|
369
|
+
return h('div', { className: 'dvp-audio', title: media.name },
|
|
370
|
+
h('audio', {
|
|
371
|
+
ref,
|
|
372
|
+
className: 'dvp-media',
|
|
373
|
+
src: media.url,
|
|
374
|
+
preload: 'metadata',
|
|
375
|
+
autoPlay: !!media.autoplay,
|
|
376
|
+
loop: !!media.loop,
|
|
377
|
+
onLoadedMetadata: (e) => set({ duration: e.currentTarget.duration || 0 }),
|
|
378
|
+
onDurationChange: (e) => set({ duration: e.currentTarget.duration || 0 }),
|
|
379
|
+
onTimeUpdate: (e) => { if (!state.drag) set({ at: e.currentTarget.currentTime || 0 }) },
|
|
380
|
+
onPlay: (e) => { solo(e.currentTarget); set({ playing: true }) },
|
|
381
|
+
onPause: () => set({ playing: false }),
|
|
382
|
+
onEnded: () => set({ playing: false, at: 0 }),
|
|
383
|
+
onError,
|
|
384
|
+
}),
|
|
385
|
+
h('button', {
|
|
386
|
+
className: 'dvp-btn', type: 'button', onClick: toggle,
|
|
387
|
+
'aria-label': state.playing ? t('pause', ZH.pause) : t('play', ZH.play),
|
|
388
|
+
}, icon(state.playing ? PAUSE : PLAY, 14)),
|
|
389
|
+
h('div', { className: 'dvp-body' },
|
|
390
|
+
h('div', { className: 'dvp-row' },
|
|
391
|
+
h('span', { className: 'dvp-aname' }, media.name || t('media', ZH.media)),
|
|
392
|
+
meta.size ? h('span', { className: 'dvp-time' }, fmtSize(meta.size)) : null,
|
|
393
|
+
time ? h('span', { className: 'dvp-time' }, time) : null),
|
|
394
|
+
h('div', {
|
|
395
|
+
ref: trackRef,
|
|
396
|
+
className: 'dvp-track' + (state.drag ? ' is-drag' : ''),
|
|
397
|
+
role: 'slider', tabIndex: 0,
|
|
398
|
+
'aria-label': t('seek', ZH.seek),
|
|
399
|
+
'aria-valuemin': 0, 'aria-valuemax': Math.round(state.duration), 'aria-valuenow': Math.round(state.at),
|
|
400
|
+
'aria-valuetext': time,
|
|
401
|
+
onPointerDown: (e) => { try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) { /* ignore */ } set({ drag: true }); seekTo(e.clientX) },
|
|
402
|
+
onPointerMove: (e) => { if (state.drag) seekTo(e.clientX) },
|
|
403
|
+
onPointerUp: () => set({ drag: false }),
|
|
404
|
+
onPointerCancel: () => set({ drag: false }),
|
|
405
|
+
onKeyDown: (e) => {
|
|
406
|
+
const el = a()
|
|
407
|
+
if (!el || !state.duration) return
|
|
408
|
+
const step = e.key === 'ArrowRight' ? 5 : e.key === 'ArrowLeft' ? -5 : 0
|
|
409
|
+
if (!step) return
|
|
410
|
+
e.preventDefault()
|
|
411
|
+
el.currentTime = Math.min(state.duration, Math.max(0, el.currentTime + step))
|
|
412
|
+
set({ at: el.currentTime })
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
h('span', { className: 'dvp-rail' }),
|
|
416
|
+
h('span', { className: 'dvp-fill', style: { width: ratio * 100 + '%' } }),
|
|
417
|
+
h('span', { className: 'dvp-knob', style: { left: ratio * 100 + '%' } }))),
|
|
418
|
+
h(DownloadLink, { url: media.url, name: media.name, title: t('download', ZH.download) }))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function MediaCard(props) {
|
|
422
|
+
return props.media.kind === 'audio' ? h(AudioBar, props) : h(VideoTile, props)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 翻译函数:优先宿主 locale 服务,查不到回退中文默认值 */
|
|
426
|
+
function makeT(ctx) {
|
|
427
|
+
const loc = ctx && ctx.locale
|
|
428
|
+
if (loc && typeof loc.t === 'function') {
|
|
429
|
+
return (k, def) => loc.t(NS + '.' + k) || loc.t(k) || def || k
|
|
430
|
+
}
|
|
431
|
+
return (k, def) => def || k
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 从工具块里把播放地址挖出来 —— 多个来源都试一遍,稳一点 */
|
|
435
|
+
function readMedia(block) {
|
|
436
|
+
if (!block) return null
|
|
437
|
+
const meta = block.presentationMeta || block.meta || {}
|
|
438
|
+
const call = block.call || {}
|
|
439
|
+
// 工具结果的文本内容:这个框架形态在 content 数组里([{type:'text',text}])
|
|
440
|
+
let text = String(block.output || block.text || '')
|
|
441
|
+
if (Array.isArray(block.content)) {
|
|
442
|
+
text = block.content.map((c) => (c && c.text) || '').join('\n')
|
|
443
|
+
}
|
|
444
|
+
let url = meta.url || ''
|
|
445
|
+
let kind = meta.kind || ''
|
|
446
|
+
let name = meta.name || meta.title || call.title || ''
|
|
447
|
+
let size = meta.size || ''
|
|
448
|
+
|
|
449
|
+
// 兜底:从输出文本里找 /dsh-video-preview/media 开头的地址(地址单独一行,到空白为止;文件名里的括号也算)
|
|
450
|
+
if (!url) {
|
|
451
|
+
const m = text.match(/\/dsh-video-preview\/media\?\S+/)
|
|
452
|
+
if (m) url = m[0]
|
|
453
|
+
if (!name) {
|
|
454
|
+
const n = text.match(/▶️\s*(.+?)(/)
|
|
455
|
+
if (n) name = n[1]
|
|
456
|
+
}
|
|
457
|
+
const sz = text.match(/([^(]*?([\d.]+\s*[KMG]?B))/)
|
|
458
|
+
if (sz) size = sz[1]
|
|
459
|
+
}
|
|
460
|
+
if (!kind) {
|
|
461
|
+
const k = String(text).match(/((视频|音频),/)
|
|
462
|
+
kind = k ? (k[1] === '音频' ? 'audio' : 'video') : 'video'
|
|
463
|
+
}
|
|
464
|
+
if (!url) return null
|
|
465
|
+
return { url, kind, name, size, autoplay: !!meta.autoplay, loop: !!meta.loop }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 工具块里的兜底播放器(展开 play_video 的调用块才看得到) */
|
|
469
|
+
function ToolView(props) {
|
|
470
|
+
const block = props.block
|
|
471
|
+
const t = typeof props.t === 'function' ? props.t : (k, def) => def || k
|
|
472
|
+
const running = !block || block.kind === 'tool-call' || !('content' in block)
|
|
473
|
+
const failed = block && (block.isError || block.error !== undefined)
|
|
474
|
+
if (failed) {
|
|
475
|
+
const msg = ((block.error && (block.error.message || block.error)) || t('failed', ZH.failed)) + ''
|
|
476
|
+
return h('div', { className: 'dvp-note is-wide' }, h('span', { className: 'dvp-err' }, msg))
|
|
477
|
+
}
|
|
478
|
+
const media = running ? null : readMedia(block)
|
|
479
|
+
if (!media) return h('div', { className: 'dvp-note' }, running ? t('preparing', ZH.preparing) : t('noUrl', ZH.noUrl))
|
|
480
|
+
return h('div', { className: 'dvp-list' }, h(MediaCard, { media, t }))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── turnTail 内联播放器:这一轮 present 过的视频直接渲染在最终输出下方 ─────────
|
|
484
|
+
// 官方 deliverables 把 presented 文件挂在 turn.data 上并在 turnTail 画芯片;
|
|
485
|
+
// 我们在同一个链上追加一个条目,把媒体文件渲染成真正的播放器。
|
|
486
|
+
|
|
487
|
+
function TurnTailPlayers(props) {
|
|
488
|
+
const turn = props.turn
|
|
489
|
+
const t = typeof props.t === 'function' ? props.t : (k, def) => def || k
|
|
490
|
+
const [open, setOpen] = react.useState(false)
|
|
491
|
+
const data = turn && typeof turn.data !== 'undefined' && turn.data && typeof turn.data.get === 'function' ? turn.data : null
|
|
492
|
+
const files = data ? (data.get('deliverables')?.presented ?? []) : []
|
|
493
|
+
const seen = new Set()
|
|
494
|
+
const items = []
|
|
495
|
+
for (const f of files) {
|
|
496
|
+
const p = String((f && f.path) || '')
|
|
497
|
+
const isAudio = AUDIO_RE.test(p)
|
|
498
|
+
// 只管播得了的格式;avi 之类留给原生文件芯片,不给一个放不出来的黑框
|
|
499
|
+
if (!isAudio && !VIDEO_RE.test(p)) continue
|
|
500
|
+
if (seen.has(p)) continue
|
|
501
|
+
seen.add(p)
|
|
502
|
+
// play_video 的 title / autoplay / loop 跟着交付文件一起写进来
|
|
503
|
+
items.push({
|
|
504
|
+
url: '/dsh-video-preview/preview?p=' + encodeURIComponent(p),
|
|
505
|
+
kind: isAudio ? 'audio' : 'video',
|
|
506
|
+
name: (typeof f.title === 'string' && f.title) || p.split('/').pop() || p,
|
|
507
|
+
autoplay: !!f.autoplay,
|
|
508
|
+
loop: !!f.loop,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
if (!items.length) return null
|
|
512
|
+
// 多了先折叠:一轮生成一堆的时候不刷屏(多出来的只差一个就不折,省得「还有 1 个」)
|
|
513
|
+
const fold = items.length > FOLD_AT + 1 && !open
|
|
514
|
+
const shown = fold ? items.slice(0, FOLD_AT) : items
|
|
515
|
+
const rest = items.length - shown.length
|
|
516
|
+
return h('div', { className: 'dvp-list' },
|
|
517
|
+
shown.map((m) => h(MediaCard, { key: m.url, media: m, t })),
|
|
518
|
+
fold
|
|
519
|
+
? h('button', { className: 'dvp-more', type: 'button', onClick: () => setOpen(true) },
|
|
520
|
+
t('more', ZH.more).replace('{n}', String(rest)), ' ▾')
|
|
521
|
+
: items.length > FOLD_AT + 1
|
|
522
|
+
? h('button', { className: 'dvp-more', type: 'button', onClick: () => setOpen(false) }, t('less', ZH.less), ' ▴')
|
|
523
|
+
: null)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── 侧边栏视频预览:注册 documentPreviews 渲染器,预览面板直接变播放器 ────────
|
|
527
|
+
const MEDIA_EXTS = ['mp4', 'm4v', 'mov', 'webm', 'ogv', 'mkv', '3gp',
|
|
528
|
+
'mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus', 'weba']
|
|
529
|
+
|
|
530
|
+
const isAbsPath = (p) => /^(\/|[A-Za-z]:[\\/])/.test(p)
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 地址 → 地址里写的路径(照 DSH 的 parseFileAddress,一段一段解码):
|
|
534
|
+
* dsh-resource://file/session/<会话>/<路径> 工作区里的文件是相对路径;工作区外的绝对路径前面多一个 /(地址里是 //)
|
|
535
|
+
* dsh-resource://file/absolute/<去掉开头 / 的绝对路径>
|
|
536
|
+
* 相对路径要靠宿主解析成真实路径(见 VideoPreviewBody),这里不猜。
|
|
537
|
+
*/
|
|
538
|
+
function previewPathOf(resourceAddress) {
|
|
539
|
+
let s = ''
|
|
540
|
+
if (!resourceAddress) return ''
|
|
541
|
+
if (typeof resourceAddress === 'string') s = resourceAddress
|
|
542
|
+
else if (typeof resourceAddress.path === 'string') return resourceAddress.path
|
|
543
|
+
else return ''
|
|
544
|
+
if (s.indexOf('dsh-resource://') === 0) {
|
|
545
|
+
if (s.indexOf('dsh-resource://file/') !== 0) return ''
|
|
546
|
+
try {
|
|
547
|
+
const end = s.search(/[?#]/)
|
|
548
|
+
const [scope, ...rest] = s.slice('dsh-resource://file/'.length, end === -1 ? undefined : end).split('/')
|
|
549
|
+
if (scope === 'session' && rest.length > 1 && rest[0]) return rest.slice(1).map(decodeURIComponent).join('/')
|
|
550
|
+
if (scope === 'absolute' && rest.length && rest[0]) return '/' + rest.map(decodeURIComponent).join('/')
|
|
551
|
+
} catch (_) { /* 编码坏了 */ }
|
|
552
|
+
return ''
|
|
553
|
+
}
|
|
554
|
+
if (s.indexOf('file://') === 0) return decodeURIComponent(s.replace(/^file:\/\//, ''))
|
|
555
|
+
return s
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function VideoPreviewBody(props) {
|
|
559
|
+
const t = typeof props.t === 'function' ? props.t : (k, def) => def || k
|
|
560
|
+
// 宿主解析好的真实路径:会话工作区里的文件,地址里只有相对路径,得靠它(宿主给每个预览体都传了 useResource)
|
|
561
|
+
const res = typeof props.useResource === 'function'
|
|
562
|
+
? props.useResource(typeof props.resourceAddress === 'string' ? props.resourceAddress : '')
|
|
563
|
+
: null
|
|
564
|
+
const hostPath = res && res.value && typeof res.value.absolutePath === 'string' ? res.value.absolutePath : ''
|
|
565
|
+
const raw = previewPathOf(props.resourceAddress)
|
|
566
|
+
const p = hostPath || (isAbsPath(raw) ? raw : '')
|
|
567
|
+
const src = p ? '/dsh-video-preview/preview?p=' + encodeURIComponent(p) : ''
|
|
568
|
+
const [reason, onError] = useMediaError(src)
|
|
569
|
+
if (!p) {
|
|
570
|
+
// 相对路径还在等宿主解析 / 宿主说文件不在 / 压根没有路径
|
|
571
|
+
const status = res ? res.status : ''
|
|
572
|
+
const key = status === 'loading' ? 'preparing' : status === 'failed' ? 'notFound' : 'noPath'
|
|
573
|
+
return h('p', { style: { padding: 16, color: '#888', fontSize: 13 } }, t(key, ZH[key]))
|
|
574
|
+
}
|
|
575
|
+
const audio = AUDIO_RE.test(p)
|
|
576
|
+
const media = { url: src, kind: audio ? 'audio' : 'video', name: p.split('/').pop() || p }
|
|
577
|
+
if (reason) {
|
|
578
|
+
return h('div', { style: { padding: 16 } }, h(ErrorCard, { reason, media, t }))
|
|
579
|
+
}
|
|
580
|
+
const player = audio
|
|
581
|
+
? h('div', { style: { flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', padding: '0 16px' } },
|
|
582
|
+
h('audio', { src, controls: true, preload: 'metadata', onError, className: 'dvp-media', style: { width: '100%' } }))
|
|
583
|
+
: h('video', {
|
|
584
|
+
src, controls: true, playsInline: true, preload: 'metadata', onError, className: 'dvp-media',
|
|
585
|
+
onPlay: (e) => solo(e.currentTarget),
|
|
586
|
+
style: { flex: 1, width: '100%', minHeight: 0, objectFit: 'contain', background: '#000' },
|
|
587
|
+
})
|
|
588
|
+
return h('div', { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' } },
|
|
589
|
+
player,
|
|
590
|
+
h('div', { style: { padding: '6px 12px', fontSize: 12, color: '#aaa', background: '#111', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, media.name))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 问一下现在是谁在应答 /dsh-video-preview/*:服务端每个回应都带 X-DSH-Video-Preview 头,
|
|
595
|
+
* 新版的值是包名,旧版(dsh-video-preview)是 '1'。旧版还装着时新版服务端整个让开,
|
|
596
|
+
* 新版客户端也得让开,不然两份抢同一批槽位。头都没有 = 路由还没挂上(宿主刚起),过一会儿再问。
|
|
597
|
+
*/
|
|
598
|
+
function whoServes(done) {
|
|
599
|
+
let stopped = false
|
|
600
|
+
let timer = null
|
|
601
|
+
let tries = 0
|
|
602
|
+
const ask = () => {
|
|
603
|
+
fetch('/dsh-video-preview/preview', { method: 'HEAD', credentials: 'same-origin', cache: 'no-store' })
|
|
604
|
+
.then((r) => r.headers.get('x-dsh-video-preview'), () => null)
|
|
605
|
+
.then((who) => {
|
|
606
|
+
if (stopped) return
|
|
607
|
+
if (who) return done(who)
|
|
608
|
+
if (++tries < 10) timer = setTimeout(ask, Math.min(1000 * 2 ** tries, 30000))
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
ask()
|
|
612
|
+
return () => { stopped = true; clearTimeout(timer) }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function apply(ctx) {
|
|
616
|
+
if (!ctx || !ctx.slots) return
|
|
617
|
+
if (typeof fetch !== 'function') return mount(ctx)
|
|
618
|
+
const wait = () => whoServes((who) => {
|
|
619
|
+
if (who === PKG) mount(ctx)
|
|
620
|
+
else console.warn('[' + PKG + '] 旧版 dsh-video-preview 还装着,这份先不挂;到插件页移除旧版并重启 DSH')
|
|
621
|
+
})
|
|
622
|
+
if (ctx.effect) ctx.effect(wait, PKG + ': wait for server')
|
|
623
|
+
else wait()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function mount(ctx) {
|
|
627
|
+
ensureStyle()
|
|
628
|
+
|
|
629
|
+
// 注册词典(en/zh):失败不致命,t() 会回退到中文默认值
|
|
630
|
+
try {
|
|
631
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
632
|
+
if (ctx.effect) ctx.effect(() => ctx.locale.register(NS, { en: EN, zh: ZH }), 'dsh-video-preview: locale')
|
|
633
|
+
else ctx.locale.register(NS, { en: EN, zh: ZH })
|
|
634
|
+
} else if (ctx.locale && typeof ctx.locale.define === 'function') {
|
|
635
|
+
ctx.locale.define('en', NS, EN)
|
|
636
|
+
ctx.locale.define('zh', NS, ZH)
|
|
637
|
+
}
|
|
638
|
+
} catch (_) { /* 词典注册失败不挡播放器 */ }
|
|
639
|
+
|
|
640
|
+
const t = makeT(ctx)
|
|
641
|
+
const register = () => ctx.slots.register(
|
|
642
|
+
{ name: 'tool.call.toolview', key: 'play_video', locale: NS },
|
|
643
|
+
(props) => h(ToolView, { ...props, t })
|
|
644
|
+
)
|
|
645
|
+
// turnTail:这一轮 present 过的媒体 → 内联播放器,直接出现在最终输出下方
|
|
646
|
+
const registerTail = () => ctx.slots.register(
|
|
647
|
+
{ name: 'conversation.chat.turnTail', id: 'dsh-video-preview', locale: NS },
|
|
648
|
+
(props) => h(TurnTailPlayers, { ...props, t })
|
|
649
|
+
)
|
|
650
|
+
// 侧边栏视频预览渲染器:deliverable 芯片点「在侧边栏预览」→ 这里播放
|
|
651
|
+
const registerPreview = () => {
|
|
652
|
+
try {
|
|
653
|
+
if (ctx.documentPreviews && typeof ctx.documentPreviews.register === 'function') {
|
|
654
|
+
ctx.documentPreviews.register({
|
|
655
|
+
id: 'dsh-video-preview',
|
|
656
|
+
extensions: MEDIA_EXTS,
|
|
657
|
+
binaryExtensions: MEDIA_EXTS,
|
|
658
|
+
priority: 'plugin',
|
|
659
|
+
title: () => t('previewTitle', ZH.previewTitle),
|
|
660
|
+
loading: 'renderer',
|
|
661
|
+
wrap: false,
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
} catch (e) { console.warn('[dsh-video-preview] 预览渲染器注册失败:', e) }
|
|
665
|
+
ctx.slots.register(
|
|
666
|
+
{ name: 'sidebar.right.tab.document', key: 'dsh-video-preview', locale: NS },
|
|
667
|
+
(props) => h(VideoPreviewBody, { ...props, t })
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
if (typeof ctx.slots.inject === 'function') {
|
|
673
|
+
ctx.slots.inject('tool.call.toolview', () => {
|
|
674
|
+
try { register() } catch (e) { console.warn('[dsh-video-preview] 注册失败:', e) }
|
|
675
|
+
})
|
|
676
|
+
ctx.slots.inject('conversation.chat.turnTail', () => {
|
|
677
|
+
try { registerTail() } catch (e) { console.warn('[dsh-video-preview] turnTail 注册失败:', e) }
|
|
678
|
+
})
|
|
679
|
+
ctx.slots.inject('sidebar.right.tab.document', () => {
|
|
680
|
+
try { registerPreview() } catch (e) { console.warn('[dsh-video-preview] 预览槽注册失败:', e) }
|
|
681
|
+
})
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
} catch (e) { console.warn('[dsh-video-preview] slots.inject 失败,改用直接注册:', e) }
|
|
685
|
+
|
|
686
|
+
try { register() } catch (e) { console.warn('[dsh-video-preview] 直接注册失败:', e) }
|
|
687
|
+
try { registerTail() } catch (e) { console.warn('[dsh-video-preview] turnTail 直接注册失败:', e) }
|
|
688
|
+
try { registerPreview() } catch (e) { console.warn('[dsh-video-preview] 预览槽直接注册失败:', e) }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
exports.apply = apply
|
|
692
|
+
// 注意:这里是**客户端服务名**(短的),不是包名。写包名会一直 pending。
|
|
693
|
+
exports.inject = ['slots', 'locale', 'documentPreviews']
|
|
694
|
+
return module.exports
|
|
695
|
+
},
|
|
696
|
+
})
|