dsh-intertidal 0.0.0-stage → 0.2.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.
@@ -0,0 +1,538 @@
1
+ // 潮间 —— 客户端节奏(计时器 + 抑制规则 + 退场计时)。这是"来得多恰好、退得多干净"的地方。
2
+ //
3
+ // 设计约束:
4
+ // * 计时以**上次浮现结束**(lastHideAt)为基准,不是 lastShownAt —— 否则浮现三分钟里
5
+ // 间隔就在走,等于每 1 小时 57 分来一次。
6
+ // * 客户端负责秒级计时(无 IPC 开销),宿主只记 lastShownAt/lastHideAt,刷新后节奏不重置。
7
+ // * 浮现后**不再**因为你打字而缩窗/退出(spec §5.3 末段)。只有到点、Esc、或自己按 C 才变。
8
+ // * 所有副作用都可失败:宿主不在,就按镜像设置 + 本地计时继续工作。
9
+
10
+ import { DEFAULTS, mergeSettings } from '../settings.js'
11
+
12
+ export const SUPPRESS = Object.freeze({
13
+ autoOff: 'auto-off',
14
+ quiet: 'quiet',
15
+ typing: 'typing',
16
+ dialog: 'dialog',
17
+ skippedToday: 'skipped-today',
18
+ reducedMotion: 'reduced-motion',
19
+ })
20
+
21
+ const FADE = { manual: 600, timeout: 3000, pip: 1500 }
22
+ const INPUT_IDLE_MS = 20_000
23
+ const INPUT_MAX_WAIT_MS = 10 * 60_000
24
+ const TICK_MS = 1000
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 纯函数(可单测,不碰 DOM)
28
+
29
+ /** 'HH:MM' → 当天分钟数;非法返回 null。 */
30
+ export function parseClock(text) {
31
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? '').trim())
32
+ if (!m) return null
33
+ const h = Number(m[1])
34
+ const min = Number(m[2])
35
+ if (h > 23 || min > 59) return null
36
+ return h * 60 + min
37
+ }
38
+
39
+ /** 是否落在静默时段(支持跨零点,如 23:00–07:00)。 */
40
+ export function isQuietTime(quiet, date) {
41
+ if (!quiet || quiet.enabled !== true) return false
42
+ const start = parseClock(quiet.start)
43
+ const end = parseClock(quiet.end)
44
+ if (start === null || end === null) return false
45
+ if (start === end) return false
46
+ const nowMin = date.getHours() * 60 + date.getMinutes()
47
+ return start < end ? nowMin >= start && nowMin < end : nowMin >= start || nowMin < end
48
+ }
49
+
50
+ /** 本地日期 YYYY-MM-DD。 */
51
+ export function localDateKey(date) {
52
+ const y = date.getFullYear()
53
+ const m = String(date.getMonth() + 1).padStart(2, '0')
54
+ const d = String(date.getDate()).padStart(2, '0')
55
+ return `${y}-${m}-${d}`
56
+ }
57
+
58
+ export function skippedToday(rhythm, date) {
59
+ const list = Array.isArray(rhythm?.skipDates) ? rhythm.skipDates : []
60
+ return list.includes(localDateKey(date))
61
+ }
62
+
63
+ /**
64
+ * 下一次定时浮现的时刻(ms)。
65
+ * anchor = max(lastHideAt, 手动看过时的 lastShownAt);都没有 → installedAt + firstDelay。
66
+ */
67
+ export function nextDueAt(settings, rhythm, nowMs = Date.now()) {
68
+ const intervalMs = Math.max(1, Number(settings?.intervalMinutes) || DEFAULTS.intervalMinutes) * 60_000
69
+ const firstDelayMs = Math.max(0, Number(settings?.firstDelayMinutes ?? DEFAULTS.firstDelayMinutes)) * 60_000
70
+ const hide = Number(rhythm?.lastHideAt) || 0
71
+ const shown = Number(rhythm?.lastShownAt) || 0
72
+ const anchorSec = settings?.manualExtends === false ? hide : Math.max(hide, shown)
73
+ if (!anchorSec) {
74
+ const installed = Number(rhythm?.installedAt) || 0
75
+ // 刚装、还没写过 installedAt:从现在起算首次延迟
76
+ return (installed ? installed * 1000 : nowMs) + firstDelayMs
77
+ }
78
+ return anchorSec * 1000 + intervalMs
79
+ }
80
+
81
+ /**
82
+ * 输入抑制:刚有输入就等(R3)。等的是"从到点开始算"的 10 分钟——超时就放弃本次。
83
+ * @param lastInputAt 最后一次键盘/鼠标事件的时间(ms),0 表示没记录
84
+ * @param nowMs 现在
85
+ * @param dueAt 本次原本该浮现的时刻
86
+ */
87
+ export function inputSuppression(lastInputAt, nowMs, dueAt = nowMs, { idleMs = INPUT_IDLE_MS, maxWaitMs = INPUT_MAX_WAIT_MS } = {}) {
88
+ if (!lastInputAt) return { waiting: false, giveUp: false }
89
+ if (nowMs - lastInputAt >= idleMs) return { waiting: false, giveUp: false }
90
+ return { waiting: true, giveUp: nowMs - dueAt > maxWaitMs }
91
+ }
92
+
93
+ /** prefers-reduced-motion 与设置合并成"本次是否要减动态"。 */
94
+ export function reducedMotionActive(setting, systemReduce) {
95
+ if (setting === 'on') return true
96
+ if (setting === 'off') return false
97
+ return Boolean(systemReduce)
98
+ }
99
+
100
+ const PHASE_TEXT = { dawn: 'dawn', noon: 'noon', afternoon: 'afternoon', dusk: 'dusk', night: 'night' }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // 运行时
104
+
105
+ /**
106
+ * @param {{ api: any, storage?: Storage, doc?: Document, win?: Window, now?: () => number }} deps
107
+ */
108
+ export function createRuntime({ api, storage = globalThis.localStorage, doc = globalThis.document, win = globalThis.window, now = () => Date.now() } = {}) {
109
+ const listeners = new Set()
110
+ let timer = null
111
+ let disposed = false
112
+ let lastInputAt = 0
113
+ let soundUnlocked = false
114
+
115
+ const state = {
116
+ ready: false,
117
+ offline: false,
118
+ settings: null,
119
+ rhythm: null,
120
+ sky: null,
121
+ media: null,
122
+ form: 'hidden',
123
+ exiting: null,
124
+ paused: false,
125
+ pausedRemaining: 0,
126
+ deadline: 0,
127
+ remainingMs: 0,
128
+ nextDueMs: null,
129
+ suppression: null,
130
+ stats: null,
131
+ note: null,
132
+ }
133
+
134
+ const snapshot = () => ({
135
+ ...state,
136
+ settings: state.settings ? { ...state.settings } : null,
137
+ rhythm: state.rhythm ? { ...state.rhythm } : null,
138
+ })
139
+
140
+ function emit() {
141
+ const value = snapshot()
142
+ for (const fn of listeners) {
143
+ try {
144
+ fn(value)
145
+ } catch {
146
+ /* 订阅者自己抛,别连坐 */
147
+ }
148
+ }
149
+ }
150
+
151
+ function subscribe(fn) {
152
+ listeners.add(fn)
153
+ try {
154
+ fn(snapshot())
155
+ } catch {
156
+ /* 忽略 */
157
+ }
158
+ return () => listeners.delete(fn)
159
+ }
160
+
161
+ function note(text) {
162
+ state.note = text
163
+ emit()
164
+ setTimeout(() => {
165
+ if (state.note !== text) return
166
+ state.note = null
167
+ emit()
168
+ }, 4000)
169
+ }
170
+
171
+ const reducedMotion = () => reducedMotionActive(state.settings?.reducedMotion, systemReduceMotion())
172
+
173
+ function systemReduceMotion() {
174
+ try {
175
+ return Boolean(win?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
176
+ } catch {
177
+ return false
178
+ }
179
+ }
180
+
181
+ // ---- 输入空闲 -------------------------------------------------------------
182
+
183
+ function markInput() {
184
+ // 只在"没在看海"时记——看得正开心的时候按键盘不该影响下一次。
185
+ if (state.form !== 'hidden') return
186
+ lastInputAt = now()
187
+ }
188
+
189
+ const inputEvents = ['keydown', 'pointerdown', 'mousemove', 'wheel', 'touchstart']
190
+ function bindInput() {
191
+ for (const name of inputEvents) win?.addEventListener?.(name, markInput, { passive: true })
192
+ }
193
+ function unbindInput() {
194
+ for (const name of inputEvents) win?.removeEventListener?.(name, markInput)
195
+ }
196
+
197
+ /** 有模态对话在就推迟:R1 的兜底,宁可晚来一次,也不能遮住审批。 */
198
+ function hasBlockingDialog() {
199
+ try {
200
+ const nodes = doc?.querySelectorAll?.('[role="dialog"], [role="alertdialog"]') ?? []
201
+ for (const node of nodes) {
202
+ if (node?.offsetParent !== null || node?.getClientRects?.().length) return true
203
+ }
204
+ } catch {
205
+ /* 查询失败就当没有 */
206
+ }
207
+ return false
208
+ }
209
+
210
+ // ---- 触发判定 -------------------------------------------------------------
211
+
212
+ /** 现在该不该自动浮现;不该则给出抑制原因。 */
213
+ function autoSuppression(at = now()) {
214
+ if (!state.settings?.auto) return SUPPRESS.autoOff
215
+ if (reducedMotion()) return SUPPRESS.reducedMotion
216
+ if (isQuietTime(state.settings.quietHours, new Date(at))) return SUPPRESS.quiet
217
+ if (skippedToday(state.rhythm, new Date(at))) return SUPPRESS.skippedToday
218
+ if (state.settings.deferOnTyping && inputSuppression(lastInputAt, at, state.nextDueMs ?? at).waiting) return SUPPRESS.typing
219
+ if (hasBlockingDialog()) return SUPPRESS.dialog
220
+ return null
221
+ }
222
+
223
+ function computeNextDue() {
224
+ state.nextDueMs = nextDueAt(state.settings, state.rhythm, now())
225
+ return state.nextDueMs
226
+ }
227
+
228
+ // ---- 浮现 / 退场 -----------------------------------------------------------
229
+
230
+ async function show(kind) {
231
+ if (state.form !== 'hidden' || state.exiting) return
232
+ const settings = state.settings ?? DEFAULTS
233
+ const form = settings.form === 'pip' ? 'pip' : 'full'
234
+ const durationMs = Math.max(1, Number(settings.durationSeconds) || DEFAULTS.durationSeconds) * 1000
235
+ state.form = form
236
+ state.deadline = now() + durationMs
237
+ state.remainingMs = durationMs
238
+ state.paused = false
239
+ state.pausedRemaining = 0
240
+ state.exiting = null
241
+ state.suppression = kind === 'auto' ? state.suppression : null
242
+ // 声音只在用户已经给过手势(设置里点过"开")时才自动响,否则静默排队。
243
+ if (settings.sound?.enabled && soundUnlocked) state.sound?.enable?.(settings.sound.volume)
244
+ emit()
245
+ // 每次浮现换一张素材(目录/链接模式);拿不到就继续用程序化海。
246
+ void pickMedia(true)
247
+
248
+ // 宿主只记时间戳(用于重启后节奏不重置);失败不影响看海。
249
+ void api?.rhythm?.(kind === 'auto' ? 'shown' : 'manual-open')?.then?.((res) => {
250
+ if (res?.ok && res.value) {
251
+ state.rhythm = { ...res.value }
252
+ emit()
253
+ }
254
+ })
255
+ refreshSky()
256
+ }
257
+
258
+ function hide(kind = 'manual') {
259
+ if (state.form === 'hidden' || state.exiting) return
260
+ const fade = FADE[kind] ?? FADE.manual
261
+ state.exiting = { kind, until: now() + fade }
262
+ emit()
263
+ setTimeout(() => finalizeHide(), fade)
264
+ }
265
+
266
+ function finalizeHide() {
267
+ if (state.form === 'hidden') return
268
+ state.form = 'hidden'
269
+ state.exiting = null
270
+ state.deadline = 0
271
+ state.remainingMs = 0
272
+ state.paused = false
273
+ state.pausedRemaining = 0
274
+ state.sound?.disable?.()
275
+ emit()
276
+ void api?.rhythm?.('hidden')?.then?.((res) => {
277
+ if (res?.ok && res.value) {
278
+ state.rhythm = { ...res.value }
279
+ computeNextDue()
280
+ emit()
281
+ }
282
+ })
283
+ }
284
+
285
+ function toggleForm() {
286
+ if (state.form === 'hidden') return
287
+ state.form = state.form === 'full' ? 'pip' : 'full'
288
+ if (state.settings?.rememberForm) void saveSettings({ form: state.form })
289
+ emit()
290
+ }
291
+
292
+ function toPip() {
293
+ if (state.form !== 'full') return
294
+ toggleForm()
295
+ }
296
+
297
+ function togglePause() {
298
+ if (state.form === 'hidden' || state.exiting) return
299
+ if (state.paused) {
300
+ state.paused = false
301
+ state.deadline = now() + state.pausedRemaining
302
+ state.pausedRemaining = 0
303
+ } else {
304
+ state.paused = true
305
+ state.pausedRemaining = Math.max(0, state.deadline - now())
306
+ state.deadline = 0
307
+ }
308
+ emit()
309
+ }
310
+
311
+ async function skipToday() {
312
+ const res = await api?.rhythm?.('skip-today')
313
+ if (res?.ok && res.value) {
314
+ state.rhythm = { ...res.value }
315
+ } else {
316
+ // 宿主不在也要能止损:本地记一天。
317
+ const list = Array.isArray(state.rhythm?.skipDates) ? [...state.rhythm.skipDates] : []
318
+ const key = localDateKey(new Date())
319
+ if (!list.includes(key)) list.push(key)
320
+ state.rhythm = { ...(state.rhythm ?? {}), skipDates: list }
321
+ }
322
+ if (state.form !== 'hidden') hide('manual')
323
+ computeNextDue()
324
+ emit()
325
+ return state.rhythm
326
+ }
327
+
328
+ // ---- 设置与天文 ------------------------------------------------------------
329
+
330
+ async function saveSettings(patch) {
331
+ const merged = mergeSettings(state.settings ?? DEFAULTS, patch)
332
+ state.settings = merged
333
+ emit()
334
+ if (patch?.sound && state.sound?.setVolume) state.sound.setVolume(merged.sound.volume)
335
+ const res = await api?.saveSettings?.(patch)
336
+ if (res?.ok && res.value) {
337
+ state.settings = mergeSettings(res.value, {})
338
+ state.offline = false
339
+ } else if (res) {
340
+ state.offline = true
341
+ note('hint.offline')
342
+ }
343
+ computeNextDue()
344
+ if (patch?.media) void pickMedia(true)
345
+ emit()
346
+ return state.settings
347
+ }
348
+
349
+ /**
350
+ * 抽一张素材。`fresh` 为真时换一张(每次浮现都换);失败或未启用就让 media 保持 null,
351
+ * SeaCanvas 会退回程序化海。
352
+ */
353
+ async function pickMedia(fresh = false) {
354
+ const media = state.settings?.media
355
+ if (!media || media.mode === 'off') {
356
+ if (state.media) {
357
+ state.media = null
358
+ emit()
359
+ }
360
+ return null
361
+ }
362
+ let res = null
363
+ try {
364
+ res = await api?.media?.(fresh)
365
+ } catch {
366
+ res = null
367
+ }
368
+ const item = res?.ok ? (res.value?.item ?? null) : null
369
+ state.media = item
370
+ emit()
371
+ return item
372
+ }
373
+
374
+ async function refreshSky() {
375
+ const location = state.settings?.location
376
+ const res = await api?.now?.(location)
377
+ if (res?.ok && res.value) state.sky = res.value
378
+ else if (!state.sky) state.sky = null
379
+ emit()
380
+ }
381
+
382
+ // ---- 主循环 ----------------------------------------------------------------
383
+
384
+ function tick() {
385
+ if (disposed) return
386
+ const at = now()
387
+
388
+ if (state.form !== 'hidden') {
389
+ if (!state.exiting) {
390
+ if (state.paused) {
391
+ state.remainingMs = state.pausedRemaining
392
+ emit()
393
+ } else {
394
+ state.remainingMs = Math.max(0, state.deadline - at)
395
+ if (state.remainingMs <= 0) {
396
+ hide(state.form === 'pip' ? 'pip' : 'timeout')
397
+ return
398
+ }
399
+ emit()
400
+ }
401
+ }
402
+ return
403
+ }
404
+
405
+ computeNextDue()
406
+ const due = state.nextDueMs !== null && at >= state.nextDueMs
407
+ if (!due) {
408
+ emit()
409
+ return
410
+ }
411
+ const reason = autoSuppression(at)
412
+ state.suppression = reason
413
+ if (!reason) {
414
+ void show('auto')
415
+ return
416
+ }
417
+ // 输入抑制等到"给放弃为止":放弃本次 → 直接把节奏往后推一个间隔。
418
+ if (reason === SUPPRESS.typing && state.settings?.deferOnTyping) {
419
+ const input = inputSuppression(lastInputAt, at, state.nextDueMs ?? at)
420
+ if (input.giveUp) {
421
+ lastInputAt = 0
422
+ void api?.rhythm?.('hidden')?.then?.((res) => {
423
+ if (res?.ok && res.value) {
424
+ state.rhythm = { ...res.value }
425
+ computeNextDue()
426
+ emit()
427
+ }
428
+ })
429
+ }
430
+ }
431
+ emit()
432
+ }
433
+
434
+ // ---- 生命周期 --------------------------------------------------------------
435
+
436
+ async function init() {
437
+ if (state.ready || disposed) return
438
+ let res = null
439
+ try {
440
+ res = await api?.state?.()
441
+ } catch {
442
+ res = null
443
+ }
444
+ if (res?.ok && res.value) {
445
+ state.settings = mergeSettings(res.value.settings, {})
446
+ state.rhythm = res.value.rhythm ?? null
447
+ state.sky = res.value.now ?? null
448
+ state.offline = false
449
+ } else {
450
+ state.settings = mergeSettings(res?.value?.settings ?? null, {})
451
+ state.rhythm = res?.value?.rhythm ?? null
452
+ state.sky = res?.value?.now ?? null
453
+ state.offline = true
454
+ }
455
+ // dispose 可能在 await 期间已经发生过(插件被卸载 / 测试收尾)——不能再留一个计时器。
456
+ if (disposed) return
457
+ state.ready = true
458
+ computeNextDue()
459
+ bindInput()
460
+ timer = setInterval(tick, TICK_MS)
461
+ timer?.unref?.()
462
+ emit()
463
+ // 首帧不开海,先等一个 tick:避免"刚打开 DSH 就糊一脸"。
464
+ }
465
+
466
+ function dispose() {
467
+ disposed = true
468
+ if (timer) clearInterval(timer)
469
+ timer = null
470
+ unbindInput()
471
+ listeners.clear()
472
+ state.sound?.dispose?.()
473
+ state.form = 'hidden'
474
+ }
475
+
476
+ /** 覆盖层把实测帧率报回来(验收用;也用于设置页显示降级状态)。 */
477
+ function reportStats(stats) {
478
+ state.stats = stats
479
+ emit()
480
+ }
481
+
482
+ function attachSound(sound) {
483
+ state.sound = sound
484
+ return () => {
485
+ sound?.dispose?.()
486
+ if (state.sound === sound) state.sound = null
487
+ }
488
+ }
489
+
490
+ /** 设置页里"开声音"必须走一次手势。 */
491
+ function unlockSound() {
492
+ soundUnlocked = true
493
+ return state.sound?.enable?.(state.settings?.sound?.volume ?? DEFAULTS.sound.volume) ?? false
494
+ }
495
+
496
+ return {
497
+ getState: snapshot,
498
+ subscribe,
499
+ init,
500
+ dispose,
501
+ show,
502
+ hide,
503
+ toggleForm,
504
+ toPip,
505
+ togglePause,
506
+ skipToday,
507
+ saveSettings,
508
+ refreshSky,
509
+ tick,
510
+ attachSound,
511
+ pickMedia,
512
+ /** 设置页"检查目录"用:交给宿主扫一遍,返回 {ok,count,images,videos,names}。 */
513
+ async scanMedia(dir) {
514
+ try {
515
+ return (await api?.scanMedia?.(dir)) ?? { ok: false }
516
+ } catch {
517
+ return { ok: false }
518
+ }
519
+ },
520
+ /** 设置页"抓取并缓存"用。 */
521
+ async resolveMedia(url) {
522
+ try {
523
+ return (await api?.resolveMedia?.(url)) ?? { ok: false }
524
+ } catch {
525
+ return { ok: false }
526
+ }
527
+ },
528
+ reportStats,
529
+ unlockSound,
530
+ phaseText: (phase) => PHASE_TEXT[phase] ?? 'afternoon',
531
+ get reducedMotion() {
532
+ return reducedMotion()
533
+ },
534
+ get soundUnlocked() {
535
+ return soundUnlocked
536
+ },
537
+ }
538
+ }