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,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
|
+
}
|