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.
- package/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/media-modes.png +0 -0
- package/assets/screenshots/overlay-countdown.png +0 -0
- package/assets/screenshots/procedural-overview.png +0 -0
- package/assets/screenshots/settings-narrow.png +0 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.js +3828 -0
- package/package.json +70 -4
- package/screenshots.json +6 -0
- package/src/cities.js +165 -0
- package/src/client/api.js +140 -0
- package/src/client/birds.js +112 -0
- package/src/client/format.js +28 -0
- package/src/client/i18n.js +250 -0
- package/src/client/index.jsx +163 -0
- package/src/client/overlay.jsx +260 -0
- package/src/client/palette.js +391 -0
- package/src/client/rhythm.js +538 -0
- package/src/client/sea-canvas.jsx +1290 -0
- package/src/client/settings-panel.jsx +430 -0
- package/src/client/sound.js +163 -0
- package/src/client/styles.js +217 -0
- package/src/client/water.js +330 -0
- package/src/host.js +458 -0
- package/src/media.js +423 -0
- package/src/settings.js +322 -0
- package/src/sky.js +428 -0
- package/src/store.js +169 -0
|
@@ -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
|
+
}
|