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,430 @@
1
+ // 潮间 —— 设置页(`settings.section`)。读写都走宿主 `/intertidal/settings`,改完即时生效。
2
+ //
3
+ // 这里刻意只做"表单 + 一次 POST",不做本地乐观状态机:settings 的唯一真相在宿主(它还会做
4
+ // 范围钳制),客户端拿到返回值后由 runtime 广播。
5
+
6
+ import { useEffect, useState } from 'react'
7
+ import { CITIES } from '../cities.js'
8
+ import { DEFAULTS } from '../settings.js'
9
+
10
+ function Row({ label, hint, children, stack = false }) {
11
+ return (
12
+ <div className="it-row" data-stack={stack ? '1' : '0'}>
13
+ <div className="it-row-label">
14
+ <span>{label}</span>
15
+ {hint ? <small>{hint}</small> : null}
16
+ </div>
17
+ <div className="it-row-control">{children}</div>
18
+ </div>
19
+ )
20
+ }
21
+
22
+ function Switch({ checked, onChange, label }) {
23
+ return (
24
+ <button
25
+ type="button"
26
+ className="it-switch"
27
+ role="switch"
28
+ aria-checked={checked ? 'true' : 'false'}
29
+ aria-label={label}
30
+ onClick={() => onChange(!checked)}
31
+ />
32
+ )
33
+ }
34
+
35
+ const DURATIONS = [60, 180, 300, 600]
36
+ const INTERVALS = [15, 30, 60, 90, 120, 180, 240, 360, 480]
37
+
38
+ export default function SettingsPanel({ runtime, t = (k) => k, getLang = () => 'zh', subscribeLang, pickDirectory }) {
39
+ const [state, setState] = useState(() => runtime.getState())
40
+ const lang = typeof getLang === 'function' ? getLang() : 'zh'
41
+ const [, bumpLang] = useState(0)
42
+ useEffect(() => {
43
+ if (typeof subscribeLang !== 'function') return undefined
44
+ const unsub = subscribeLang(() => bumpLang((value) => value + 1))
45
+ return () => unsub?.()
46
+ }, [subscribeLang])
47
+ const [custom, setCustom] = useState(false)
48
+ const [scan, setScan] = useState(null)
49
+ const [fetchNote, setFetchNote] = useState(null)
50
+
51
+
52
+ useEffect(() => runtime.subscribe(setState), [runtime])
53
+
54
+ const settings = state.settings ?? DEFAULTS
55
+ const save = (patch) => void runtime.saveSettings(patch)
56
+ const cityName = (city) => (lang === 'en' ? city.en : city.zh)
57
+
58
+ const nextText = (() => {
59
+ if (!settings.auto) return t('settings.next.off')
60
+ const due = state.nextDueMs
61
+ if (!due) return '—'
62
+ const diff = due - Date.now()
63
+ if (diff <= 0) return t('settings.next.soon')
64
+ const mins = Math.round(diff / 60_000)
65
+ return mins >= 60 ? `${Math.round(mins / 60)} ${lang === 'en' ? 'h' : '小时'}` : `${mins} ${t('unit.minutes')}`
66
+ })()
67
+
68
+ return (
69
+ <div className="it-settings">
70
+ <div className="it-group">
71
+ <h4>{t('settings.group.rhythm')}</h4>
72
+ <Row label={t('settings.auto')} hint={t('settings.auto.hint')}>
73
+ <Switch checked={settings.auto} label={t('settings.auto')} onChange={(value) => save({ auto: value })} />
74
+ </Row>
75
+ <Row label={t('settings.interval')} hint={t('settings.interval.hint')}>
76
+ <select
77
+ value={settings.intervalMinutes}
78
+ onChange={(event) => save({ intervalMinutes: Number(event.target.value) })}
79
+ >
80
+ {INTERVALS.map((minutes) => (
81
+ <option key={minutes} value={minutes}>
82
+ {minutes} {t('unit.minutes')}
83
+ </option>
84
+ ))}
85
+ </select>
86
+ </Row>
87
+ <Row label={t('settings.duration')} stack>
88
+ <select
89
+ value={DURATIONS.includes(settings.durationSeconds) ? settings.durationSeconds : 'custom'}
90
+ onChange={(event) => {
91
+ if (event.target.value === 'custom') return
92
+ save({ durationSeconds: Number(event.target.value) })
93
+ }}
94
+ >
95
+ {DURATIONS.map((seconds) => (
96
+ <option key={seconds} value={seconds}>
97
+ {seconds >= 60 ? `${seconds / 60} ${t('unit.minutes')}` : `${seconds} ${t('unit.seconds')}`}
98
+ </option>
99
+ ))}
100
+ {DURATIONS.includes(settings.durationSeconds) ? null : (
101
+ <option value="custom">
102
+ {settings.durationSeconds} {t('unit.seconds')}
103
+ </option>
104
+ )}
105
+ </select>
106
+ <input
107
+ type="number"
108
+ min={30}
109
+ max={1800}
110
+ step={10}
111
+ className="it-field-sm"
112
+ value={settings.durationSeconds}
113
+ onChange={(event) => save({ durationSeconds: Number(event.target.value) })}
114
+ />
115
+ </Row>
116
+ <Row label={t('settings.firstDelay')} hint={t('settings.firstDelay.hint')}>
117
+ <input
118
+ type="number"
119
+ min={0}
120
+ max={120}
121
+ step={5}
122
+ className="it-field-sm"
123
+ value={settings.firstDelayMinutes}
124
+ onChange={(event) => save({ firstDelayMinutes: Number(event.target.value) })}
125
+ />
126
+ </Row>
127
+ <Row label={t('settings.quiet')} hint={t('settings.quiet.hint')}>
128
+ <Switch
129
+ checked={settings.quietHours.enabled}
130
+ label={t('settings.quiet')}
131
+ onChange={(value) => save({ quietHours: { ...settings.quietHours, enabled: value } })}
132
+ />
133
+ <input
134
+ type="time"
135
+ value={settings.quietHours.start}
136
+ onChange={(event) => save({ quietHours: { ...settings.quietHours, start: event.target.value } })}
137
+ />
138
+ <span style={{ opacity: 0.5 }}>–</span>
139
+ <input
140
+ type="time"
141
+ value={settings.quietHours.end}
142
+ onChange={(event) => save({ quietHours: { ...settings.quietHours, end: event.target.value } })}
143
+ />
144
+ </Row>
145
+ <Row label={t('settings.deferTyping')} hint={t('settings.deferTyping.hint')}>
146
+ <Switch
147
+ checked={settings.deferOnTyping}
148
+ label={t('settings.deferTyping')}
149
+ onChange={(value) => save({ deferOnTyping: value })}
150
+ />
151
+ </Row>
152
+ <Row label={t('settings.manualExtends')} hint={t('settings.manualExtends.hint')}>
153
+ <Switch
154
+ checked={settings.manualExtends}
155
+ label={t('settings.manualExtends')}
156
+ onChange={(value) => save({ manualExtends: value })}
157
+ />
158
+ </Row>
159
+ <Row label={t('settings.next')}>
160
+ <span className="it-hint">{nextText}</span>
161
+ </Row>
162
+ </div>
163
+
164
+ <div className="it-group">
165
+ <h4>{t('settings.group.form')}</h4>
166
+ <Row label={t('settings.form')}>
167
+ <select value={settings.form} onChange={(event) => save({ form: event.target.value })}>
168
+ <option value="full">{t('settings.form.full')}</option>
169
+ <option value="pip">{t('settings.form.pip')}</option>
170
+ </select>
171
+ </Row>
172
+ <Row label={t('settings.rememberForm')}>
173
+ <Switch
174
+ checked={settings.rememberForm}
175
+ label={t('settings.rememberForm')}
176
+ onChange={(value) => save({ rememberForm: value })}
177
+ />
178
+ </Row>
179
+ <Row label={t('settings.caption')} hint={t('settings.caption.hint')}>
180
+ <Switch checked={settings.caption} label={t('settings.caption')} onChange={(value) => save({ caption: value })} />
181
+ </Row>
182
+ </div>
183
+
184
+ <div className="it-group">
185
+ <h4>{t('settings.group.media')}</h4>
186
+ <Row label={t('settings.media.mode')} hint={t('settings.media.hint')}>
187
+ <select
188
+ value={settings.media.mode}
189
+ onChange={(event) => save({ media: { ...settings.media, mode: event.target.value } })}
190
+ >
191
+ <option value="off">{t('settings.media.off')}</option>
192
+ <option value="dir">{t('settings.media.dir')}</option>
193
+ <option value="url">{t('settings.media.url')}</option>
194
+ </select>
195
+ </Row>
196
+
197
+ {settings.media.mode === 'dir' ? (
198
+ <>
199
+ <Row label={t('settings.media.dir')} hint={t('settings.media.dir.hint')} stack>
200
+ <input
201
+ type="text"
202
+ className="it-field"
203
+ placeholder="/Users/you/Pictures/sea"
204
+ value={settings.media.dir}
205
+ onChange={(event) => save({ media: { ...settings.media, dir: event.target.value } })}
206
+ />
207
+ {typeof pickDirectory === 'function' ? (
208
+ <button
209
+ type="button"
210
+ className="it-action"
211
+ onClick={async () => {
212
+ try {
213
+ const picked = await pickDirectory()
214
+ if (typeof picked === 'string' && picked) {
215
+ save({ media: { ...settings.media, dir: picked } })
216
+ const res = await runtime.scanMedia(picked)
217
+ setScan(res?.ok ? res.value : { ok: false })
218
+ }
219
+ } catch {
220
+ setScan({ ok: false })
221
+ }
222
+ }}
223
+ >
224
+ {t('settings.media.pick')}
225
+ </button>
226
+ ) : null}
227
+ <button
228
+ type="button"
229
+ className="it-action"
230
+ onClick={async () => {
231
+ const res = await runtime.scanMedia(settings.media.dir)
232
+ setScan(res?.ok ? res.value : { ok: false })
233
+ }}
234
+ >
235
+ {t('settings.media.scan')}
236
+ </button>
237
+ </Row>
238
+ {scan ? (
239
+ <div className="it-hint it-row-note" style={{ paddingBottom: 6 }}>
240
+ {scan.ok
241
+ ? scan.count > 0
242
+ ? `${t('settings.media.scanOk')}:${scan.count}(${scan.images} / ${scan.videos})`
243
+ : t('settings.media.scanEmpty')
244
+ : t('settings.media.scanFail')}
245
+ </div>
246
+ ) : null}
247
+ <Row label={t('settings.media.shuffle')}>
248
+ <Switch
249
+ checked={settings.media.shuffle}
250
+ label={t('settings.media.shuffle')}
251
+ onChange={(value) => save({ media: { ...settings.media, shuffle: value } })}
252
+ />
253
+ </Row>
254
+ </>
255
+ ) : null}
256
+
257
+ {settings.media.mode === 'url' ? (
258
+ <>
259
+ <Row label={t('settings.media.urls')} hint={t('settings.media.urls.hint')} stack>
260
+ <textarea
261
+ rows={4}
262
+ className="it-field"
263
+ value={settings.media.urls.join('\n')}
264
+ onChange={(event) =>
265
+ save({ media: { ...settings.media, urls: event.target.value.split('\n') } })
266
+ }
267
+ />
268
+ </Row>
269
+ <Row label={t('settings.media.fetch')}>
270
+ <button
271
+ type="button"
272
+ className="it-action"
273
+ onClick={async () => {
274
+ setFetchNote(null)
275
+ const urls = settings.media.urls.slice(0, 8)
276
+ let ok = 0
277
+ for (const url of urls) {
278
+ const res = await runtime.resolveMedia(url)
279
+ if (res?.ok && res.value?.item) ok += 1
280
+ }
281
+ setFetchNote(ok > 0 ? t('settings.media.fetchOk') : t('settings.media.fetchFail'))
282
+ }}
283
+ >
284
+ {t('settings.media.fetch')}
285
+ </button>
286
+ {fetchNote ? <span className="it-hint">{fetchNote}</span> : null}
287
+ </Row>
288
+ </>
289
+ ) : null}
290
+
291
+ {settings.media.mode !== 'off' ? (
292
+ <>
293
+ <Row label={t('settings.media.grade')} hint={t('settings.media.grade.hint')}>
294
+ <input
295
+ type="range"
296
+ min={0}
297
+ max={1}
298
+ step={0.05}
299
+ value={settings.media.grade}
300
+ aria-label={t('settings.media.grade')}
301
+ onChange={(event) => save({ media: { ...settings.media, grade: Number(event.target.value) } })}
302
+ />
303
+ </Row>
304
+ <Row label={t('settings.media.motion')} hint={t('settings.media.motion.hint')}>
305
+ <input
306
+ type="range"
307
+ min={0}
308
+ max={1}
309
+ step={0.05}
310
+ value={settings.media.motion}
311
+ aria-label={t('settings.media.motion')}
312
+ onChange={(event) => save({ media: { ...settings.media, motion: Number(event.target.value) } })}
313
+ />
314
+ </Row>
315
+ </>
316
+ ) : null}
317
+ </div>
318
+
319
+ <div className="it-group">
320
+ <h4>{t('settings.group.place')}</h4>
321
+ <Row label={t('settings.city')}>
322
+ <select
323
+ value={custom ? '__custom__' : settings.location.city}
324
+ onChange={(event) => {
325
+ if (event.target.value === '__custom__') {
326
+ setCustom(true)
327
+ return
328
+ }
329
+ setCustom(false)
330
+ const city = CITIES.find((item) => item.id === event.target.value)
331
+ if (!city) return
332
+ save({ location: { city: city.id, lat: city.lat, lon: city.lon, tz: city.tz } })
333
+ }}
334
+ >
335
+ {CITIES.map((city) => (
336
+ <option key={city.id} value={city.id}>
337
+ {cityName(city)}
338
+ </option>
339
+ ))}
340
+ <option value="__custom__">{t('settings.city.custom')}</option>
341
+ </select>
342
+ </Row>
343
+ {custom ? (
344
+ <Row label={t('settings.city.custom')} stack>
345
+ <input
346
+ type="number"
347
+ step="0.01"
348
+ className="it-field-sm"
349
+ value={settings.location.lat}
350
+ aria-label={t('settings.lat')}
351
+ onChange={(event) =>
352
+ save({ location: { ...settings.location, lat: Number(event.target.value) } })
353
+ }
354
+ />
355
+ <input
356
+ type="number"
357
+ step="0.01"
358
+ className="it-field-sm"
359
+ value={settings.location.lon}
360
+ aria-label={t('settings.lon')}
361
+ onChange={(event) =>
362
+ save({ location: { ...settings.location, lon: Number(event.target.value) } })
363
+ }
364
+ />
365
+ <input
366
+ type="text"
367
+ className="it-field"
368
+ value={settings.location.tz}
369
+ aria-label={t('settings.tz')}
370
+ onChange={(event) => save({ location: { ...settings.location, tz: event.target.value } })}
371
+ />
372
+ </Row>
373
+ ) : null}
374
+ </div>
375
+
376
+ <div className="it-group">
377
+ <h4>{t('settings.group.sound')}</h4>
378
+ <Row label={t('settings.sound')} hint={t('settings.sound.hint')}>
379
+ <Switch
380
+ checked={settings.sound.enabled}
381
+ label={t('settings.sound')}
382
+ onChange={(value) => {
383
+ if (value) runtime.unlockSound()
384
+ save({ sound: { ...settings.sound, enabled: value } })
385
+ if (!value) runtime.getState().sound?.disable?.()
386
+ }}
387
+ />
388
+ <input
389
+ type="range"
390
+ min={0}
391
+ max={1}
392
+ step={0.01}
393
+ value={settings.sound.volume}
394
+ aria-label={t('settings.volume')}
395
+ onChange={(event) => save({ sound: { ...settings.sound, volume: Number(event.target.value) } })}
396
+ />
397
+ </Row>
398
+ </div>
399
+
400
+ <div className="it-group">
401
+ <h4>{t('settings.group.access')}</h4>
402
+ <Row label={t('settings.reducedMotion')} hint={t('settings.reducedMotion.hint')}>
403
+ <select
404
+ value={settings.reducedMotion}
405
+ onChange={(event) => save({ reducedMotion: event.target.value })}
406
+ >
407
+ <option value="auto">{t('settings.reducedMotion.auto')}</option>
408
+ <option value="on">{t('settings.reducedMotion.on')}</option>
409
+ <option value="off">{t('settings.reducedMotion.off')}</option>
410
+ </select>
411
+ </Row>
412
+ </div>
413
+
414
+ <div className="it-group">
415
+ <div className="it-actions">
416
+ <button type="button" className="it-action" data-primary="1" onClick={() => runtime.show('manual')}>
417
+ {t('action.preview')}
418
+ </button>
419
+ <button type="button" className="it-action" onClick={() => void runtime.skipToday()}>
420
+ {t('action.skip')}
421
+ </button>
422
+ </div>
423
+ {state.offline ? <div className="it-warn">{t('hint.offline')}</div> : null}
424
+ <div className="it-hint">
425
+ {t('hint.keys')} · {t('settings.stateFile')}: ~/.dsh/intertidal/state.json
426
+ </div>
427
+ </div>
428
+ </div>
429
+ )
430
+ }
@@ -0,0 +1,163 @@
1
+ // 潮间 —— WebAudio 合成海浪(默认关)。
2
+ //
3
+ // 不加载任何素材:白噪声 → 带通(中心频率 400–1200 Hz 随"浪高"扫动)→ 增益;
4
+ // 再用 0.08 Hz 的 LFO 慢慢推增益,听感像潮水一层一层来。
5
+ //
6
+ // 浏览器自动播放策略:AudioContext 必须在一次用户手势里创建/resume。所以 `enable()`
7
+ // 只能由点击触发;`enable()` 里失败就安静地返回 false,绝不弹错。
8
+
9
+ const FADE_IN = 0.15
10
+ const FADE_OUT = 0.4
11
+
12
+ export function createSurfSound() {
13
+ let ctx = null
14
+ let source = null
15
+ let gain = null
16
+ let lfo = null
17
+ let lfoGain = null
18
+ let filter = null
19
+ let enabled = false
20
+ let volume = 0.35
21
+
22
+ function ensureContext() {
23
+ if (ctx) return ctx
24
+ const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext
25
+ if (!Ctor) return null
26
+ try {
27
+ ctx = new Ctor()
28
+ return ctx
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /** 2 秒的白噪声缓冲,循环播放。 */
35
+ function noiseBuffer(audio) {
36
+ const length = Math.floor(audio.sampleRate * 2)
37
+ const buffer = audio.createBuffer(1, length, audio.sampleRate)
38
+ const data = buffer.getChannelData(0)
39
+ let last = 0
40
+ for (let i = 0; i < length; i += 1) {
41
+ const white = Math.random() * 2 - 1
42
+ // 轻微低通,去掉最刺耳的高频嘶声
43
+ last = last * 0.92 + white * 0.08
44
+ data[i] = last * 3.2
45
+ }
46
+ return buffer
47
+ }
48
+
49
+ /** 在一次用户手势里调用。成功返回 true。 */
50
+ function enable(nextVolume = volume) {
51
+ const audio = ensureContext()
52
+ if (!audio) return false
53
+ try {
54
+ volume = clampVolume(nextVolume)
55
+ if (audio.state === 'suspended') void audio.resume()
56
+ if (!source) {
57
+ source = audio.createBufferSource()
58
+ source.buffer = noiseBuffer(audio)
59
+ source.loop = true
60
+
61
+ filter = audio.createBiquadFilter()
62
+ filter.type = 'bandpass'
63
+ filter.frequency.value = 700
64
+ filter.Q.value = 0.6
65
+
66
+ gain = audio.createGain()
67
+ gain.gain.value = 0
68
+
69
+ // LFO:0.08 Hz,把带通中心频率在 400–1200 Hz 之间来回扫。
70
+ lfo = audio.createOscillator()
71
+ lfo.type = 'sine'
72
+ lfo.frequency.value = 0.08
73
+ lfoGain = audio.createGain()
74
+ lfoGain.gain.value = 400
75
+ lfo.connect(lfoGain)
76
+ lfoGain.connect(filter.frequency)
77
+ lfo.start()
78
+
79
+ source.connect(filter)
80
+ filter.connect(gain)
81
+ gain.connect(audio.destination)
82
+ source.start()
83
+ }
84
+ const now = audio.currentTime
85
+ gain.gain.cancelScheduledValues(now)
86
+ gain.gain.setValueAtTime(gain.gain.value, now)
87
+ gain.gain.linearRampToValueAtTime(volume, now + FADE_IN)
88
+ enabled = true
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ function disable() {
96
+ enabled = false
97
+ const audio = ctx
98
+ if (!audio || !gain) return
99
+ try {
100
+ const now = audio.currentTime
101
+ gain.gain.cancelScheduledValues(now)
102
+ gain.gain.setValueAtTime(gain.gain.value, now)
103
+ gain.gain.linearRampToValueAtTime(0, now + FADE_OUT)
104
+ } catch {
105
+ /* 忽略 */
106
+ }
107
+ }
108
+
109
+ function setVolume(next) {
110
+ volume = clampVolume(next)
111
+ if (!ctx || !gain || !enabled) return
112
+ try {
113
+ const now = ctx.currentTime
114
+ gain.gain.cancelScheduledValues(now)
115
+ gain.gain.linearRampToValueAtTime(volume, now + FADE_IN)
116
+ } catch {
117
+ /* 忽略 */
118
+ }
119
+ }
120
+
121
+ /** 卸载时调用:先淡出,再彻底拆掉。 */
122
+ function dispose() {
123
+ disable()
124
+ const audio = ctx
125
+ const nodes = { source, lfo }
126
+ ctx = null
127
+ source = null
128
+ lfo = null
129
+ filter = null
130
+ lfoGain = null
131
+ gain = null
132
+ if (!audio) return
133
+ setTimeout(() => {
134
+ try {
135
+ nodes.source?.stop()
136
+ } catch {
137
+ /* 已经停了 */
138
+ }
139
+ try {
140
+ nodes.lfo?.stop()
141
+ } catch {
142
+ /* 已经停了 */
143
+ }
144
+ void audio.close?.().catch?.(() => {})
145
+ }, 500)
146
+ }
147
+
148
+ return {
149
+ enable,
150
+ disable,
151
+ setVolume,
152
+ dispose,
153
+ get enabled() {
154
+ return enabled
155
+ },
156
+ }
157
+ }
158
+
159
+ function clampVolume(value) {
160
+ const n = Number(value)
161
+ if (!Number.isFinite(n)) return 0.35
162
+ return Math.min(1, Math.max(0, n))
163
+ }