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
package/dist/client.js
ADDED
|
@@ -0,0 +1,3828 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-intertidal",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name2 in all)
|
|
13
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
24
|
+
|
|
25
|
+
// src/client/index.jsx
|
|
26
|
+
var index_exports = {};
|
|
27
|
+
__export(index_exports, {
|
|
28
|
+
apply: () => apply,
|
|
29
|
+
inject: () => inject,
|
|
30
|
+
name: () => name
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/client/overlay.jsx
|
|
35
|
+
var import_react2 = require("react");
|
|
36
|
+
|
|
37
|
+
// src/client/sea-canvas.jsx
|
|
38
|
+
var import_react = require("react");
|
|
39
|
+
|
|
40
|
+
// src/client/palette.js
|
|
41
|
+
var HORIZON = 0.52;
|
|
42
|
+
var MIN_VISIBLE_ALT = -1;
|
|
43
|
+
var POS_MIN = -0.2;
|
|
44
|
+
var POS_MAX = 1.2;
|
|
45
|
+
var FALLBACK_PHASE = "afternoon";
|
|
46
|
+
var PHASE_NAMES = Object.freeze(["dawn", "noon", "afternoon", "dusk", "night"]);
|
|
47
|
+
var ALT = Object.freeze({ night: -14, twilight: 0, afternoon: 16, noon: 48 });
|
|
48
|
+
var BASE = Object.freeze({
|
|
49
|
+
// 晨曦:橘粉 → 淡青,低对比雾感(海面几乎与天同调)
|
|
50
|
+
dawn: Object.freeze({
|
|
51
|
+
skyTop: "#5d7f9d",
|
|
52
|
+
skyMid: "#c69b9c",
|
|
53
|
+
skyBottom: "#f3b78e",
|
|
54
|
+
horizonGlow: "#ffd2a3",
|
|
55
|
+
seaFar: "#9aa8b0",
|
|
56
|
+
seaMid: "#6b8492",
|
|
57
|
+
seaNear: "#4a6673",
|
|
58
|
+
foam: "#f7e9dd",
|
|
59
|
+
sunColor: "#ffd9a8",
|
|
60
|
+
moonColor: "#eef2f8",
|
|
61
|
+
stars: 0.18,
|
|
62
|
+
exposure: 0.72
|
|
63
|
+
}),
|
|
64
|
+
// 正午:高饱和蓝,强反光白浪
|
|
65
|
+
noon: Object.freeze({
|
|
66
|
+
skyTop: "#0f63c8",
|
|
67
|
+
skyMid: "#3f9fe0",
|
|
68
|
+
skyBottom: "#b7e1f6",
|
|
69
|
+
horizonGlow: "#f4fbff",
|
|
70
|
+
seaFar: "#3d8fc4",
|
|
71
|
+
seaMid: "#1f74a8",
|
|
72
|
+
seaNear: "#0d5182",
|
|
73
|
+
foam: "#ffffff",
|
|
74
|
+
sunColor: "#fffdf0",
|
|
75
|
+
moonColor: "#eaf2ff",
|
|
76
|
+
stars: 0,
|
|
77
|
+
exposure: 1
|
|
78
|
+
}),
|
|
79
|
+
// 午后:蓝偏暖(青蓝到米黄的地平线,海偏青),标准反差
|
|
80
|
+
afternoon: Object.freeze({
|
|
81
|
+
skyTop: "#2c78a8",
|
|
82
|
+
skyMid: "#79b1c4",
|
|
83
|
+
skyBottom: "#e8ddbd",
|
|
84
|
+
horizonGlow: "#ffe9c2",
|
|
85
|
+
seaFar: "#4a94a6",
|
|
86
|
+
seaMid: "#2a7185",
|
|
87
|
+
seaNear: "#194f63",
|
|
88
|
+
foam: "#fdf8ea",
|
|
89
|
+
sunColor: "#fff3cf",
|
|
90
|
+
moonColor: "#eef2f8",
|
|
91
|
+
stars: 0,
|
|
92
|
+
exposure: 0.94
|
|
93
|
+
}),
|
|
94
|
+
// 黄昏:金橙 → 绛紫,逆光金路
|
|
95
|
+
dusk: Object.freeze({
|
|
96
|
+
skyTop: "#372a5b",
|
|
97
|
+
skyMid: "#8c4a77",
|
|
98
|
+
skyBottom: "#f5a04f",
|
|
99
|
+
horizonGlow: "#ffbe6a",
|
|
100
|
+
seaFar: "#8a6172",
|
|
101
|
+
seaMid: "#523a55",
|
|
102
|
+
seaNear: "#2c2039",
|
|
103
|
+
foam: "#ffe7cd",
|
|
104
|
+
sunColor: "#ffb35a",
|
|
105
|
+
moonColor: "#f0ecf4",
|
|
106
|
+
stars: 0.22,
|
|
107
|
+
exposure: 0.62
|
|
108
|
+
}),
|
|
109
|
+
// 夜:深靛 → 近黑,月光带
|
|
110
|
+
night: Object.freeze({
|
|
111
|
+
skyTop: "#04060f",
|
|
112
|
+
skyMid: "#0a1128",
|
|
113
|
+
skyBottom: "#182a4a",
|
|
114
|
+
horizonGlow: "#2c4570",
|
|
115
|
+
seaFar: "#16253d",
|
|
116
|
+
seaMid: "#0d1729",
|
|
117
|
+
seaNear: "#060b16",
|
|
118
|
+
foam: "#b9c8db",
|
|
119
|
+
sunColor: "#ffeccd",
|
|
120
|
+
moonColor: "#e6eeff",
|
|
121
|
+
stars: 0.9,
|
|
122
|
+
exposure: 0.3
|
|
123
|
+
})
|
|
124
|
+
});
|
|
125
|
+
var COLOR_KEYS = Object.freeze([
|
|
126
|
+
"skyTop",
|
|
127
|
+
"skyMid",
|
|
128
|
+
"skyBottom",
|
|
129
|
+
"horizonGlow",
|
|
130
|
+
"seaFar",
|
|
131
|
+
"seaMid",
|
|
132
|
+
"seaNear",
|
|
133
|
+
"foam",
|
|
134
|
+
"sunColor",
|
|
135
|
+
"moonColor"
|
|
136
|
+
]);
|
|
137
|
+
var NUM_KEYS = Object.freeze(["stars", "exposure"]);
|
|
138
|
+
function num(value, fallback) {
|
|
139
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
140
|
+
}
|
|
141
|
+
function clamp(value, lo, hi) {
|
|
142
|
+
return value < lo ? lo : value > hi ? hi : value;
|
|
143
|
+
}
|
|
144
|
+
function clamp01(value) {
|
|
145
|
+
return clamp(num(value, 0), 0, 1);
|
|
146
|
+
}
|
|
147
|
+
function round(value, digits) {
|
|
148
|
+
const base = Math.pow(10, digits);
|
|
149
|
+
return Math.round(num(value, 0) * base) / base;
|
|
150
|
+
}
|
|
151
|
+
function parseHex(color) {
|
|
152
|
+
if (typeof color !== "string") return null;
|
|
153
|
+
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
let hex = match[1];
|
|
156
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
157
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
158
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
159
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
160
|
+
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) return null;
|
|
161
|
+
return [r, g, b];
|
|
162
|
+
}
|
|
163
|
+
function toHex(channels) {
|
|
164
|
+
const part = (value) => {
|
|
165
|
+
const byte = Math.round(clamp(num(value, 0), 0, 255));
|
|
166
|
+
return (byte < 16 ? "0" : "") + byte.toString(16);
|
|
167
|
+
};
|
|
168
|
+
return "#" + part(channels[0]) + part(channels[1]) + part(channels[2]);
|
|
169
|
+
}
|
|
170
|
+
function mix(a, b, t) {
|
|
171
|
+
const ca = parseHex(a);
|
|
172
|
+
const cb = parseHex(b);
|
|
173
|
+
if (!ca && !cb) return "#000000";
|
|
174
|
+
if (!ca) return toHex(cb);
|
|
175
|
+
if (!cb) return toHex(ca);
|
|
176
|
+
const k = clamp01(t);
|
|
177
|
+
return toHex([
|
|
178
|
+
ca[0] + (cb[0] - ca[0]) * k,
|
|
179
|
+
ca[1] + (cb[1] - ca[1]) * k,
|
|
180
|
+
ca[2] + (cb[2] - ca[2]) * k
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
function normalizePhase(phase) {
|
|
184
|
+
if (typeof phase !== "string") return null;
|
|
185
|
+
const key = phase.trim().toLowerCase();
|
|
186
|
+
return PHASE_NAMES.indexOf(key) >= 0 ? key : null;
|
|
187
|
+
}
|
|
188
|
+
function phasePalette(phase) {
|
|
189
|
+
const key = normalizePhase(phase) || FALLBACK_PHASE;
|
|
190
|
+
const base = BASE[key];
|
|
191
|
+
const out = { phase: key };
|
|
192
|
+
for (const name2 of COLOR_KEYS) out[name2] = base[name2];
|
|
193
|
+
for (const name2 of NUM_KEYS) out[name2] = base[name2];
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
function hardFallback() {
|
|
197
|
+
return {
|
|
198
|
+
skyTop: "#1f6fb4",
|
|
199
|
+
skyMid: "#63a7d2",
|
|
200
|
+
skyBottom: "#dcd9c6",
|
|
201
|
+
horizonGlow: "#ffeccb",
|
|
202
|
+
seaFar: "#3b87ab",
|
|
203
|
+
seaMid: "#21688d",
|
|
204
|
+
seaNear: "#12486a",
|
|
205
|
+
foam: "#fdfaf0",
|
|
206
|
+
sun: null,
|
|
207
|
+
moon: null,
|
|
208
|
+
stars: 0,
|
|
209
|
+
exposure: 0.93
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function blendPalette(a, b, k) {
|
|
213
|
+
const t = clamp01(k);
|
|
214
|
+
const out = {};
|
|
215
|
+
for (const name2 of COLOR_KEYS) out[name2] = mix(a[name2], b[name2], t);
|
|
216
|
+
for (const name2 of NUM_KEYS) out[name2] = clamp01(a[name2] + (b[name2] - a[name2]) * t);
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
function twilightKey(phase, azimuth) {
|
|
220
|
+
if (phase === "dawn" || phase === "dusk") return phase;
|
|
221
|
+
if (typeof azimuth === "number" && Number.isFinite(azimuth)) {
|
|
222
|
+
const normalized = (azimuth % 360 + 360) % 360;
|
|
223
|
+
return normalized < 180 ? "dawn" : "dusk";
|
|
224
|
+
}
|
|
225
|
+
return "dusk";
|
|
226
|
+
}
|
|
227
|
+
function ladder(altitude, phase, azimuth) {
|
|
228
|
+
const alt = clamp(num(altitude, 0), -90, 90);
|
|
229
|
+
const twilight = BASE[twilightKey(phase, azimuth)];
|
|
230
|
+
if (alt <= ALT.night) return phasePalette("night");
|
|
231
|
+
if (alt < ALT.twilight) {
|
|
232
|
+
const k = (alt - ALT.night) / (ALT.twilight - ALT.night);
|
|
233
|
+
return blendPalette(BASE.night, twilight, k);
|
|
234
|
+
}
|
|
235
|
+
if (alt < ALT.afternoon) return blendPalette(twilight, BASE.afternoon, alt / ALT.afternoon);
|
|
236
|
+
if (alt < ALT.noon) {
|
|
237
|
+
const k = (alt - ALT.afternoon) / (ALT.noon - ALT.afternoon);
|
|
238
|
+
return blendPalette(BASE.afternoon, BASE.noon, k);
|
|
239
|
+
}
|
|
240
|
+
return phasePalette("noon");
|
|
241
|
+
}
|
|
242
|
+
function readAltitude(body) {
|
|
243
|
+
if (!body || typeof body !== "object") return null;
|
|
244
|
+
const altitude = typeof body.altitude === "number" ? body.altitude : NaN;
|
|
245
|
+
return Number.isFinite(altitude) ? clamp(altitude, -90, 90) : null;
|
|
246
|
+
}
|
|
247
|
+
function bodyPosition(body) {
|
|
248
|
+
if (!body || typeof body !== "object") return null;
|
|
249
|
+
const rawAltitude = typeof body.altitude === "number" ? body.altitude : NaN;
|
|
250
|
+
if (!Number.isFinite(rawAltitude)) return null;
|
|
251
|
+
const altitude = clamp(rawAltitude, -90, 90);
|
|
252
|
+
if (altitude < MIN_VISIBLE_ALT) return null;
|
|
253
|
+
const azimuth = num(body.azimuth, 180);
|
|
254
|
+
const deviation = ((azimuth - 180) % 360 + 540) % 360 - 180;
|
|
255
|
+
const radians = deviation * Math.PI / 180;
|
|
256
|
+
const tan = Math.tan(radians);
|
|
257
|
+
const x = clamp(0.5 + (Number.isFinite(tan) ? tan : Math.sign(radians) * 1e6) * 0.5, POS_MIN, POS_MAX);
|
|
258
|
+
const y = clamp(HORIZON - Math.sin(altitude * Math.PI / 180) * HORIZON, POS_MIN, POS_MAX);
|
|
259
|
+
return { x: round(x, 4), y: round(y, 4), altitude };
|
|
260
|
+
}
|
|
261
|
+
function normalizeMoonPhase(value) {
|
|
262
|
+
const phase = num(value, 0.5);
|
|
263
|
+
return round(clamp(phase, 0, 1), 4);
|
|
264
|
+
}
|
|
265
|
+
function illuminatedFraction(phase) {
|
|
266
|
+
return clamp01((1 - Math.cos(Math.PI * 2 * phase)) / 2);
|
|
267
|
+
}
|
|
268
|
+
function sunOf(position, colors) {
|
|
269
|
+
if (!position) return null;
|
|
270
|
+
return {
|
|
271
|
+
x: position.x,
|
|
272
|
+
y: position.y,
|
|
273
|
+
color: colors.sunColor,
|
|
274
|
+
alpha: round(clamp01(0.55 + position.altitude * 0.045), 3)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function moonOf(position, colors, phase) {
|
|
278
|
+
if (!position) return null;
|
|
279
|
+
const lit = illuminatedFraction(phase);
|
|
280
|
+
const base = clamp01(0.45 + position.altitude * 0.05);
|
|
281
|
+
return {
|
|
282
|
+
x: position.x,
|
|
283
|
+
y: position.y,
|
|
284
|
+
color: colors.moonColor,
|
|
285
|
+
alpha: round(base * (0.35 + 0.65 * lit), 3),
|
|
286
|
+
phase
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function computePalette(now) {
|
|
290
|
+
const source = now && typeof now === "object" ? now : {};
|
|
291
|
+
const phase = normalizePhase(source.phase);
|
|
292
|
+
const sunAltitude = readAltitude(source.sun);
|
|
293
|
+
const sunPosition = bodyPosition(source.sun);
|
|
294
|
+
const moonPhase = normalizeMoonPhase(source.moon && source.moon.phase);
|
|
295
|
+
const colors = sunAltitude === null ? phasePalette(phase || FALLBACK_PHASE) : ladder(sunAltitude, phase, num(source.sun && source.sun.azimuth, NaN));
|
|
296
|
+
let stars = clamp01(colors.stars);
|
|
297
|
+
if (sunAltitude !== null) stars *= clamp01((-sunAltitude + 1) / 7);
|
|
298
|
+
else if ((phase || FALLBACK_PHASE) !== "night") stars = 0;
|
|
299
|
+
const sun = sunOf(sunPosition, colors);
|
|
300
|
+
let moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
|
|
301
|
+
const nightish = sunAltitude !== null ? sunAltitude < -6 : (phase || FALLBACK_PHASE) === "night";
|
|
302
|
+
if (!moon && nightish) {
|
|
303
|
+
const lit = illuminatedFraction(moonPhase);
|
|
304
|
+
moon = {
|
|
305
|
+
x: 0.62,
|
|
306
|
+
y: 0.2,
|
|
307
|
+
color: colors.moonColor,
|
|
308
|
+
alpha: round(clamp01(0.4 + 0.55 * lit), 3),
|
|
309
|
+
phase: moonPhase
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
skyTop: colors.skyTop,
|
|
314
|
+
skyMid: colors.skyMid,
|
|
315
|
+
skyBottom: colors.skyBottom,
|
|
316
|
+
horizonGlow: colors.horizonGlow,
|
|
317
|
+
seaFar: colors.seaFar,
|
|
318
|
+
seaMid: colors.seaMid,
|
|
319
|
+
seaNear: colors.seaNear,
|
|
320
|
+
foam: colors.foam,
|
|
321
|
+
sun,
|
|
322
|
+
moon,
|
|
323
|
+
stars: round(stars, 3),
|
|
324
|
+
exposure: round(clamp01(colors.exposure), 3)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function paletteFor(now) {
|
|
328
|
+
try {
|
|
329
|
+
return computePalette(now);
|
|
330
|
+
} catch (_error) {
|
|
331
|
+
try {
|
|
332
|
+
return computePalette(null);
|
|
333
|
+
} catch (_ignored) {
|
|
334
|
+
return hardFallback();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/client/water.js
|
|
340
|
+
var TAU = Math.PI * 2;
|
|
341
|
+
var BUFFER_W = 430;
|
|
342
|
+
var MAX_BUFFER_H = 260;
|
|
343
|
+
var MIN_BUFFER_H = 40;
|
|
344
|
+
function hash2(x, y) {
|
|
345
|
+
let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263);
|
|
346
|
+
h = Math.imul(h ^ h >>> 13, 1274126177);
|
|
347
|
+
return ((h ^ h >>> 16) >>> 0) / 4294967296;
|
|
348
|
+
}
|
|
349
|
+
function vnoise(x, y) {
|
|
350
|
+
const xi = Math.floor(x);
|
|
351
|
+
const yi = Math.floor(y);
|
|
352
|
+
const xf = x - xi;
|
|
353
|
+
const yf = y - yi;
|
|
354
|
+
const u = xf * xf * (3 - 2 * xf);
|
|
355
|
+
const v = yf * yf * (3 - 2 * yf);
|
|
356
|
+
const a = hash2(xi, yi);
|
|
357
|
+
const b = hash2(xi + 1, yi);
|
|
358
|
+
const c = hash2(xi, yi + 1);
|
|
359
|
+
const d = hash2(xi + 1, yi + 1);
|
|
360
|
+
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
|
|
361
|
+
}
|
|
362
|
+
function fbmSmooth(x, y) {
|
|
363
|
+
return 0.55 * vnoise(x, y) + 0.3 * vnoise(x * 2.07 + 3.1, y * 2.07 + 1.7) + 0.15 * vnoise(x * 4.31 + 7.7, y * 4.31 + 3.3);
|
|
364
|
+
}
|
|
365
|
+
function fbmRidged(x, y) {
|
|
366
|
+
let sum = 0;
|
|
367
|
+
let amp = 0.6;
|
|
368
|
+
let freq = 1;
|
|
369
|
+
let total = 0;
|
|
370
|
+
for (let o = 0; o < 4; o += 1) {
|
|
371
|
+
const n = vnoise(x * freq + o * 13.7, y * freq + o * 7.3);
|
|
372
|
+
sum += amp * (1 - Math.abs(2 * n - 1));
|
|
373
|
+
total += amp;
|
|
374
|
+
amp *= 0.52;
|
|
375
|
+
freq *= 2.17;
|
|
376
|
+
}
|
|
377
|
+
return sum / total;
|
|
378
|
+
}
|
|
379
|
+
function hexToRgb(hex) {
|
|
380
|
+
const text = String(hex ?? "").replace("#", "");
|
|
381
|
+
if (text.length !== 6) return [0, 0, 0];
|
|
382
|
+
return [parseInt(text.slice(0, 2), 16), parseInt(text.slice(2, 4), 16), parseInt(text.slice(4, 6), 16)];
|
|
383
|
+
}
|
|
384
|
+
var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
385
|
+
var lerp = (a, b, t) => a + (b - a) * t;
|
|
386
|
+
function createWaterField(doc = globalThis.document) {
|
|
387
|
+
const canvas = doc?.createElement?.("canvas") ?? null;
|
|
388
|
+
return { width: 0, height: 0, canvas, ctx: canvas?.getContext?.("2d") ?? null, image: null, h: null };
|
|
389
|
+
}
|
|
390
|
+
function ensureSize(field2, width, height) {
|
|
391
|
+
if (field2.width === width && field2.height === height && field2.image) return true;
|
|
392
|
+
field2.width = width;
|
|
393
|
+
field2.height = height;
|
|
394
|
+
field2.h = new Float32Array(width * height);
|
|
395
|
+
if (!field2.canvas || !field2.ctx) return false;
|
|
396
|
+
field2.canvas.width = width;
|
|
397
|
+
field2.canvas.height = height;
|
|
398
|
+
field2.image = field2.ctx.createImageData(width, height);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
function drawWater(ctx, view, field2) {
|
|
402
|
+
const { W, H, horizonY, t } = view;
|
|
403
|
+
const pal = view.pal ?? {};
|
|
404
|
+
const tideN = Number.isFinite(view.tideN) ? view.tideN : 0;
|
|
405
|
+
const waterH = Math.max(1, H - horizonY);
|
|
406
|
+
const bw = BUFFER_W;
|
|
407
|
+
const bh = Math.max(MIN_BUFFER_H, Math.min(MAX_BUFFER_H, Math.round(bw * waterH / Math.max(1, W))));
|
|
408
|
+
if (!ensureSize(field2, bw, bh)) return false;
|
|
409
|
+
const exposure = clamp012(Number.isFinite(pal.exposure) ? pal.exposure : 0.8);
|
|
410
|
+
const deep = hexToRgb(pal.seaNear ?? "#0d1729");
|
|
411
|
+
const mid = hexToRgb(pal.seaMid ?? "#27728e");
|
|
412
|
+
const far = hexToRgb(pal.seaFar ?? "#4793ae");
|
|
413
|
+
const sky = hexToRgb(pal.skyBottom ?? "#b7e1f6");
|
|
414
|
+
const foam = hexToRgb(pal.foam ?? "#ffffff");
|
|
415
|
+
const glow = hexToRgb(pal.horizonGlow ?? "#ffd5a8");
|
|
416
|
+
const body = pal.sun || pal.moon || null;
|
|
417
|
+
const bodyX = body ? clamp012(body.x) : 0.5;
|
|
418
|
+
const bodyColor = hexToRgb(body?.color ?? pal.horizonGlow ?? "#fff6e2");
|
|
419
|
+
const bodyAlpha = body ? clamp012(body.alpha ?? 0.8) : 0.35;
|
|
420
|
+
const heights = field2.h;
|
|
421
|
+
const LAYERS = 5;
|
|
422
|
+
const SPEED = 0.22;
|
|
423
|
+
const scroll = t * 0.12;
|
|
424
|
+
const XSPREAD = 6.2;
|
|
425
|
+
const WFREQ = 5.4;
|
|
426
|
+
const XFREQ = 1.45;
|
|
427
|
+
let idx = 0;
|
|
428
|
+
for (let j = 0; j < bh; j += 1) {
|
|
429
|
+
const d = (j + 0.5) / bh;
|
|
430
|
+
const zc = 1 / (0.38 + d * 2.4);
|
|
431
|
+
const wz = zc - scroll;
|
|
432
|
+
const phaseBase = d * LAYERS - t * SPEED;
|
|
433
|
+
const swellAmp = 0.3 + 0.52 * d;
|
|
434
|
+
for (let i = 0; i < bw; i += 1) {
|
|
435
|
+
const wx = ((i + 0.5) / bw - 0.5) * zc * XSPREAD;
|
|
436
|
+
const wobble = (vnoise(wx * 0.85 + 9.7, wz * 0.55 + 3.3) - 0.5) * 0.62;
|
|
437
|
+
const s0 = Math.sin((phaseBase + wobble) * TAU);
|
|
438
|
+
const swell = (s0 < 0 ? -1 : 1) * Math.pow(Math.abs(s0), 1.55);
|
|
439
|
+
const s1 = Math.sin((d * 3.1 - t * 0.135 + wobble * 1.7 + 2.3) * TAU);
|
|
440
|
+
const swell2 = (s1 < 0 ? -1 : 1) * Math.pow(Math.abs(s1), 1.4);
|
|
441
|
+
const ridge = fbmRidged(wx * XFREQ + 3, wz * WFREQ);
|
|
442
|
+
const chop = fbmRidged(wx * XFREQ * 2.6 + 17.3, wz * WFREQ * 1.5 + 8.9);
|
|
443
|
+
const detail = fbmSmooth(wx * XFREQ * 1.9 + 11.3, wz * WFREQ * 0.72 + 5.1);
|
|
444
|
+
const h = 0.5 + 0.5 * swell * swellAmp + 0.5 * swell2 * swellAmp * 0.42 + (ridge - 0.5) * 0.54 + (chop - 0.5) * 0.44 * d + (detail - 0.5) * 0.22;
|
|
445
|
+
heights[idx] = h < 0 ? 0 : h > 1 ? 1 : h;
|
|
446
|
+
idx += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const data = field2.image.data;
|
|
450
|
+
let p = 0;
|
|
451
|
+
for (let j = 0; j < bh; j += 1) {
|
|
452
|
+
const d = (j + 0.5) / bh;
|
|
453
|
+
const haze = (1 - d) * (1 - d) * (0.55 + 0.35 * exposure);
|
|
454
|
+
const nearness = clamp012(d * 1.6);
|
|
455
|
+
const rBase = lerp(lerp(far[0], mid[0], nearness), deep[0], Math.max(0, d - 0.35) / 0.65);
|
|
456
|
+
const gBase = lerp(lerp(far[1], mid[1], nearness), deep[1], Math.max(0, d - 0.35) / 0.65);
|
|
457
|
+
const bBase = lerp(lerp(far[2], mid[2], nearness), deep[2], Math.max(0, d - 0.35) / 0.65);
|
|
458
|
+
const foamBand = clamp012((d - 0.62) / 0.38) * clamp012(0.45 + 0.45 * tideN);
|
|
459
|
+
const rowLight = 0.5 + 0.5 * exposure;
|
|
460
|
+
for (let i = 0; i < bw; i += 1) {
|
|
461
|
+
const h = heights[j * bw + i];
|
|
462
|
+
const hx = (heights[j * bw + Math.min(bw - 1, i + 1)] - heights[j * bw + Math.max(0, i - 1)]) * 0.5;
|
|
463
|
+
const hz = (heights[Math.min(bh - 1, j + 1) * bw + i] - heights[Math.max(0, j - 1) * bw + i]) * 0.5;
|
|
464
|
+
const tilt = -(hx * (bodyX - 0.5) * 4.4 + hz * 1.15);
|
|
465
|
+
const shade = clamp012(0.58 + tilt * (3.1 - 1.1 * d));
|
|
466
|
+
const crest = clamp012((h - 0.4) / 0.6);
|
|
467
|
+
const relief = 0.78 + 0.4 * (h - 0.5) * 2;
|
|
468
|
+
let r = rBase * shade * relief;
|
|
469
|
+
let g = gBase * shade * relief;
|
|
470
|
+
let b = bBase * shade * relief;
|
|
471
|
+
r = lerp(r, sky[0], haze * 0.62);
|
|
472
|
+
g = lerp(g, sky[1], haze * 0.62);
|
|
473
|
+
b = lerp(b, sky[2], haze * 0.62);
|
|
474
|
+
const crestLight = crest * crest * clamp012(0.3 + tilt * 2.6) * rowLight;
|
|
475
|
+
r += glow[0] * crestLight * 0.46;
|
|
476
|
+
g += glow[1] * crestLight * 0.46;
|
|
477
|
+
b += glow[2] * crestLight * 0.42;
|
|
478
|
+
const spec = Math.pow(clamp012(0.26 - tilt * 1.35), 5) * crest * bodyAlpha;
|
|
479
|
+
if (spec > 4e-3) {
|
|
480
|
+
r += bodyColor[0] * spec * 1.35;
|
|
481
|
+
g += bodyColor[1] * spec * 1.35;
|
|
482
|
+
b += bodyColor[2] * spec * 1.35;
|
|
483
|
+
}
|
|
484
|
+
const streakBand = clamp012((d - 0.42) / 0.58);
|
|
485
|
+
if (streakBand > 0.01) {
|
|
486
|
+
const mask = clamp012((h - (0.68 - 0.16 * streakBand)) / 0.24) * streakBand;
|
|
487
|
+
if (mask > 0.01) {
|
|
488
|
+
const a = mask * (0.18 + 0.3 * clamp012(foamBand * 2));
|
|
489
|
+
r = lerp(r, foam[0], a);
|
|
490
|
+
g = lerp(g, foam[1], a);
|
|
491
|
+
b = lerp(b, foam[2], a);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
data[p] = r > 255 ? 255 : r < 0 ? 0 : r;
|
|
495
|
+
data[p + 1] = g > 255 ? 255 : g < 0 ? 0 : g;
|
|
496
|
+
data[p + 2] = b > 255 ? 255 : b < 0 ? 0 : b;
|
|
497
|
+
data[p + 3] = 255;
|
|
498
|
+
p += 4;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
field2.ctx.putImageData(field2.image, 0, 0);
|
|
502
|
+
ctx.save();
|
|
503
|
+
ctx.imageSmoothingEnabled = true;
|
|
504
|
+
try {
|
|
505
|
+
ctx.imageSmoothingQuality = "high";
|
|
506
|
+
} catch {
|
|
507
|
+
}
|
|
508
|
+
ctx.drawImage(field2.canvas, 0, horizonY, W, waterH);
|
|
509
|
+
ctx.restore();
|
|
510
|
+
try {
|
|
511
|
+
drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow });
|
|
512
|
+
} catch {
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const hazeH = Math.max(2, waterH * 0.09);
|
|
516
|
+
const grad = ctx.createLinearGradient(0, horizonY - hazeH * 0.4, 0, horizonY + hazeH);
|
|
517
|
+
grad.addColorStop(0, `rgba(${sky[0] | 0},${sky[1] | 0},${sky[2] | 0},${0.55 + 0.35 * (1 - exposure)})`);
|
|
518
|
+
grad.addColorStop(1, `rgba(${sky[0] | 0},${sky[1] | 0},${sky[2] | 0},0)`);
|
|
519
|
+
ctx.fillStyle = grad;
|
|
520
|
+
ctx.fillRect(0, horizonY - hazeH * 0.4, W, hazeH * 1.4);
|
|
521
|
+
} catch {
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow }) {
|
|
526
|
+
const waterH = H - horizonY;
|
|
527
|
+
if (waterH <= 2) return;
|
|
528
|
+
const count = Math.round((70 + 240 * exposure) * clamp012(bodyAlpha + 0.2));
|
|
529
|
+
const sunU = bodyX;
|
|
530
|
+
ctx.save();
|
|
531
|
+
for (let n = 0; n < count; n += 1) {
|
|
532
|
+
const seedA = hash2(n * 3 + 1, 7);
|
|
533
|
+
const seedB = hash2(n * 5 + 2, 13);
|
|
534
|
+
const seedC = hash2(n * 7 + 3, 19);
|
|
535
|
+
const d = Math.pow(seedA, 0.72);
|
|
536
|
+
const y = horizonY + d * waterH;
|
|
537
|
+
const spread = 0.1 + 0.3 * d;
|
|
538
|
+
const x = (sunU + (seedB - 0.5) * 2 * spread) * W;
|
|
539
|
+
if (x < 0 || x > W) continue;
|
|
540
|
+
const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.45 + seedC * 1.1) + seedC * TAU));
|
|
541
|
+
const alpha = twinkle * (0.14 + 0.42 * exposure) * clamp012(1 - Math.abs(seedB - 0.5) * 1.25);
|
|
542
|
+
if (alpha < 0.03) continue;
|
|
543
|
+
const len = 1 + 1.9 * d + seedC * 1.2;
|
|
544
|
+
ctx.fillStyle = `rgba(${glow[0] | 0},${glow[1] | 0},${glow[2] | 0},${alpha.toFixed(3)})`;
|
|
545
|
+
ctx.fillRect(x, y, len, 1);
|
|
546
|
+
}
|
|
547
|
+
ctx.restore();
|
|
548
|
+
}
|
|
549
|
+
var WATER_CONSTANTS = Object.freeze({ BUFFER_W, MAX_BUFFER_H, MIN_BUFFER_H, TAU });
|
|
550
|
+
|
|
551
|
+
// src/client/birds.js
|
|
552
|
+
var TAU2 = Math.PI * 2;
|
|
553
|
+
var clamp013 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
554
|
+
function hash(n) {
|
|
555
|
+
let x = Math.imul(n | 0, 2654435761);
|
|
556
|
+
x = Math.imul(x ^ x >>> 15, 2246822507);
|
|
557
|
+
return ((x ^ x >>> 13) >>> 0) / 4294967296;
|
|
558
|
+
}
|
|
559
|
+
var BIRD_CONSTANTS = Object.freeze({
|
|
560
|
+
slots: 9,
|
|
561
|
+
// 同时在"档期"里的鸟槽(不是同屏数量)
|
|
562
|
+
minPeriod: 19,
|
|
563
|
+
// 最短的一个轮回(秒)
|
|
564
|
+
periodSpan: 23,
|
|
565
|
+
// 周期跨度:每只鸟都不同,避免同拍
|
|
566
|
+
minFlight: 7,
|
|
567
|
+
// 飞过一整屏需要的秒数(下界)
|
|
568
|
+
flightSpan: 5
|
|
569
|
+
});
|
|
570
|
+
function daylightOf(pal) {
|
|
571
|
+
if (!pal || typeof pal !== "object") return 0;
|
|
572
|
+
if (!Number.isFinite(pal.exposure)) return 0;
|
|
573
|
+
const stars = Number.isFinite(pal.stars) ? pal.stars : 0;
|
|
574
|
+
return clamp013((pal.exposure - 0.45) / 0.4) * clamp013(1 - stars * 1.6);
|
|
575
|
+
}
|
|
576
|
+
function drawBirds(ctx, view) {
|
|
577
|
+
const { W, H, horizonY, t } = view;
|
|
578
|
+
const pal = view.pal ?? {};
|
|
579
|
+
const daylight = daylightOf(pal);
|
|
580
|
+
if (daylight <= 0.02) return 0;
|
|
581
|
+
if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(horizonY)) return 0;
|
|
582
|
+
if (horizonY <= 4 || !ctx || typeof ctx.beginPath !== "function") return 0;
|
|
583
|
+
const { slots, minPeriod, periodSpan, minFlight, flightSpan } = BIRD_CONSTANTS;
|
|
584
|
+
const skyH = horizonY;
|
|
585
|
+
const scale = Math.max(0.55, Math.min(2.2, H / 720));
|
|
586
|
+
const density = 0.55 + 0.45 * daylight;
|
|
587
|
+
let drawn = 0;
|
|
588
|
+
for (let i = 0; i < slots; i += 1) {
|
|
589
|
+
const period = minPeriod + hash(i * 3 + 1) * periodSpan;
|
|
590
|
+
const flight = minFlight + hash(i * 7 + 5) * flightSpan;
|
|
591
|
+
const offset = hash(i * 11 + 2) * period;
|
|
592
|
+
const local = (t + offset) % period;
|
|
593
|
+
if (local > flight) continue;
|
|
594
|
+
if (hash(i * 13 + 9) > density) continue;
|
|
595
|
+
const p = local / flight;
|
|
596
|
+
const r1 = hash(i * 17 + 3);
|
|
597
|
+
const r2 = hash(i * 19 + 11);
|
|
598
|
+
const r3 = hash(i * 23 + 7);
|
|
599
|
+
const leftToRight = r1 > 0.45;
|
|
600
|
+
const x0 = leftToRight ? -0.08 * W : 1.08 * W;
|
|
601
|
+
const x1 = leftToRight ? 1.08 * W : -0.08 * W;
|
|
602
|
+
const x = x0 + (x1 - x0) * p;
|
|
603
|
+
const depth = 0.28 + 0.42 * r2;
|
|
604
|
+
const bob = Math.sin((p * 2 + r3) * TAU2) * skyH * 0.012;
|
|
605
|
+
const y = horizonY * depth + bob;
|
|
606
|
+
const size = (11 + 9 * r3) * scale * (0.55 + 0.6 * depth);
|
|
607
|
+
const flaps = 6 + Math.floor(r3 * 5);
|
|
608
|
+
const wing = Math.sin(p * flaps * TAU2 + r2 * TAU2);
|
|
609
|
+
const lift = 0.55 + 0.45 * Math.abs(wing);
|
|
610
|
+
const alpha = clamp013((0.5 + 0.32 * daylight) * (0.7 + 0.3 * Math.sin(p * Math.PI)));
|
|
611
|
+
ctx.save();
|
|
612
|
+
ctx.strokeStyle = `rgba(28,34,44,${alpha.toFixed(3)})`;
|
|
613
|
+
ctx.lineWidth = Math.max(1.1, 1.7 * scale);
|
|
614
|
+
ctx.lineCap = "round";
|
|
615
|
+
if (typeof ctx.beginPath === "function") {
|
|
616
|
+
ctx.beginPath();
|
|
617
|
+
ctx.moveTo(x - size, y - wing * size * 0.55 * lift);
|
|
618
|
+
ctx.quadraticCurveTo(x - size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x, y);
|
|
619
|
+
ctx.quadraticCurveTo(x + size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x + size, y - wing * size * 0.55 * lift);
|
|
620
|
+
ctx.stroke();
|
|
621
|
+
}
|
|
622
|
+
drawn += 1;
|
|
623
|
+
}
|
|
624
|
+
return drawn;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/client/sea-canvas.jsx
|
|
628
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
629
|
+
var TAU3 = Math.PI * 2;
|
|
630
|
+
var TUNING = Object.freeze({
|
|
631
|
+
maxBufferWidth: 1280,
|
|
632
|
+
// 内部渲染缓冲宽度上限(程序化海)
|
|
633
|
+
mediaBufferWidth: 2560,
|
|
634
|
+
// 媒体模式的上限(照片要有清晰度,契约 A3)
|
|
635
|
+
targetFps: 30,
|
|
636
|
+
// 目标帧率(不是 60,省电)
|
|
637
|
+
pipFps: 15,
|
|
638
|
+
// form === 'pip' 的小窗帧率
|
|
639
|
+
degradedFps: 15,
|
|
640
|
+
// 降级帧率
|
|
641
|
+
slowWindowMs: 3e3,
|
|
642
|
+
// 连续 3 秒
|
|
643
|
+
slowFps: 20,
|
|
644
|
+
// 平均帧率低于它 → 降级
|
|
645
|
+
resizeDebounceMs: 200,
|
|
646
|
+
// resize 防抖
|
|
647
|
+
reducedRefreshMs: 6e4,
|
|
648
|
+
// reducedMotion:每 60 秒换一帧
|
|
649
|
+
maxStepMs: 120,
|
|
650
|
+
// 时间步长上限:单帧最多推进 120ms(防节流回来跳帧)
|
|
651
|
+
stopOnBlur: false
|
|
652
|
+
// spec §4.3 提到「窗口失焦停帧」;默认关:
|
|
653
|
+
// 开 DevTools 会让页面 blur,验收时会被误判成卡死。
|
|
654
|
+
});
|
|
655
|
+
var HORIZON2 = 0.52;
|
|
656
|
+
var L3 = Object.freeze({
|
|
657
|
+
coverageMin: 0.03,
|
|
658
|
+
// spec §4.1:覆盖率 3%–9%
|
|
659
|
+
coverageMax: 0.09
|
|
660
|
+
});
|
|
661
|
+
var MEDIA = Object.freeze({
|
|
662
|
+
kenBurnsPeriod: 40,
|
|
663
|
+
// 秒,契约要求 ~40s 周期
|
|
664
|
+
kenBurnsZoom: 0.06,
|
|
665
|
+
// 1.0 → 1.06
|
|
666
|
+
overscan: 1.008,
|
|
667
|
+
// 恒定超采样,保证 1–2px 位移不漏边
|
|
668
|
+
breathFreq: 0.11,
|
|
669
|
+
// Hz,水面呼吸
|
|
670
|
+
breathPx: 2,
|
|
671
|
+
// 横向位移幅度(契约 1–2px)
|
|
672
|
+
sweepHeight: 0.09,
|
|
673
|
+
// 高光扫过带的厚度(占画面高)
|
|
674
|
+
skyMaskBottom: 0.62,
|
|
675
|
+
// 顶部天光蒙版盖到画面的 62%
|
|
676
|
+
vignette: 0.22
|
|
677
|
+
// 暗角强度
|
|
678
|
+
});
|
|
679
|
+
var L4 = Object.freeze({
|
|
680
|
+
freq: 0.6,
|
|
681
|
+
// Hz
|
|
682
|
+
band: 0.22,
|
|
683
|
+
// 占画面下 22%
|
|
684
|
+
eventPeriod: 9.2,
|
|
685
|
+
// 翻白事件的周期(秒)
|
|
686
|
+
eventRise: 0.05,
|
|
687
|
+
// 涨:周期的 5%(≈0.46s)
|
|
688
|
+
eventDecay: 0.12
|
|
689
|
+
// 落:周期的 12%(≈1.1s)
|
|
690
|
+
});
|
|
691
|
+
function num2(value, fallback) {
|
|
692
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
693
|
+
}
|
|
694
|
+
function clamp2(value, lo, hi) {
|
|
695
|
+
return value < lo ? lo : value > hi ? hi : value;
|
|
696
|
+
}
|
|
697
|
+
function clamp014(value) {
|
|
698
|
+
return clamp2(num2(value, 0), 0, 1);
|
|
699
|
+
}
|
|
700
|
+
function lerp2(a, b, k) {
|
|
701
|
+
return a + (b - a) * k;
|
|
702
|
+
}
|
|
703
|
+
function round2(value, digits) {
|
|
704
|
+
const base = Math.pow(10, digits);
|
|
705
|
+
return Math.round(num2(value, 0) * base) / base;
|
|
706
|
+
}
|
|
707
|
+
function rgba(color, alpha) {
|
|
708
|
+
let r = 0;
|
|
709
|
+
let g = 0;
|
|
710
|
+
let b = 0;
|
|
711
|
+
const match = typeof color === "string" ? /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim()) : null;
|
|
712
|
+
if (match) {
|
|
713
|
+
let hex = match[1];
|
|
714
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
715
|
+
r = parseInt(hex.slice(0, 2), 16) || 0;
|
|
716
|
+
g = parseInt(hex.slice(2, 4), 16) || 0;
|
|
717
|
+
b = parseInt(hex.slice(4, 6), 16) || 0;
|
|
718
|
+
}
|
|
719
|
+
return "rgba(" + r + "," + g + "," + b + "," + round2(clamp014(alpha), 3) + ")";
|
|
720
|
+
}
|
|
721
|
+
function hash1(n) {
|
|
722
|
+
const s = Math.sin(n * 12.9898) * 43758.5453;
|
|
723
|
+
return s - Math.floor(s);
|
|
724
|
+
}
|
|
725
|
+
function drawSky(ctx, W, H, horizonY, pal) {
|
|
726
|
+
const height = Math.max(1, horizonY);
|
|
727
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
728
|
+
gradient.addColorStop(0, pal.skyTop);
|
|
729
|
+
gradient.addColorStop(0.54, pal.skyMid);
|
|
730
|
+
gradient.addColorStop(1, pal.skyBottom);
|
|
731
|
+
ctx.fillStyle = gradient;
|
|
732
|
+
ctx.fillRect(0, 0, W, height);
|
|
733
|
+
const spread = H * (0.09 + 0.07 * (1 - clamp014(pal.exposure)));
|
|
734
|
+
const glow = ctx.createLinearGradient(0, horizonY - spread, 0, horizonY + H * 4e-3);
|
|
735
|
+
glow.addColorStop(0, rgba(pal.horizonGlow, 0));
|
|
736
|
+
glow.addColorStop(0.72, rgba(pal.horizonGlow, 0.3));
|
|
737
|
+
glow.addColorStop(1, rgba(pal.horizonGlow, 0.16));
|
|
738
|
+
ctx.fillStyle = glow;
|
|
739
|
+
ctx.fillRect(0, Math.max(0, horizonY - spread), W, Math.min(H, spread + H * 4e-3));
|
|
740
|
+
const hazeTop = Math.max(0, horizonY - spread * 0.62);
|
|
741
|
+
ctx.fillStyle = rgba(pal.skyBottom, 0.16);
|
|
742
|
+
ctx.fillRect(0, hazeTop, W, horizonY - hazeTop);
|
|
743
|
+
}
|
|
744
|
+
function drawStars(ctx, W, H, horizonY, pal, t) {
|
|
745
|
+
const density = clamp014(pal.stars);
|
|
746
|
+
if (density <= 0.01) return;
|
|
747
|
+
const count = Math.round(density * 120);
|
|
748
|
+
const tint = mix(pal.foam, "#ffffff", 0.65);
|
|
749
|
+
const ceiling = Math.max(1, horizonY * 0.95);
|
|
750
|
+
for (let i = 0; i < count; i += 1) {
|
|
751
|
+
const hx = hash1(i * 1.731 + 0.37);
|
|
752
|
+
const hy = hash1(i * 2.917 + 0.11);
|
|
753
|
+
const x = hx * W;
|
|
754
|
+
const y = hy * hy * ceiling;
|
|
755
|
+
const size = 0.7 + hash1(i * 3.77) * 1.1;
|
|
756
|
+
const twinkle = 0.55 + 0.45 * Math.sin(t * (0.6 + hx * 1.9) + i * 1.3);
|
|
757
|
+
ctx.fillStyle = rgba(tint, clamp014(density * twinkle) * 0.85);
|
|
758
|
+
ctx.fillRect(x, y, size, size);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function drawSun(ctx, W, H, pal) {
|
|
762
|
+
const sun = pal.sun;
|
|
763
|
+
if (!sun) return;
|
|
764
|
+
const alpha = clamp014(sun.alpha);
|
|
765
|
+
if (alpha <= 0.02) return;
|
|
766
|
+
const cx = clamp2(sun.x, -0.2, 1.2) * W;
|
|
767
|
+
const cy = clamp2(sun.y, -0.2, 1.2) * H;
|
|
768
|
+
const radius = Math.max(6, H * 0.028);
|
|
769
|
+
const halo = ctx.createRadialGradient(cx, cy, radius * 0.4, cx, cy, radius * 7);
|
|
770
|
+
halo.addColorStop(0, rgba(pal.horizonGlow, 0.42 * alpha));
|
|
771
|
+
halo.addColorStop(0.35, rgba(pal.horizonGlow, 0.13 * alpha));
|
|
772
|
+
halo.addColorStop(1, rgba(pal.horizonGlow, 0));
|
|
773
|
+
ctx.fillStyle = halo;
|
|
774
|
+
ctx.beginPath();
|
|
775
|
+
ctx.arc(cx, cy, radius * 7, 0, TAU3);
|
|
776
|
+
ctx.fill();
|
|
777
|
+
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
778
|
+
core.addColorStop(0, rgba(sun.color, alpha));
|
|
779
|
+
core.addColorStop(0.72, rgba(sun.color, alpha * 0.92));
|
|
780
|
+
core.addColorStop(1, rgba(sun.color, alpha * 0.5));
|
|
781
|
+
ctx.fillStyle = core;
|
|
782
|
+
ctx.beginPath();
|
|
783
|
+
ctx.arc(cx, cy, radius, 0, TAU3);
|
|
784
|
+
ctx.fill();
|
|
785
|
+
}
|
|
786
|
+
function drawMoon(ctx, W, H, pal) {
|
|
787
|
+
const moon = pal.moon;
|
|
788
|
+
if (!moon) return;
|
|
789
|
+
const alpha = clamp014(moon.alpha);
|
|
790
|
+
if (alpha <= 0.02) return;
|
|
791
|
+
const cx = clamp2(moon.x, -0.2, 1.2) * W;
|
|
792
|
+
const cy = clamp2(moon.y, -0.2, 1.2) * H;
|
|
793
|
+
const radius = Math.max(5, H * 0.019);
|
|
794
|
+
const phase = clamp014(num2(moon.phase, 0.5));
|
|
795
|
+
const halo = ctx.createRadialGradient(cx, cy, radius * 0.6, cx, cy, radius * 6);
|
|
796
|
+
halo.addColorStop(0, rgba(moon.color, 0.26 * alpha));
|
|
797
|
+
halo.addColorStop(0.4, rgba(moon.color, 0.08 * alpha));
|
|
798
|
+
halo.addColorStop(1, rgba(moon.color, 0));
|
|
799
|
+
ctx.fillStyle = halo;
|
|
800
|
+
ctx.beginPath();
|
|
801
|
+
ctx.arc(cx, cy, radius * 6, 0, TAU3);
|
|
802
|
+
ctx.fill();
|
|
803
|
+
const waxing = phase < 0.5;
|
|
804
|
+
const p = waxing ? phase : 1 - phase;
|
|
805
|
+
const rx = Math.max(0.01, radius * Math.abs(Math.cos(TAU3 * p)));
|
|
806
|
+
ctx.save();
|
|
807
|
+
if (!waxing) {
|
|
808
|
+
ctx.translate(cx * 2, 0);
|
|
809
|
+
ctx.scale(-1, 1);
|
|
810
|
+
}
|
|
811
|
+
ctx.beginPath();
|
|
812
|
+
ctx.arc(cx, cy, radius, -Math.PI / 2, Math.PI / 2, false);
|
|
813
|
+
if (typeof ctx.ellipse === "function") {
|
|
814
|
+
ctx.ellipse(cx, cy, rx, radius, 0, Math.PI / 2, -Math.PI / 2, p < 0.25);
|
|
815
|
+
} else {
|
|
816
|
+
ctx.lineTo(cx, cy + radius);
|
|
817
|
+
ctx.lineTo(cx, cy - radius);
|
|
818
|
+
}
|
|
819
|
+
ctx.closePath();
|
|
820
|
+
ctx.fillStyle = rgba(moon.color, alpha);
|
|
821
|
+
ctx.fill();
|
|
822
|
+
ctx.restore();
|
|
823
|
+
}
|
|
824
|
+
function farSeaNoise(u, t) {
|
|
825
|
+
const w = TAU3 * L1.freq;
|
|
826
|
+
return Math.sin(u * L1.spatial + t * w) * 0.5 + Math.sin(u * L1.spatial * 2.6 - t * w * 1.7 + 1.1) * 0.3 + Math.sin(u * L1.spatial * 5.4 + t * w * 2.9 + 2.3) * 0.2;
|
|
827
|
+
}
|
|
828
|
+
function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
829
|
+
const amp = lerp2(L1.ampMin, L1.ampMax, 0.5 + 0.5 * Math.sin(t * TAU3 * L1.freq)) * px;
|
|
830
|
+
const step = Math.max(8, W / 80);
|
|
831
|
+
const curve = (x) => horizonY - amp * (0.5 + 0.5 * farSeaNoise(x / Math.max(1, W) * TAU3, t));
|
|
832
|
+
ctx.save();
|
|
833
|
+
ctx.beginPath();
|
|
834
|
+
ctx.moveTo(0, curve(0));
|
|
835
|
+
for (let x = step; x < W + step; x += step) {
|
|
836
|
+
const xx = Math.min(x, W);
|
|
837
|
+
ctx.lineTo(xx, curve(xx));
|
|
838
|
+
}
|
|
839
|
+
ctx.lineTo(W, H);
|
|
840
|
+
ctx.lineTo(0, H);
|
|
841
|
+
ctx.closePath();
|
|
842
|
+
ctx.clip();
|
|
843
|
+
const gradient = ctx.createLinearGradient(0, horizonY - amp - 2, 0, H);
|
|
844
|
+
const mirror = 0.45 + 0.25 * (1 - clamp014(pal.exposure));
|
|
845
|
+
gradient.addColorStop(0, mix(pal.seaFar, pal.skyBottom, mirror));
|
|
846
|
+
gradient.addColorStop(0.18, mix(pal.seaFar, pal.skyBottom, mirror * 0.45));
|
|
847
|
+
gradient.addColorStop(0.42, mix(pal.seaFar, pal.seaMid, 0.85));
|
|
848
|
+
gradient.addColorStop(0.7, pal.seaMid);
|
|
849
|
+
gradient.addColorStop(1, mix(pal.seaNear, pal.seaMid, 0.15));
|
|
850
|
+
ctx.fillStyle = gradient;
|
|
851
|
+
ctx.fillRect(0, horizonY - amp - 2, W, H - horizonY + amp + 4);
|
|
852
|
+
ctx.restore();
|
|
853
|
+
const fogAmount = clamp014(0.3 + 0.5 * (1 - clamp014(pal.exposure)));
|
|
854
|
+
const fogHeight = H * (0.045 + 0.085 * (1 - clamp014(pal.exposure)));
|
|
855
|
+
const fogColor = mix(pal.skyBottom, pal.horizonGlow, 0.45);
|
|
856
|
+
const fog = ctx.createLinearGradient(0, horizonY - fogHeight * 0.45, 0, horizonY + fogHeight * 0.9);
|
|
857
|
+
fog.addColorStop(0, rgba(fogColor, 0));
|
|
858
|
+
fog.addColorStop(0.34, rgba(fogColor, 0.26 * fogAmount));
|
|
859
|
+
fog.addColorStop(0.55, rgba(fogColor, 0.18 * fogAmount));
|
|
860
|
+
fog.addColorStop(1, rgba(fogColor, 0));
|
|
861
|
+
ctx.fillStyle = fog;
|
|
862
|
+
ctx.fillRect(0, horizonY - fogHeight * 0.45, W, fogHeight * 1.35);
|
|
863
|
+
ctx.beginPath();
|
|
864
|
+
ctx.moveTo(0, curve(0));
|
|
865
|
+
for (let x = step; x < W + step; x += step) {
|
|
866
|
+
const xx = Math.min(x, W);
|
|
867
|
+
ctx.lineTo(xx, curve(xx));
|
|
868
|
+
}
|
|
869
|
+
ctx.strokeStyle = rgba(mix(pal.seaFar, pal.foam, 0.3), 0.06 + 0.16 * clamp014(pal.exposure));
|
|
870
|
+
ctx.lineWidth = Math.max(0.6, 1.1 * px);
|
|
871
|
+
ctx.stroke();
|
|
872
|
+
}
|
|
873
|
+
function gradeOf(media) {
|
|
874
|
+
if (!media || typeof media !== "object") return 0.55;
|
|
875
|
+
const flat = media.grade !== void 0 ? media.grade : media.settings && media.settings.grade;
|
|
876
|
+
return clamp014(num2(flat, 0.55));
|
|
877
|
+
}
|
|
878
|
+
function motionOf(media) {
|
|
879
|
+
if (!media || typeof media !== "object") return 0.5;
|
|
880
|
+
const flat = media.motion !== void 0 ? media.motion : media.settings && media.settings.motion;
|
|
881
|
+
return clamp014(num2(flat, 0.5));
|
|
882
|
+
}
|
|
883
|
+
function hashString(text) {
|
|
884
|
+
const source = typeof text === "string" ? text : "";
|
|
885
|
+
let h = 2166136261;
|
|
886
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
887
|
+
h ^= source.charCodeAt(i);
|
|
888
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
889
|
+
}
|
|
890
|
+
return (h >>> 0) / 4294967296;
|
|
891
|
+
}
|
|
892
|
+
function withComposite(ctx, op, paint) {
|
|
893
|
+
const previous = ctx.globalCompositeOperation;
|
|
894
|
+
try {
|
|
895
|
+
ctx.globalCompositeOperation = op;
|
|
896
|
+
} catch (_error) {
|
|
897
|
+
}
|
|
898
|
+
if (ctx.globalCompositeOperation !== op) ctx.globalCompositeOperation = "source-over";
|
|
899
|
+
try {
|
|
900
|
+
paint();
|
|
901
|
+
} finally {
|
|
902
|
+
ctx.globalCompositeOperation = previous || "source-over";
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function compositeFill(ctx, op, color, alpha, W, H) {
|
|
906
|
+
const a = clamp014(alpha);
|
|
907
|
+
if (a <= 3e-3) return;
|
|
908
|
+
withComposite(ctx, op, () => {
|
|
909
|
+
ctx.fillStyle = rgba(color, a);
|
|
910
|
+
ctx.fillRect(0, 0, W, H);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
function drawMediaGrade(ctx, W, H, pal, grade) {
|
|
914
|
+
const g = clamp014(grade);
|
|
915
|
+
if (g <= 0.01) return;
|
|
916
|
+
const exposure = clamp014(pal.exposure);
|
|
917
|
+
const lowSun = 1 - exposure;
|
|
918
|
+
const darkness = clamp014((0.95 - exposure) / 0.65);
|
|
919
|
+
const tint = mix(pal.seaMid, pal.horizonGlow, clamp014(0.2 + 0.75 * lowSun));
|
|
920
|
+
const shade = mix(tint, "#03060e", clamp014(0.4 + 0.55 * darkness));
|
|
921
|
+
compositeFill(ctx, "multiply", shade, clamp014(g * (0.08 + 0.9 * darkness)), W, H);
|
|
922
|
+
if (darkness > 0.2) compositeFill(ctx, "multiply", "#04070f", clamp014(g * 0.4 * (darkness - 0.2) / 0.8), W, H);
|
|
923
|
+
compositeFill(ctx, "screen", mix(pal.horizonGlow, "#ffffff", 0.5), clamp014(g * (0.1 + 0.16 * exposure)), W, H);
|
|
924
|
+
compositeFill(ctx, "soft-light", tint, clamp014(g * 0.55), W, H);
|
|
925
|
+
}
|
|
926
|
+
function drawMediaSkyMask(ctx, W, H, pal, grade) {
|
|
927
|
+
const g = clamp014(grade);
|
|
928
|
+
if (g <= 0.01) return;
|
|
929
|
+
const bottom = H * MEDIA.skyMaskBottom;
|
|
930
|
+
const shade = ctx.createLinearGradient(0, 0, 0, bottom);
|
|
931
|
+
shade.addColorStop(0, rgba(mix("#ffffff", pal.skyTop, 0.62), 0.9 * g));
|
|
932
|
+
shade.addColorStop(0.45, rgba(mix("#ffffff", pal.skyMid, 0.5), 0.72 * g));
|
|
933
|
+
shade.addColorStop(1, rgba(mix("#ffffff", pal.skyBottom, 0.3), 0));
|
|
934
|
+
withComposite(ctx, "multiply", () => {
|
|
935
|
+
ctx.fillStyle = shade;
|
|
936
|
+
ctx.fillRect(0, 0, W, bottom);
|
|
937
|
+
});
|
|
938
|
+
const veil = ctx.createLinearGradient(0, 0, 0, bottom);
|
|
939
|
+
veil.addColorStop(0, rgba(pal.skyTop, 0.34 * g));
|
|
940
|
+
veil.addColorStop(0.5, rgba(pal.skyMid, 0.2 * g));
|
|
941
|
+
veil.addColorStop(1, rgba(pal.skyBottom, 0));
|
|
942
|
+
ctx.fillStyle = veil;
|
|
943
|
+
ctx.fillRect(0, 0, W, bottom);
|
|
944
|
+
}
|
|
945
|
+
function drawMediaSweep(ctx, W, H, pal, t, motion, grade) {
|
|
946
|
+
const phase = (t * MEDIA.breathFreq % 1 + 1) % 1;
|
|
947
|
+
const bandHeight = H * MEDIA.sweepHeight;
|
|
948
|
+
const y = -bandHeight + phase * (H + bandHeight * 2);
|
|
949
|
+
const strength = Math.sin(Math.PI * phase);
|
|
950
|
+
const alpha = 0.05 * strength * (0.45 + 0.75 * clamp014(grade)) * (0.5 + 0.5 * clamp014(motion));
|
|
951
|
+
if (alpha <= 4e-3) return;
|
|
952
|
+
const sweep = ctx.createLinearGradient(0, y, 0, y + bandHeight);
|
|
953
|
+
sweep.addColorStop(0, rgba(pal.foam, 0));
|
|
954
|
+
sweep.addColorStop(0.5, rgba(mix(pal.horizonGlow, "#ffffff", 0.4), alpha));
|
|
955
|
+
sweep.addColorStop(1, rgba(pal.foam, 0));
|
|
956
|
+
withComposite(ctx, "screen", () => {
|
|
957
|
+
ctx.fillStyle = sweep;
|
|
958
|
+
ctx.fillRect(0, y, W, bandHeight);
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function drawVignette(ctx, W, H, strength) {
|
|
962
|
+
const s = clamp014(strength);
|
|
963
|
+
if (s <= 0.01) return;
|
|
964
|
+
const gradient = ctx.createRadialGradient(W * 0.5, H * 0.46, Math.min(W, H) * 0.34, W * 0.5, H * 0.5, Math.max(W, H) * 0.78);
|
|
965
|
+
gradient.addColorStop(0, "rgba(0,0,0,0)");
|
|
966
|
+
gradient.addColorStop(1, rgba("#000000", s));
|
|
967
|
+
ctx.fillStyle = gradient;
|
|
968
|
+
ctx.fillRect(0, 0, W, H);
|
|
969
|
+
}
|
|
970
|
+
function renderMediaScene(ctx, W, H, t, pal, asset, media, warmth) {
|
|
971
|
+
const el = asset && asset.el;
|
|
972
|
+
if (!el) return false;
|
|
973
|
+
let iw = 0;
|
|
974
|
+
let ih = 0;
|
|
975
|
+
if (asset.kind === "video") {
|
|
976
|
+
if (num2(el.readyState, 0) < 2) return false;
|
|
977
|
+
iw = num2(el.videoWidth, 0);
|
|
978
|
+
ih = num2(el.videoHeight, 0);
|
|
979
|
+
} else {
|
|
980
|
+
iw = num2(el.naturalWidth, num2(asset.w, 0));
|
|
981
|
+
ih = num2(el.naturalHeight, num2(asset.h, 0));
|
|
982
|
+
}
|
|
983
|
+
if (!iw || !ih) return false;
|
|
984
|
+
const motion = motionOf(media);
|
|
985
|
+
const grade = gradeOf(media);
|
|
986
|
+
const seed = clamp014(num2(asset.seed, 0));
|
|
987
|
+
const phase = ((t / MEDIA.kenBurnsPeriod + seed) % 1 + 1) % 1;
|
|
988
|
+
const ease = 0.5 - 0.5 * Math.cos(TAU3 * phase);
|
|
989
|
+
const zoom = MEDIA.overscan + MEDIA.kenBurnsZoom * motion * (0.35 + 0.65 * ease);
|
|
990
|
+
const scale = Math.max(W / iw, H / ih) * zoom;
|
|
991
|
+
const dw = Math.max(W + 2, iw * scale);
|
|
992
|
+
const dh = Math.max(H + 2, ih * scale);
|
|
993
|
+
const marginX = Math.max(0, (dw - W) / 2);
|
|
994
|
+
const marginY = Math.max(0, (dh - H) / 2);
|
|
995
|
+
const panX = marginX * 0.8 * motion * Math.sin(TAU3 * phase + seed * TAU3);
|
|
996
|
+
const panY = marginY * 0.5 * motion * Math.cos(TAU3 * phase * 0.71 + seed * 5.1);
|
|
997
|
+
const breath = motion > 0.02 ? Math.sin(TAU3 * MEDIA.breathFreq * t) * MEDIA.breathPx * (0.4 + 0.6 * motion) : 0;
|
|
998
|
+
const dx = (W - dw) / 2 + clamp2(panX + breath, -marginX, marginX);
|
|
999
|
+
const dy = (H - dh) / 2 + clamp2(panY, -marginY, marginY);
|
|
1000
|
+
ctx.drawImage(el, dx, dy, dw, dh);
|
|
1001
|
+
drawMediaGrade(ctx, W, H, pal, grade);
|
|
1002
|
+
drawMediaSweep(ctx, W, H, pal, t, motion, grade);
|
|
1003
|
+
drawMediaSkyMask(ctx, W, H, pal, grade);
|
|
1004
|
+
drawVignette(ctx, W, H, MEDIA.vignette * (0.5 + 0.5 * grade));
|
|
1005
|
+
if (warmth) {
|
|
1006
|
+
ctx.fillStyle = rgba(warmth > 0 ? "#ffb066" : "#6fa8ff", Math.abs(warmth) * 0.9);
|
|
1007
|
+
ctx.fillRect(0, 0, W, H);
|
|
1008
|
+
}
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
var lastRenderTime = 0;
|
|
1012
|
+
var lazyWaterField = null;
|
|
1013
|
+
function resolveField(field2) {
|
|
1014
|
+
if (field2) return field2;
|
|
1015
|
+
if (!lazyWaterField) {
|
|
1016
|
+
try {
|
|
1017
|
+
lazyWaterField = createWaterField(typeof document !== "undefined" ? document : void 0);
|
|
1018
|
+
} catch (_error) {
|
|
1019
|
+
lazyWaterField = null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return lazyWaterField;
|
|
1023
|
+
}
|
|
1024
|
+
function renderScene(ctx, W, H, t, pal, tideN, warmth, field2) {
|
|
1025
|
+
lastRenderTime = t;
|
|
1026
|
+
field2 = resolveField(field2);
|
|
1027
|
+
const px = clamp2(H / 720, 0.65, 1.8);
|
|
1028
|
+
const nearLift = tideN * H * 6e-3;
|
|
1029
|
+
const horizonY = H * HORIZON2 + nearLift;
|
|
1030
|
+
ctx.save();
|
|
1031
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1032
|
+
ctx.globalAlpha = 1;
|
|
1033
|
+
ctx.globalCompositeOperation = "source-over";
|
|
1034
|
+
ctx.imageSmoothingEnabled = true;
|
|
1035
|
+
drawSky(ctx, W, H, horizonY, pal);
|
|
1036
|
+
drawStars(ctx, W, H, horizonY, pal, t);
|
|
1037
|
+
drawSun(ctx, W, H, pal);
|
|
1038
|
+
drawMoon(ctx, W, H, pal);
|
|
1039
|
+
if (daylightOf(pal) > 0.02) {
|
|
1040
|
+
try {
|
|
1041
|
+
drawBirds(ctx, { W, H, horizonY, t, pal });
|
|
1042
|
+
} catch (_error) {
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
let drawn = false;
|
|
1046
|
+
if (field2) {
|
|
1047
|
+
try {
|
|
1048
|
+
drawn = drawWater(ctx, { W, H, horizonY, t, pal, tideN }, field2);
|
|
1049
|
+
} catch (_error) {
|
|
1050
|
+
drawn = false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (!drawn) {
|
|
1054
|
+
drawFarSea(ctx, W, H, horizonY, pal, t, px);
|
|
1055
|
+
}
|
|
1056
|
+
if (warmth) {
|
|
1057
|
+
ctx.fillStyle = rgba(warmth > 0 ? "#ffb066" : "#6fa8ff", Math.abs(warmth) * 0.9);
|
|
1058
|
+
ctx.fillRect(0, 0, W, H);
|
|
1059
|
+
}
|
|
1060
|
+
ctx.restore();
|
|
1061
|
+
}
|
|
1062
|
+
var HOST_STYLE = {
|
|
1063
|
+
position: "absolute",
|
|
1064
|
+
top: 0,
|
|
1065
|
+
right: 0,
|
|
1066
|
+
bottom: 0,
|
|
1067
|
+
left: 0,
|
|
1068
|
+
overflow: "hidden",
|
|
1069
|
+
pointerEvents: "none",
|
|
1070
|
+
// 装饰层:点击/拖拽都留给覆盖层自己处理
|
|
1071
|
+
background: "#0b1220"
|
|
1072
|
+
};
|
|
1073
|
+
var CANVAS_STYLE = {
|
|
1074
|
+
display: "block",
|
|
1075
|
+
width: "100%",
|
|
1076
|
+
height: "100%",
|
|
1077
|
+
border: "none",
|
|
1078
|
+
background: "transparent"
|
|
1079
|
+
};
|
|
1080
|
+
function SeaCanvas({ now, media, form, paused, reducedMotion, onStats }) {
|
|
1081
|
+
const hostRef = (0, import_react.useRef)(null);
|
|
1082
|
+
const canvasRef = (0, import_react.useRef)(null);
|
|
1083
|
+
const nowRef = (0, import_react.useRef)(now);
|
|
1084
|
+
const mediaRef = (0, import_react.useRef)(media);
|
|
1085
|
+
const formRef = (0, import_react.useRef)(form);
|
|
1086
|
+
const pausedRef = (0, import_react.useRef)(Boolean(paused));
|
|
1087
|
+
const reducedRef = (0, import_react.useRef)(Boolean(reducedMotion));
|
|
1088
|
+
const statsRef = (0, import_react.useRef)(onStats);
|
|
1089
|
+
const apiRef = (0, import_react.useRef)(null);
|
|
1090
|
+
(0, import_react.useEffect)(() => {
|
|
1091
|
+
nowRef.current = now;
|
|
1092
|
+
const api = apiRef.current;
|
|
1093
|
+
if (api) api.refreshNow();
|
|
1094
|
+
}, [now]);
|
|
1095
|
+
(0, import_react.useEffect)(() => {
|
|
1096
|
+
mediaRef.current = media;
|
|
1097
|
+
const api = apiRef.current;
|
|
1098
|
+
if (api) api.setMedia(media);
|
|
1099
|
+
}, [media]);
|
|
1100
|
+
(0, import_react.useEffect)(() => {
|
|
1101
|
+
formRef.current = form;
|
|
1102
|
+
}, [form]);
|
|
1103
|
+
(0, import_react.useEffect)(() => {
|
|
1104
|
+
statsRef.current = onStats;
|
|
1105
|
+
}, [onStats]);
|
|
1106
|
+
(0, import_react.useEffect)(() => {
|
|
1107
|
+
pausedRef.current = Boolean(paused);
|
|
1108
|
+
const api = apiRef.current;
|
|
1109
|
+
if (api) api.sync();
|
|
1110
|
+
}, [paused]);
|
|
1111
|
+
(0, import_react.useEffect)(() => {
|
|
1112
|
+
reducedRef.current = Boolean(reducedMotion);
|
|
1113
|
+
const api = apiRef.current;
|
|
1114
|
+
if (api) api.sync();
|
|
1115
|
+
}, [reducedMotion]);
|
|
1116
|
+
(0, import_react.useEffect)(() => {
|
|
1117
|
+
const host = hostRef.current;
|
|
1118
|
+
const canvas = canvasRef.current;
|
|
1119
|
+
if (!host || !canvas) return void 0;
|
|
1120
|
+
const perfNow = typeof performance !== "undefined" && typeof performance.now === "function" ? () => performance.now() : () => Date.now();
|
|
1121
|
+
const state = {
|
|
1122
|
+
alive: true,
|
|
1123
|
+
mode: "off",
|
|
1124
|
+
// 'anim' | 'static' | 'off'
|
|
1125
|
+
hidden: typeof document !== "undefined" ? document.hidden === true : false,
|
|
1126
|
+
blurred: false,
|
|
1127
|
+
ctx: null,
|
|
1128
|
+
w: 0,
|
|
1129
|
+
h: 0,
|
|
1130
|
+
dpr: 0,
|
|
1131
|
+
raf: 0,
|
|
1132
|
+
staticTimer: 0,
|
|
1133
|
+
resizeTimer: 0,
|
|
1134
|
+
clockMs: 0,
|
|
1135
|
+
clockAnchor: 0,
|
|
1136
|
+
lastDrawMs: 0,
|
|
1137
|
+
curSecond: -1,
|
|
1138
|
+
curCount: 0,
|
|
1139
|
+
seconds: [],
|
|
1140
|
+
fps: 0,
|
|
1141
|
+
degraded: false,
|
|
1142
|
+
mediaCap: false,
|
|
1143
|
+
// 媒体就绪 → 缓冲上限提到 2560
|
|
1144
|
+
staticStep: 0,
|
|
1145
|
+
// 水面斑块场的低分辨率高度场缓冲:挂载时建一次,整个生命周期复用。
|
|
1146
|
+
// 建不出来就是 null,renderScene 会退回 drawFarSea(简单的远景渐变),不会留白。
|
|
1147
|
+
field: (() => {
|
|
1148
|
+
try {
|
|
1149
|
+
return createWaterField(typeof document !== "undefined" ? document : void 0);
|
|
1150
|
+
} catch (_error) {
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
})()
|
|
1154
|
+
};
|
|
1155
|
+
try {
|
|
1156
|
+
state.ctx = canvas.getContext("2d", { alpha: false });
|
|
1157
|
+
} catch (_error) {
|
|
1158
|
+
state.ctx = null;
|
|
1159
|
+
}
|
|
1160
|
+
if (!state.ctx) {
|
|
1161
|
+
try {
|
|
1162
|
+
state.ctx = canvas.getContext("2d");
|
|
1163
|
+
} catch (_error) {
|
|
1164
|
+
state.ctx = null;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (!state.ctx) {
|
|
1168
|
+
try {
|
|
1169
|
+
canvas.style.display = "none";
|
|
1170
|
+
} catch (_error) {
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const asset = { key: "", kind: null, el: null, ready: false, failed: false, w: 0, h: 0, seed: 0 };
|
|
1174
|
+
const bufferCap = () => state.mediaCap ? TUNING.mediaBufferWidth : TUNING.maxBufferWidth;
|
|
1175
|
+
function teardownAsset() {
|
|
1176
|
+
const el = asset.el;
|
|
1177
|
+
asset.el = null;
|
|
1178
|
+
asset.kind = null;
|
|
1179
|
+
asset.key = "";
|
|
1180
|
+
asset.ready = false;
|
|
1181
|
+
asset.failed = false;
|
|
1182
|
+
asset.w = 0;
|
|
1183
|
+
asset.h = 0;
|
|
1184
|
+
asset.seed = 0;
|
|
1185
|
+
if (!el) return;
|
|
1186
|
+
try {
|
|
1187
|
+
if (typeof el.pause === "function") el.pause();
|
|
1188
|
+
} catch (_error) {
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
if (typeof el.removeAttribute === "function") el.removeAttribute("src");
|
|
1192
|
+
} catch (_error) {
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
if (typeof el.load === "function") el.load();
|
|
1196
|
+
} catch (_error) {
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function setVideoPlaying(playing) {
|
|
1200
|
+
const el = asset.el;
|
|
1201
|
+
if (!el || asset.kind !== "video") return;
|
|
1202
|
+
try {
|
|
1203
|
+
if (playing) {
|
|
1204
|
+
const started = el.play();
|
|
1205
|
+
if (started && typeof started.catch === "function") started.catch(() => {
|
|
1206
|
+
});
|
|
1207
|
+
} else if (typeof el.pause === "function") {
|
|
1208
|
+
el.pause();
|
|
1209
|
+
}
|
|
1210
|
+
} catch (_error) {
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function onMediaReady() {
|
|
1214
|
+
state.mediaCap = true;
|
|
1215
|
+
measureNow();
|
|
1216
|
+
if (state.mode !== "anim") draw(perfNow());
|
|
1217
|
+
}
|
|
1218
|
+
function onMediaFailed() {
|
|
1219
|
+
state.mediaCap = false;
|
|
1220
|
+
measureNow();
|
|
1221
|
+
}
|
|
1222
|
+
function loadAsset(next) {
|
|
1223
|
+
teardownAsset();
|
|
1224
|
+
if (!next || typeof next !== "object") return;
|
|
1225
|
+
const kind = next.kind === "video" ? "video" : next.kind === "image" ? "image" : null;
|
|
1226
|
+
const url = typeof next.url === "string" ? next.url.trim() : "";
|
|
1227
|
+
if (!kind || !url) return;
|
|
1228
|
+
asset.key = kind + "|" + url;
|
|
1229
|
+
asset.kind = kind;
|
|
1230
|
+
asset.seed = hashString(asset.key);
|
|
1231
|
+
try {
|
|
1232
|
+
if (kind === "image") {
|
|
1233
|
+
const img = new Image();
|
|
1234
|
+
asset.el = img;
|
|
1235
|
+
img.onload = () => {
|
|
1236
|
+
if (asset.el !== img) return;
|
|
1237
|
+
const w = num2(img.naturalWidth, 0);
|
|
1238
|
+
const h = num2(img.naturalHeight, 0);
|
|
1239
|
+
if (!w || !h) {
|
|
1240
|
+
asset.failed = true;
|
|
1241
|
+
onMediaFailed();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
asset.w = w;
|
|
1245
|
+
asset.h = h;
|
|
1246
|
+
asset.failed = false;
|
|
1247
|
+
asset.ready = true;
|
|
1248
|
+
onMediaReady();
|
|
1249
|
+
};
|
|
1250
|
+
img.onerror = () => {
|
|
1251
|
+
if (asset.el !== img) return;
|
|
1252
|
+
asset.ready = false;
|
|
1253
|
+
asset.failed = true;
|
|
1254
|
+
onMediaFailed();
|
|
1255
|
+
};
|
|
1256
|
+
try {
|
|
1257
|
+
img.decoding = "async";
|
|
1258
|
+
} catch (_error) {
|
|
1259
|
+
}
|
|
1260
|
+
img.src = url;
|
|
1261
|
+
} else {
|
|
1262
|
+
const video = document.createElement("video");
|
|
1263
|
+
asset.el = video;
|
|
1264
|
+
video.muted = true;
|
|
1265
|
+
video.loop = true;
|
|
1266
|
+
video.autoplay = true;
|
|
1267
|
+
video.preload = "auto";
|
|
1268
|
+
try {
|
|
1269
|
+
video.setAttribute("playsinline", "");
|
|
1270
|
+
video.setAttribute("webkit-playsinline", "");
|
|
1271
|
+
video.setAttribute("muted", "");
|
|
1272
|
+
video.setAttribute("disableremoteplayback", "");
|
|
1273
|
+
} catch (_error) {
|
|
1274
|
+
}
|
|
1275
|
+
const markReady = () => {
|
|
1276
|
+
if (asset.el !== video) return;
|
|
1277
|
+
const w = num2(video.videoWidth, 0);
|
|
1278
|
+
const h = num2(video.videoHeight, 0);
|
|
1279
|
+
if (!w || !h) return;
|
|
1280
|
+
asset.w = w;
|
|
1281
|
+
asset.h = h;
|
|
1282
|
+
asset.failed = false;
|
|
1283
|
+
asset.ready = true;
|
|
1284
|
+
onMediaReady();
|
|
1285
|
+
};
|
|
1286
|
+
try {
|
|
1287
|
+
video.addEventListener("loadeddata", markReady);
|
|
1288
|
+
video.addEventListener("canplay", markReady);
|
|
1289
|
+
video.addEventListener("error", () => {
|
|
1290
|
+
if (asset.el !== video) return;
|
|
1291
|
+
asset.ready = false;
|
|
1292
|
+
asset.failed = true;
|
|
1293
|
+
onMediaFailed();
|
|
1294
|
+
});
|
|
1295
|
+
} catch (_error) {
|
|
1296
|
+
}
|
|
1297
|
+
video.src = url;
|
|
1298
|
+
setVideoPlaying(true);
|
|
1299
|
+
}
|
|
1300
|
+
} catch (_error) {
|
|
1301
|
+
asset.ready = false;
|
|
1302
|
+
asset.failed = true;
|
|
1303
|
+
onMediaFailed();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const requestFrame = typeof requestAnimationFrame === "function" ? (fn) => requestAnimationFrame(fn) : (fn) => setTimeout(() => fn(perfNow()), 16);
|
|
1307
|
+
const cancelFrame = typeof cancelAnimationFrame === "function" ? (id) => cancelAnimationFrame(id) : (id) => clearTimeout(id);
|
|
1308
|
+
const currentPalette = () => {
|
|
1309
|
+
try {
|
|
1310
|
+
return paletteFor(nowRef.current);
|
|
1311
|
+
} catch (_error) {
|
|
1312
|
+
return paletteFor(null);
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
const tideNormalized = () => {
|
|
1316
|
+
const source = nowRef.current;
|
|
1317
|
+
const height = source && source.tide ? source.tide.height : 0;
|
|
1318
|
+
return clamp2(num2(height, 0) / 1.2, -1, 1);
|
|
1319
|
+
};
|
|
1320
|
+
const advanceClock = (ms) => {
|
|
1321
|
+
const nowMs = num2(ms, perfNow());
|
|
1322
|
+
if (!state.clockAnchor) state.clockAnchor = nowMs;
|
|
1323
|
+
const delta = nowMs - state.clockAnchor;
|
|
1324
|
+
state.clockAnchor = nowMs;
|
|
1325
|
+
state.clockMs += clamp2(delta, 0, TUNING.maxStepMs);
|
|
1326
|
+
return state.clockMs;
|
|
1327
|
+
};
|
|
1328
|
+
const animTime = (ms) => advanceClock(ms) / 1e3;
|
|
1329
|
+
const staticWarmth = () => state.staticStep % 2 === 0 ? 0.022 : -0.022;
|
|
1330
|
+
const paintFallback = (pal, ms) => {
|
|
1331
|
+
try {
|
|
1332
|
+
const breathe = 0.97 + 0.03 * Math.sin(num2(ms, perfNow()) / 1e3 * 0.35);
|
|
1333
|
+
const horizonTint = mix(pal.skyBottom, pal.foam, 0.5);
|
|
1334
|
+
host.style.background = "linear-gradient(180deg, " + pal.skyTop + " 0%, " + pal.skyMid + " 32%, " + pal.skyBottom + " 50%, " + horizonTint + " 52%, " + pal.seaFar + " 56%, " + pal.seaMid + " 76%, " + pal.seaNear + " 100%)";
|
|
1335
|
+
host.style.filter = "brightness(" + round2(breathe, 3) + ")";
|
|
1336
|
+
} catch (_error) {
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
const draw = (ms) => {
|
|
1340
|
+
const pal = currentPalette();
|
|
1341
|
+
if (!state.ctx) {
|
|
1342
|
+
paintFallback(pal, ms);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (!state.w || !state.h) return;
|
|
1346
|
+
const t = animTime(ms);
|
|
1347
|
+
const warmth = state.mode === "static" ? staticWarmth() : 0;
|
|
1348
|
+
if (asset.el) {
|
|
1349
|
+
let drewMedia = false;
|
|
1350
|
+
try {
|
|
1351
|
+
drewMedia = renderMediaScene(state.ctx, state.w, state.h, t, pal, asset, mediaRef.current, warmth);
|
|
1352
|
+
} catch (_error) {
|
|
1353
|
+
drewMedia = false;
|
|
1354
|
+
}
|
|
1355
|
+
if (drewMedia) return;
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
renderScene(state.ctx, state.w, state.h, t, pal, tideNormalized(), warmth, state.field);
|
|
1359
|
+
} catch (_error) {
|
|
1360
|
+
state.ctx = null;
|
|
1361
|
+
try {
|
|
1362
|
+
canvas.style.display = "none";
|
|
1363
|
+
} catch (_ignored) {
|
|
1364
|
+
}
|
|
1365
|
+
paintFallback(pal, ms);
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
const reportStats = () => {
|
|
1369
|
+
const callback = statsRef.current;
|
|
1370
|
+
if (typeof callback !== "function") return;
|
|
1371
|
+
try {
|
|
1372
|
+
callback({ fps: state.fps, downgraded: state.degraded });
|
|
1373
|
+
} catch (_error) {
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
const trackFps = (ms, targetFps) => {
|
|
1377
|
+
const second = Math.floor(ms / 1e3);
|
|
1378
|
+
if (second !== state.curSecond) {
|
|
1379
|
+
if (state.curSecond >= 0) {
|
|
1380
|
+
state.seconds.push(state.curCount);
|
|
1381
|
+
while (state.seconds.length > 3) state.seconds.shift();
|
|
1382
|
+
}
|
|
1383
|
+
state.curSecond = second;
|
|
1384
|
+
state.curCount = 0;
|
|
1385
|
+
const n = state.seconds.length;
|
|
1386
|
+
if (n > 0) {
|
|
1387
|
+
let sum = 0;
|
|
1388
|
+
for (const count of state.seconds) sum += count;
|
|
1389
|
+
const average = sum / n;
|
|
1390
|
+
state.fps = Math.round(average * 10) / 10;
|
|
1391
|
+
if (n >= 3 && !state.degraded && targetFps === TUNING.targetFps && average < TUNING.slowFps) {
|
|
1392
|
+
state.degraded = true;
|
|
1393
|
+
state.lastDrawMs = 0;
|
|
1394
|
+
}
|
|
1395
|
+
reportStats();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
state.curCount += 1;
|
|
1399
|
+
};
|
|
1400
|
+
const stopRaf = () => {
|
|
1401
|
+
if (state.raf) {
|
|
1402
|
+
try {
|
|
1403
|
+
cancelFrame(state.raf);
|
|
1404
|
+
} catch (_error) {
|
|
1405
|
+
}
|
|
1406
|
+
state.raf = 0;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
const clearStatic = () => {
|
|
1410
|
+
if (state.staticTimer) {
|
|
1411
|
+
clearTimeout(state.staticTimer);
|
|
1412
|
+
state.staticTimer = 0;
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
function frame(ms) {
|
|
1416
|
+
state.raf = 0;
|
|
1417
|
+
if (!state.alive) return;
|
|
1418
|
+
const target = state.degraded ? TUNING.degradedFps : formRef.current === "pip" ? TUNING.pipFps : TUNING.targetFps;
|
|
1419
|
+
const interval = 1e3 / target;
|
|
1420
|
+
const gate = state.ctx ? interval : 900;
|
|
1421
|
+
if (state.lastDrawMs && ms - state.lastDrawMs < gate - 0.5) {
|
|
1422
|
+
scheduleFrame();
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
state.lastDrawMs = ms;
|
|
1426
|
+
draw(ms);
|
|
1427
|
+
if (state.ctx) trackFps(ms, target);
|
|
1428
|
+
if (num2(typeof window !== "undefined" ? window.devicePixelRatio : 1, 1) !== state.dpr) scheduleResize();
|
|
1429
|
+
scheduleFrame();
|
|
1430
|
+
}
|
|
1431
|
+
function scheduleFrame() {
|
|
1432
|
+
if (!state.alive || state.raf) return;
|
|
1433
|
+
state.raf = requestFrame(frame);
|
|
1434
|
+
}
|
|
1435
|
+
function scheduleStatic() {
|
|
1436
|
+
clearStatic();
|
|
1437
|
+
state.staticTimer = setTimeout(() => {
|
|
1438
|
+
state.staticTimer = 0;
|
|
1439
|
+
if (!state.alive || state.mode !== "static") return;
|
|
1440
|
+
state.staticStep += 1;
|
|
1441
|
+
draw(perfNow());
|
|
1442
|
+
scheduleStatic();
|
|
1443
|
+
}, TUNING.reducedRefreshMs);
|
|
1444
|
+
}
|
|
1445
|
+
function measureNow() {
|
|
1446
|
+
if (!state.alive) return;
|
|
1447
|
+
let rect = { width: 0, height: 0 };
|
|
1448
|
+
try {
|
|
1449
|
+
rect = host.getBoundingClientRect();
|
|
1450
|
+
} catch (_error) {
|
|
1451
|
+
rect = { width: 0, height: 0 };
|
|
1452
|
+
}
|
|
1453
|
+
const dpr = clamp2(num2(typeof window !== "undefined" ? window.devicePixelRatio : 1, 1), 1, 3);
|
|
1454
|
+
const cssWidth = Math.max(64, Math.round(num2(rect.width, 0) || host.clientWidth || 640));
|
|
1455
|
+
const cssHeight = Math.max(64, Math.round(num2(rect.height, 0) || host.clientHeight || 360));
|
|
1456
|
+
const rawWidth = cssWidth * dpr;
|
|
1457
|
+
const width = Math.max(160, Math.min(bufferCap(), Math.round(rawWidth)));
|
|
1458
|
+
const height = Math.max(90, Math.round(cssHeight * dpr * (width / rawWidth)));
|
|
1459
|
+
state.dpr = dpr;
|
|
1460
|
+
if (width === state.w && height === state.h) return;
|
|
1461
|
+
state.w = width;
|
|
1462
|
+
state.h = height;
|
|
1463
|
+
if (state.ctx) {
|
|
1464
|
+
try {
|
|
1465
|
+
canvas.width = width;
|
|
1466
|
+
canvas.height = height;
|
|
1467
|
+
} catch (_error) {
|
|
1468
|
+
state.ctx = null;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
state.lastDrawMs = 0;
|
|
1472
|
+
if (state.mode === "static") draw(perfNow());
|
|
1473
|
+
}
|
|
1474
|
+
function scheduleResize() {
|
|
1475
|
+
if (!state.alive) return;
|
|
1476
|
+
if (state.resizeTimer) clearTimeout(state.resizeTimer);
|
|
1477
|
+
state.resizeTimer = setTimeout(() => {
|
|
1478
|
+
state.resizeTimer = 0;
|
|
1479
|
+
measureNow();
|
|
1480
|
+
}, TUNING.resizeDebounceMs);
|
|
1481
|
+
}
|
|
1482
|
+
function applyMode() {
|
|
1483
|
+
if (!state.alive) return;
|
|
1484
|
+
const invisible = pausedRef.current || state.hidden || TUNING.stopOnBlur && state.blurred;
|
|
1485
|
+
const want = invisible ? "off" : reducedRef.current ? "static" : "anim";
|
|
1486
|
+
if (want === state.mode) return;
|
|
1487
|
+
const previous = state.mode;
|
|
1488
|
+
state.mode = want;
|
|
1489
|
+
if (want === "off") {
|
|
1490
|
+
stopRaf();
|
|
1491
|
+
clearStatic();
|
|
1492
|
+
setVideoPlaying(false);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (previous === "off") state.clockAnchor = 0;
|
|
1496
|
+
if (want === "anim") {
|
|
1497
|
+
clearStatic();
|
|
1498
|
+
state.curSecond = -1;
|
|
1499
|
+
state.curCount = 0;
|
|
1500
|
+
state.seconds = [];
|
|
1501
|
+
state.lastDrawMs = 0;
|
|
1502
|
+
setVideoPlaying(true);
|
|
1503
|
+
scheduleFrame();
|
|
1504
|
+
} else {
|
|
1505
|
+
stopRaf();
|
|
1506
|
+
setVideoPlaying(false);
|
|
1507
|
+
draw(perfNow());
|
|
1508
|
+
scheduleStatic();
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function onVisibilityChange() {
|
|
1512
|
+
state.hidden = typeof document !== "undefined" && document.hidden === true;
|
|
1513
|
+
applyMode();
|
|
1514
|
+
}
|
|
1515
|
+
const onResize = () => scheduleResize();
|
|
1516
|
+
function onWindowBlur() {
|
|
1517
|
+
state.blurred = true;
|
|
1518
|
+
if (TUNING.stopOnBlur) applyMode();
|
|
1519
|
+
}
|
|
1520
|
+
function onWindowFocus() {
|
|
1521
|
+
state.blurred = false;
|
|
1522
|
+
if (TUNING.stopOnBlur) applyMode();
|
|
1523
|
+
}
|
|
1524
|
+
let resizeObserver = null;
|
|
1525
|
+
try {
|
|
1526
|
+
if (typeof ResizeObserver === "function") {
|
|
1527
|
+
resizeObserver = new ResizeObserver(() => scheduleResize());
|
|
1528
|
+
resizeObserver.observe(host);
|
|
1529
|
+
}
|
|
1530
|
+
} catch (_error) {
|
|
1531
|
+
resizeObserver = null;
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1535
|
+
if (typeof window !== "undefined") {
|
|
1536
|
+
window.addEventListener("resize", onResize);
|
|
1537
|
+
window.addEventListener("blur", onWindowBlur);
|
|
1538
|
+
window.addEventListener("focus", onWindowFocus);
|
|
1539
|
+
}
|
|
1540
|
+
} catch (_error) {
|
|
1541
|
+
}
|
|
1542
|
+
apiRef.current = {
|
|
1543
|
+
sync: applyMode,
|
|
1544
|
+
refreshNow: () => {
|
|
1545
|
+
if (state.mode === "static") draw(perfNow());
|
|
1546
|
+
},
|
|
1547
|
+
setMedia: (next) => {
|
|
1548
|
+
const kind = next && typeof next === "object" ? next.kind === "video" ? "video" : next.kind === "image" ? "image" : "" : "";
|
|
1549
|
+
const url = next && typeof next.url === "string" ? next.url.trim() : "";
|
|
1550
|
+
const key = kind && url ? kind + "|" + url : "";
|
|
1551
|
+
if (key === asset.key) return;
|
|
1552
|
+
loadAsset(next);
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
apiRef.current.setMedia(mediaRef.current);
|
|
1556
|
+
measureNow();
|
|
1557
|
+
applyMode();
|
|
1558
|
+
return () => {
|
|
1559
|
+
state.alive = false;
|
|
1560
|
+
stopRaf();
|
|
1561
|
+
clearStatic();
|
|
1562
|
+
if (state.resizeTimer) {
|
|
1563
|
+
clearTimeout(state.resizeTimer);
|
|
1564
|
+
state.resizeTimer = 0;
|
|
1565
|
+
}
|
|
1566
|
+
try {
|
|
1567
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1568
|
+
if (typeof window !== "undefined") {
|
|
1569
|
+
window.removeEventListener("resize", onResize);
|
|
1570
|
+
window.removeEventListener("blur", onWindowBlur);
|
|
1571
|
+
window.removeEventListener("focus", onWindowFocus);
|
|
1572
|
+
}
|
|
1573
|
+
} catch (_error) {
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
if (resizeObserver && typeof resizeObserver.disconnect === "function") resizeObserver.disconnect();
|
|
1577
|
+
} catch (_error) {
|
|
1578
|
+
}
|
|
1579
|
+
resizeObserver = null;
|
|
1580
|
+
teardownAsset();
|
|
1581
|
+
apiRef.current = null;
|
|
1582
|
+
try {
|
|
1583
|
+
canvas.width = 0;
|
|
1584
|
+
canvas.height = 0;
|
|
1585
|
+
} catch (_error) {
|
|
1586
|
+
}
|
|
1587
|
+
try {
|
|
1588
|
+
host.style.background = "";
|
|
1589
|
+
host.style.filter = "";
|
|
1590
|
+
} catch (_error) {
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
}, []);
|
|
1594
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref: hostRef, style: HOST_STYLE, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("canvas", { ref: canvasRef, style: CANVAS_STYLE }) });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// src/cities.js
|
|
1598
|
+
var CITIES = Object.freeze(
|
|
1599
|
+
[
|
|
1600
|
+
{ id: "shanghai", zh: "上海", en: "Shanghai", lat: 31.23, lon: 121.47, tz: "Asia/Shanghai" },
|
|
1601
|
+
{ id: "beijing", zh: "北京", en: "Beijing", lat: 39.9, lon: 116.41, tz: "Asia/Shanghai" },
|
|
1602
|
+
{ id: "guangzhou", zh: "广州", en: "Guangzhou", lat: 23.13, lon: 113.26, tz: "Asia/Shanghai" },
|
|
1603
|
+
{ id: "shenzhen", zh: "深圳", en: "Shenzhen", lat: 22.54, lon: 114.06, tz: "Asia/Shanghai" },
|
|
1604
|
+
{ id: "chengdu", zh: "成都", en: "Chengdu", lat: 30.57, lon: 104.07, tz: "Asia/Shanghai" },
|
|
1605
|
+
{ id: "hangzhou", zh: "杭州", en: "Hangzhou", lat: 30.27, lon: 120.15, tz: "Asia/Shanghai" },
|
|
1606
|
+
{ id: "nanjing", zh: "南京", en: "Nanjing", lat: 32.06, lon: 118.8, tz: "Asia/Shanghai" },
|
|
1607
|
+
{ id: "wuhan", zh: "武汉", en: "Wuhan", lat: 30.59, lon: 114.31, tz: "Asia/Shanghai" },
|
|
1608
|
+
{ id: "xian", zh: "西安", en: "Xi'an", lat: 34.34, lon: 108.94, tz: "Asia/Shanghai" },
|
|
1609
|
+
{ id: "chongqing", zh: "重庆", en: "Chongqing", lat: 29.56, lon: 106.55, tz: "Asia/Shanghai" },
|
|
1610
|
+
{ id: "qingdao", zh: "青岛", en: "Qingdao", lat: 36.07, lon: 120.38, tz: "Asia/Shanghai" },
|
|
1611
|
+
{ id: "xiamen", zh: "厦门", en: "Xiamen", lat: 24.48, lon: 118.09, tz: "Asia/Shanghai" },
|
|
1612
|
+
{ id: "harbin", zh: "哈尔滨", en: "Harbin", lat: 45.8, lon: 126.53, tz: "Asia/Shanghai" },
|
|
1613
|
+
{ id: "kunming", zh: "昆明", en: "Kunming", lat: 25.04, lon: 102.71, tz: "Asia/Shanghai" },
|
|
1614
|
+
{ id: "urumqi", zh: "乌鲁木齐", en: "Urumqi", lat: 43.83, lon: 87.62, tz: "Asia/Shanghai" },
|
|
1615
|
+
{ id: "lhasa", zh: "拉萨", en: "Lhasa", lat: 29.65, lon: 91.14, tz: "Asia/Shanghai" },
|
|
1616
|
+
{ id: "hongkong", zh: "香港", en: "Hong Kong", lat: 22.32, lon: 114.17, tz: "Asia/Hong_Kong" },
|
|
1617
|
+
{ id: "macau", zh: "澳门", en: "Macau", lat: 22.2, lon: 113.54, tz: "Asia/Macau" },
|
|
1618
|
+
{ id: "taipei", zh: "台北", en: "Taipei", lat: 25.03, lon: 121.57, tz: "Asia/Taipei" },
|
|
1619
|
+
{ id: "tokyo", zh: "东京", en: "Tokyo", lat: 35.68, lon: 139.69, tz: "Asia/Tokyo" },
|
|
1620
|
+
{ id: "osaka", zh: "大阪", en: "Osaka", lat: 34.69, lon: 135.5, tz: "Asia/Tokyo" },
|
|
1621
|
+
{ id: "seoul", zh: "首尔", en: "Seoul", lat: 37.57, lon: 126.98, tz: "Asia/Seoul" },
|
|
1622
|
+
{ id: "singapore", zh: "新加坡", en: "Singapore", lat: 1.35, lon: 103.82, tz: "Asia/Singapore" },
|
|
1623
|
+
{ id: "bangkok", zh: "曼谷", en: "Bangkok", lat: 13.76, lon: 100.5, tz: "Asia/Bangkok" },
|
|
1624
|
+
{ id: "hochiminh", zh: "胡志明市", en: "Ho Chi Minh City", lat: 10.82, lon: 106.63, tz: "Asia/Ho_Chi_Minh" },
|
|
1625
|
+
{ id: "jakarta", zh: "雅加达", en: "Jakarta", lat: -6.21, lon: 106.85, tz: "Asia/Jakarta" },
|
|
1626
|
+
{ id: "kualalumpur", zh: "吉隆坡", en: "Kuala Lumpur", lat: 3.14, lon: 101.69, tz: "Asia/Kuala_Lumpur" },
|
|
1627
|
+
{ id: "manila", zh: "马尼拉", en: "Manila", lat: 14.6, lon: 120.98, tz: "Asia/Manila" },
|
|
1628
|
+
{ id: "mumbai", zh: "孟买", en: "Mumbai", lat: 19.08, lon: 72.88, tz: "Asia/Kolkata" },
|
|
1629
|
+
{ id: "delhi", zh: "新德里", en: "New Delhi", lat: 28.61, lon: 77.21, tz: "Asia/Kolkata" },
|
|
1630
|
+
{ id: "dubai", zh: "迪拜", en: "Dubai", lat: 25.2, lon: 55.27, tz: "Asia/Dubai" },
|
|
1631
|
+
{ id: "istanbul", zh: "伊斯坦布尔", en: "Istanbul", lat: 41.01, lon: 28.98, tz: "Europe/Istanbul" },
|
|
1632
|
+
{ id: "moscow", zh: "莫斯科", en: "Moscow", lat: 55.76, lon: 37.62, tz: "Europe/Moscow" },
|
|
1633
|
+
{ id: "athens", zh: "雅典", en: "Athens", lat: 37.98, lon: 23.73, tz: "Europe/Athens" },
|
|
1634
|
+
{ id: "rome", zh: "罗马", en: "Rome", lat: 41.9, lon: 12.5, tz: "Europe/Rome" },
|
|
1635
|
+
{ id: "berlin", zh: "柏林", en: "Berlin", lat: 52.52, lon: 13.4, tz: "Europe/Berlin" },
|
|
1636
|
+
{ id: "paris", zh: "巴黎", en: "Paris", lat: 48.86, lon: 2.35, tz: "Europe/Paris" },
|
|
1637
|
+
{ id: "madrid", zh: "马德里", en: "Madrid", lat: 40.42, lon: -3.7, tz: "Europe/Madrid" },
|
|
1638
|
+
{ id: "lisbon", zh: "里斯本", en: "Lisbon", lat: 38.72, lon: -9.14, tz: "Europe/Lisbon" },
|
|
1639
|
+
{ id: "amsterdam", zh: "阿姆斯特丹", en: "Amsterdam", lat: 52.37, lon: 4.9, tz: "Europe/Amsterdam" },
|
|
1640
|
+
{ id: "london", zh: "伦敦", en: "London", lat: 51.51, lon: -0.13, tz: "Europe/London" },
|
|
1641
|
+
{ id: "stockholm", zh: "斯德哥尔摩", en: "Stockholm", lat: 59.33, lon: 18.07, tz: "Europe/Stockholm" },
|
|
1642
|
+
{ id: "oslo", zh: "奥斯陆", en: "Oslo", lat: 59.91, lon: 10.75, tz: "Europe/Oslo" },
|
|
1643
|
+
{ id: "helsinki", zh: "赫尔辛基", en: "Helsinki", lat: 60.17, lon: 24.94, tz: "Europe/Helsinki" },
|
|
1644
|
+
{ id: "reykjavik", zh: "雷克雅未克", en: "Reykjavik", lat: 64.15, lon: -21.94, tz: "Atlantic/Reykjavik" },
|
|
1645
|
+
{ id: "tromso", zh: "特罗姆瑟", en: "Tromso", lat: 69.65, lon: 18.96, tz: "Europe/Oslo" },
|
|
1646
|
+
{ id: "longyearbyen", zh: "朗伊尔城", en: "Longyearbyen", lat: 78.22, lon: 15.65, tz: "Arctic/Longyearbyen" },
|
|
1647
|
+
{ id: "newyork", zh: "纽约", en: "New York", lat: 40.71, lon: -74.01, tz: "America/New_York" },
|
|
1648
|
+
{ id: "losangeles", zh: "洛杉矶", en: "Los Angeles", lat: 34.05, lon: -118.24, tz: "America/Los_Angeles" },
|
|
1649
|
+
{ id: "sanfrancisco", zh: "旧金山", en: "San Francisco", lat: 37.77, lon: -122.42, tz: "America/Los_Angeles" },
|
|
1650
|
+
{ id: "seattle", zh: "西雅图", en: "Seattle", lat: 47.61, lon: -122.33, tz: "America/Los_Angeles" },
|
|
1651
|
+
{ id: "chicago", zh: "芝加哥", en: "Chicago", lat: 41.88, lon: -87.63, tz: "America/Chicago" },
|
|
1652
|
+
{ id: "denver", zh: "丹佛", en: "Denver", lat: 39.74, lon: -104.99, tz: "America/Denver" },
|
|
1653
|
+
{ id: "anchorage", zh: "安克雷奇", en: "Anchorage", lat: 61.22, lon: -149.9, tz: "America/Anchorage" },
|
|
1654
|
+
{ id: "toronto", zh: "多伦多", en: "Toronto", lat: 43.65, lon: -79.38, tz: "America/Toronto" },
|
|
1655
|
+
{ id: "vancouver", zh: "温哥华", en: "Vancouver", lat: 49.28, lon: -123.12, tz: "America/Vancouver" },
|
|
1656
|
+
{ id: "mexicocity", zh: "墨西哥城", en: "Mexico City", lat: 19.43, lon: -99.13, tz: "America/Mexico_City" },
|
|
1657
|
+
{ id: "bogota", zh: "波哥大", en: "Bogota", lat: 4.71, lon: -74.07, tz: "America/Bogota" },
|
|
1658
|
+
{ id: "lima", zh: "利马", en: "Lima", lat: -12.05, lon: -77.04, tz: "America/Lima" },
|
|
1659
|
+
{ id: "saopaulo", zh: "圣保罗", en: "Sao Paulo", lat: -23.55, lon: -46.63, tz: "America/Sao_Paulo" },
|
|
1660
|
+
{ id: "buenosaires", zh: "布宜诺斯艾利斯", en: "Buenos Aires", lat: -34.6, lon: -58.38, tz: "America/Argentina/Buenos_Aires" },
|
|
1661
|
+
{ id: "santiago", zh: "圣地亚哥", en: "Santiago", lat: -33.45, lon: -70.67, tz: "America/Santiago" },
|
|
1662
|
+
{ id: "cairo", zh: "开罗", en: "Cairo", lat: 30.04, lon: 31.24, tz: "Africa/Cairo" },
|
|
1663
|
+
{ id: "nairobi", zh: "内罗毕", en: "Nairobi", lat: -1.29, lon: 36.82, tz: "Africa/Nairobi" },
|
|
1664
|
+
{ id: "lagos", zh: "拉各斯", en: "Lagos", lat: 6.52, lon: 3.38, tz: "Africa/Lagos" },
|
|
1665
|
+
{ id: "capetown", zh: "开普敦", en: "Cape Town", lat: -33.92, lon: 18.42, tz: "Africa/Johannesburg" },
|
|
1666
|
+
{ id: "sydney", zh: "悉尼", en: "Sydney", lat: -33.87, lon: 151.21, tz: "Australia/Sydney" },
|
|
1667
|
+
{ id: "melbourne", zh: "墨尔本", en: "Melbourne", lat: -37.81, lon: 144.96, tz: "Australia/Melbourne" },
|
|
1668
|
+
{ id: "brisbane", zh: "布里斯班", en: "Brisbane", lat: -27.47, lon: 153.03, tz: "Australia/Brisbane" },
|
|
1669
|
+
{ id: "perth", zh: "珀斯", en: "Perth", lat: -31.95, lon: 115.86, tz: "Australia/Perth" },
|
|
1670
|
+
{ id: "auckland", zh: "奥克兰", en: "Auckland", lat: -36.85, lon: 174.76, tz: "Pacific/Auckland" },
|
|
1671
|
+
{ id: "wellington", zh: "惠灵顿", en: "Wellington", lat: -41.29, lon: 174.78, tz: "Pacific/Auckland" },
|
|
1672
|
+
{ id: "honolulu", zh: "檀香山", en: "Honolulu", lat: 21.31, lon: -157.86, tz: "Pacific/Honolulu" }
|
|
1673
|
+
].map(Object.freeze)
|
|
1674
|
+
);
|
|
1675
|
+
var FALLBACK_CITY = CITIES[0];
|
|
1676
|
+
var REGION_CITY = Object.freeze({
|
|
1677
|
+
CN: "shanghai",
|
|
1678
|
+
TW: "taipei",
|
|
1679
|
+
HK: "hongkong",
|
|
1680
|
+
MO: "macau",
|
|
1681
|
+
JP: "tokyo",
|
|
1682
|
+
KR: "seoul",
|
|
1683
|
+
SG: "singapore",
|
|
1684
|
+
TH: "bangkok",
|
|
1685
|
+
VN: "hochiminh",
|
|
1686
|
+
ID: "jakarta",
|
|
1687
|
+
MY: "kualalumpur",
|
|
1688
|
+
PH: "manila",
|
|
1689
|
+
IN: "mumbai",
|
|
1690
|
+
AE: "dubai",
|
|
1691
|
+
TR: "istanbul",
|
|
1692
|
+
RU: "moscow",
|
|
1693
|
+
GR: "athens",
|
|
1694
|
+
IT: "rome",
|
|
1695
|
+
DE: "berlin",
|
|
1696
|
+
FR: "paris",
|
|
1697
|
+
ES: "madrid",
|
|
1698
|
+
PT: "lisbon",
|
|
1699
|
+
NL: "amsterdam",
|
|
1700
|
+
GB: "london",
|
|
1701
|
+
SE: "stockholm",
|
|
1702
|
+
NO: "oslo",
|
|
1703
|
+
FI: "helsinki",
|
|
1704
|
+
IS: "reykjavik",
|
|
1705
|
+
US: "newyork",
|
|
1706
|
+
CA: "toronto",
|
|
1707
|
+
MX: "mexicocity",
|
|
1708
|
+
BR: "saopaulo",
|
|
1709
|
+
AR: "buenosaires",
|
|
1710
|
+
CL: "santiago",
|
|
1711
|
+
CO: "bogota",
|
|
1712
|
+
PE: "lima",
|
|
1713
|
+
EG: "cairo",
|
|
1714
|
+
KE: "nairobi",
|
|
1715
|
+
NG: "lagos",
|
|
1716
|
+
ZA: "capetown",
|
|
1717
|
+
AU: "sydney",
|
|
1718
|
+
NZ: "auckland"
|
|
1719
|
+
});
|
|
1720
|
+
function findCity(id) {
|
|
1721
|
+
if (typeof id !== "string") return void 0;
|
|
1722
|
+
const key = id.trim().toLowerCase();
|
|
1723
|
+
if (!key) return void 0;
|
|
1724
|
+
return CITIES.find(
|
|
1725
|
+
(c) => c.id === key || c.zh === id.trim() || c.en.toLowerCase() === key
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// src/client/format.js
|
|
1730
|
+
function formatRemaining(ms) {
|
|
1731
|
+
const total = Math.max(0, Math.ceil(Number(ms) / 1e3) || 0);
|
|
1732
|
+
const hours = Math.floor(total / 3600);
|
|
1733
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
1734
|
+
const seconds = total % 60;
|
|
1735
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
1736
|
+
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
|
1737
|
+
}
|
|
1738
|
+
function formatClock(date, tz) {
|
|
1739
|
+
try {
|
|
1740
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
1741
|
+
hour: "2-digit",
|
|
1742
|
+
minute: "2-digit",
|
|
1743
|
+
hour12: false,
|
|
1744
|
+
timeZone: tz || void 0
|
|
1745
|
+
}).format(date);
|
|
1746
|
+
} catch {
|
|
1747
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
1748
|
+
const m = String(date.getMinutes()).padStart(2, "0");
|
|
1749
|
+
return `${h}:${m}`;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// src/client/overlay.jsx
|
|
1754
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1755
|
+
var Guarded = class extends import_react2.Component {
|
|
1756
|
+
constructor(props) {
|
|
1757
|
+
super(props);
|
|
1758
|
+
this.state = { failed: false };
|
|
1759
|
+
}
|
|
1760
|
+
static getDerivedStateFromError() {
|
|
1761
|
+
return { failed: true };
|
|
1762
|
+
}
|
|
1763
|
+
componentDidCatch(error) {
|
|
1764
|
+
console.warn("[dsh-intertidal] 覆盖层渲染失败:", error);
|
|
1765
|
+
}
|
|
1766
|
+
render() {
|
|
1767
|
+
if (!this.state.failed) return this.props.children;
|
|
1768
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "it-error", children: [
|
|
1769
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: this.props.label }),
|
|
1770
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "it-btn", onClick: () => this.setState({ failed: false }), children: this.props.closeLabel })
|
|
1771
|
+
] });
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
function isEditableTarget(target) {
|
|
1775
|
+
if (!target || typeof target !== "object") return false;
|
|
1776
|
+
const tag = String(target.tagName ?? "").toLowerCase();
|
|
1777
|
+
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
|
1778
|
+
return target.isContentEditable === true;
|
|
1779
|
+
}
|
|
1780
|
+
function placeName(location, lang) {
|
|
1781
|
+
if (!location) return "";
|
|
1782
|
+
const city = findCity?.(location.city);
|
|
1783
|
+
if (city) return lang === "en" ? city.en : city.zh;
|
|
1784
|
+
return location.city || "";
|
|
1785
|
+
}
|
|
1786
|
+
function useRuntime(runtime) {
|
|
1787
|
+
const [snapshot, setSnapshot] = (0, import_react2.useState)(() => runtime.getState());
|
|
1788
|
+
(0, import_react2.useEffect)(() => runtime.subscribe(setSnapshot), [runtime]);
|
|
1789
|
+
return snapshot;
|
|
1790
|
+
}
|
|
1791
|
+
function usePipDrag(runtime, form, enabled) {
|
|
1792
|
+
const ref = (0, import_react2.useRef)(null);
|
|
1793
|
+
const [dragging, setDragging] = (0, import_react2.useState)(false);
|
|
1794
|
+
const drag = (0, import_react2.useRef)(null);
|
|
1795
|
+
const onPointerDown = (0, import_react2.useCallback)((event) => {
|
|
1796
|
+
if (!enabled || form !== "pip") return;
|
|
1797
|
+
if (event.target?.closest?.(".it-btn")) return;
|
|
1798
|
+
const node = ref.current;
|
|
1799
|
+
if (!node) return;
|
|
1800
|
+
const rect = node.getBoundingClientRect();
|
|
1801
|
+
drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top, w: rect.width, h: rect.height };
|
|
1802
|
+
node.setPointerCapture?.(event.pointerId);
|
|
1803
|
+
setDragging(true);
|
|
1804
|
+
}, [enabled, form]);
|
|
1805
|
+
const onPointerMove = (0, import_react2.useCallback)((event) => {
|
|
1806
|
+
const info = drag.current;
|
|
1807
|
+
const node = ref.current;
|
|
1808
|
+
if (!info || !node) return;
|
|
1809
|
+
const left = Math.min(Math.max(0, event.clientX - info.dx), window.innerWidth - info.w);
|
|
1810
|
+
const top = Math.min(Math.max(0, event.clientY - info.dy), window.innerHeight - info.h);
|
|
1811
|
+
node.style.left = `${left}px`;
|
|
1812
|
+
node.style.top = `${top}px`;
|
|
1813
|
+
node.style.right = "auto";
|
|
1814
|
+
node.style.bottom = "auto";
|
|
1815
|
+
}, []);
|
|
1816
|
+
const onPointerUp = (0, import_react2.useCallback)((event) => {
|
|
1817
|
+
const node = ref.current;
|
|
1818
|
+
if (!drag.current || !node) return;
|
|
1819
|
+
drag.current = null;
|
|
1820
|
+
setDragging(false);
|
|
1821
|
+
node.releasePointerCapture?.(event.pointerId);
|
|
1822
|
+
const rect = node.getBoundingClientRect();
|
|
1823
|
+
const gap = 24;
|
|
1824
|
+
const centerX = rect.left + rect.width / 2;
|
|
1825
|
+
const centerY = rect.top + rect.height / 2;
|
|
1826
|
+
const left = centerX < window.innerWidth / 2 ? gap : window.innerWidth - rect.width - gap;
|
|
1827
|
+
const top = centerY < window.innerHeight / 2 ? gap : window.innerHeight - rect.height - gap;
|
|
1828
|
+
node.style.left = `${Math.max(gap, left)}px`;
|
|
1829
|
+
node.style.top = `${Math.max(gap, top)}px`;
|
|
1830
|
+
node.style.right = "auto";
|
|
1831
|
+
node.style.bottom = "auto";
|
|
1832
|
+
}, []);
|
|
1833
|
+
return { ref, dragging, onPointerDown, onPointerMove, onPointerUp };
|
|
1834
|
+
}
|
|
1835
|
+
function Overlay({ runtime, t = (k) => k, getLang = () => "zh", subscribeLang }) {
|
|
1836
|
+
const snapshot = useRuntime(runtime);
|
|
1837
|
+
const lang = typeof getLang === "function" ? getLang() : "zh";
|
|
1838
|
+
const [, bumpLang] = (0, import_react2.useState)(0);
|
|
1839
|
+
(0, import_react2.useEffect)(() => {
|
|
1840
|
+
if (typeof subscribeLang !== "function") return void 0;
|
|
1841
|
+
const unsub = subscribeLang(() => bumpLang((value) => value + 1));
|
|
1842
|
+
return () => unsub?.();
|
|
1843
|
+
}, [subscribeLang]);
|
|
1844
|
+
const { form, exiting, settings, sky, paused } = snapshot;
|
|
1845
|
+
const visible = form !== "hidden";
|
|
1846
|
+
const drag = usePipDrag(runtime, form, visible);
|
|
1847
|
+
(0, import_react2.useEffect)(() => {
|
|
1848
|
+
if (!visible) return void 0;
|
|
1849
|
+
const onKeyDown = (event) => {
|
|
1850
|
+
if (event.defaultPrevented) return;
|
|
1851
|
+
const key = event.key;
|
|
1852
|
+
const editable = isEditableTarget(event.target);
|
|
1853
|
+
if (key === "Escape") {
|
|
1854
|
+
event.preventDefault();
|
|
1855
|
+
runtime.hide("manual");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
if (editable) return;
|
|
1859
|
+
if (key === "c" || key === "C") {
|
|
1860
|
+
event.preventDefault();
|
|
1861
|
+
runtime.toggleForm();
|
|
1862
|
+
} else if (key === " " || key === "Spacebar") {
|
|
1863
|
+
event.preventDefault();
|
|
1864
|
+
runtime.togglePause();
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
1868
|
+
return () => window.removeEventListener("keydown", onKeyDown, true);
|
|
1869
|
+
}, [runtime, visible]);
|
|
1870
|
+
(0, import_react2.useEffect)(() => {
|
|
1871
|
+
const node = drag.ref.current;
|
|
1872
|
+
if (!node || form !== "full" || drag.dragging) return;
|
|
1873
|
+
node.style.left = "";
|
|
1874
|
+
node.style.top = "";
|
|
1875
|
+
node.style.right = "";
|
|
1876
|
+
node.style.bottom = "";
|
|
1877
|
+
}, [form, drag.dragging, drag.ref]);
|
|
1878
|
+
const caption = (0, import_react2.useMemo)(() => {
|
|
1879
|
+
if (!settings?.caption) return "";
|
|
1880
|
+
const when = formatClock(/* @__PURE__ */ new Date(), settings?.location?.tz);
|
|
1881
|
+
const where = placeName(settings?.location, lang);
|
|
1882
|
+
const phase = t(`phase.${sky?.phase ?? "afternoon"}`);
|
|
1883
|
+
return [t("panel.caption"), where, `${when} · ${phase}`].filter(Boolean).join(" · ");
|
|
1884
|
+
}, [settings?.caption, settings?.location?.city, settings?.location?.tz, sky?.phase, lang, t]);
|
|
1885
|
+
if (!visible) return null;
|
|
1886
|
+
const exitingAttr = exiting ? exiting.kind === "pip" || form === "pip" ? "pip" : "1" : "0";
|
|
1887
|
+
const dialogLabel = exiting ? "closing" : "open";
|
|
1888
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Guarded, { label: t("panel.crashed"), closeLabel: t("action.close"), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1889
|
+
"div",
|
|
1890
|
+
{
|
|
1891
|
+
ref: drag.ref,
|
|
1892
|
+
className: "it-root",
|
|
1893
|
+
"data-form": form,
|
|
1894
|
+
"data-exit": exitingAttr,
|
|
1895
|
+
"data-dragging": drag.dragging ? "1" : "0",
|
|
1896
|
+
role: "region",
|
|
1897
|
+
"aria-label": t("panel.title"),
|
|
1898
|
+
"data-state": dialogLabel,
|
|
1899
|
+
onPointerDown: drag.onPointerDown,
|
|
1900
|
+
onPointerMove: drag.onPointerMove,
|
|
1901
|
+
onPointerUp: drag.onPointerUp,
|
|
1902
|
+
onPointerCancel: drag.onPointerUp,
|
|
1903
|
+
children: [
|
|
1904
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "it-canvas-holder", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1905
|
+
SeaCanvas,
|
|
1906
|
+
{
|
|
1907
|
+
now: sky,
|
|
1908
|
+
media: snapshot.media,
|
|
1909
|
+
form,
|
|
1910
|
+
paused,
|
|
1911
|
+
reducedMotion: runtime.reducedMotion,
|
|
1912
|
+
onStats: (stats) => runtime.reportStats(stats)
|
|
1913
|
+
}
|
|
1914
|
+
) }),
|
|
1915
|
+
caption ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "it-caption", children: caption }) : null,
|
|
1916
|
+
snapshot.note ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "it-toast", children: t(snapshot.note) }) : null,
|
|
1917
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "it-controls", children: [
|
|
1918
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1919
|
+
"span",
|
|
1920
|
+
{
|
|
1921
|
+
className: "it-countdown",
|
|
1922
|
+
"data-paused": paused ? "1" : "0",
|
|
1923
|
+
title: paused ? t("panel.paused") : t("panel.remaining"),
|
|
1924
|
+
"aria-label": paused ? t("panel.paused") : t("panel.remaining"),
|
|
1925
|
+
children: formatRemaining(snapshot.remainingMs)
|
|
1926
|
+
}
|
|
1927
|
+
),
|
|
1928
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "it-btnbar", children: [
|
|
1929
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1930
|
+
"button",
|
|
1931
|
+
{
|
|
1932
|
+
type: "button",
|
|
1933
|
+
className: "it-btn",
|
|
1934
|
+
"data-icon": "1",
|
|
1935
|
+
title: paused ? t("action.resume") : t("action.pause"),
|
|
1936
|
+
"aria-label": paused ? t("action.resume") : t("action.pause"),
|
|
1937
|
+
onClick: () => runtime.togglePause(),
|
|
1938
|
+
children: paused ? "▶" : "❚❚"
|
|
1939
|
+
}
|
|
1940
|
+
),
|
|
1941
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1942
|
+
"button",
|
|
1943
|
+
{
|
|
1944
|
+
type: "button",
|
|
1945
|
+
className: "it-btn",
|
|
1946
|
+
"data-icon": "1",
|
|
1947
|
+
title: form === "full" ? t("action.pip") : t("action.expand"),
|
|
1948
|
+
"aria-label": form === "full" ? t("action.pip") : t("action.expand"),
|
|
1949
|
+
onClick: () => runtime.toggleForm(),
|
|
1950
|
+
children: form === "full" ? "⤡" : "⤢"
|
|
1951
|
+
}
|
|
1952
|
+
),
|
|
1953
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1954
|
+
"button",
|
|
1955
|
+
{
|
|
1956
|
+
type: "button",
|
|
1957
|
+
className: "it-btn",
|
|
1958
|
+
"data-icon": "1",
|
|
1959
|
+
title: t("action.close"),
|
|
1960
|
+
"aria-label": t("action.close"),
|
|
1961
|
+
onClick: () => runtime.hide("manual"),
|
|
1962
|
+
children: "✕"
|
|
1963
|
+
}
|
|
1964
|
+
)
|
|
1965
|
+
] })
|
|
1966
|
+
] })
|
|
1967
|
+
]
|
|
1968
|
+
}
|
|
1969
|
+
) });
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// src/client/settings-panel.jsx
|
|
1973
|
+
var import_react3 = require("react");
|
|
1974
|
+
|
|
1975
|
+
// src/settings.js
|
|
1976
|
+
var HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
1977
|
+
var DEFAULTS = Object.freeze({
|
|
1978
|
+
auto: true,
|
|
1979
|
+
intervalMinutes: 120,
|
|
1980
|
+
durationSeconds: 180,
|
|
1981
|
+
firstDelayMinutes: 20,
|
|
1982
|
+
quietHours: Object.freeze({ enabled: true, start: "23:00", end: "07:00" }),
|
|
1983
|
+
deferOnTyping: true,
|
|
1984
|
+
manualExtends: true,
|
|
1985
|
+
form: "full",
|
|
1986
|
+
rememberForm: true,
|
|
1987
|
+
caption: true,
|
|
1988
|
+
location: Object.freeze({
|
|
1989
|
+
city: "shanghai",
|
|
1990
|
+
lat: 31.23,
|
|
1991
|
+
lon: 121.47,
|
|
1992
|
+
tz: "Asia/Shanghai"
|
|
1993
|
+
}),
|
|
1994
|
+
sound: Object.freeze({ enabled: false, volume: 0.35 }),
|
|
1995
|
+
/** v2:真实素材(图片/视频)替代程序化海;'off' 时仍用程序化海。 */
|
|
1996
|
+
media: Object.freeze({
|
|
1997
|
+
mode: "off",
|
|
1998
|
+
dir: "",
|
|
1999
|
+
urls: Object.freeze([]),
|
|
2000
|
+
grade: 0.55,
|
|
2001
|
+
motion: 0.5,
|
|
2002
|
+
shuffle: true
|
|
2003
|
+
}),
|
|
2004
|
+
reducedMotion: "auto"
|
|
2005
|
+
});
|
|
2006
|
+
var LIMITS = Object.freeze({
|
|
2007
|
+
intervalMinutes: Object.freeze([15, 480]),
|
|
2008
|
+
durationSeconds: Object.freeze([30, 1800]),
|
|
2009
|
+
firstDelayMinutes: Object.freeze([0, 120]),
|
|
2010
|
+
volume: Object.freeze([0, 1]),
|
|
2011
|
+
grade: Object.freeze([0, 1]),
|
|
2012
|
+
motion: Object.freeze([0, 1])
|
|
2013
|
+
});
|
|
2014
|
+
var FORMS = Object.freeze(["full", "pip"]);
|
|
2015
|
+
var REDUCED_MOTION = Object.freeze(["auto", "on", "off"]);
|
|
2016
|
+
var MEDIA_MODES = Object.freeze(["off", "dir", "url"]);
|
|
2017
|
+
var MAX_MEDIA_URLS = 8;
|
|
2018
|
+
var MEDIA_URL_RE = /^https?:\/\/[^\s]+$/i;
|
|
2019
|
+
var ABS_PATH_RE = /^(\/|[A-Za-z]:[\\/])/;
|
|
2020
|
+
function clone(value) {
|
|
2021
|
+
if (Array.isArray(value)) return value.map(clone);
|
|
2022
|
+
if (value && typeof value === "object") {
|
|
2023
|
+
const out = {};
|
|
2024
|
+
for (const [k, v] of Object.entries(value)) out[k] = clone(v);
|
|
2025
|
+
return out;
|
|
2026
|
+
}
|
|
2027
|
+
return value;
|
|
2028
|
+
}
|
|
2029
|
+
var isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
2030
|
+
var field = (obj, key) => isPlainObject(obj) ? obj[key] : void 0;
|
|
2031
|
+
function boolOr(value, fallback) {
|
|
2032
|
+
return typeof value === "boolean" ? value : fallback;
|
|
2033
|
+
}
|
|
2034
|
+
function numberIn(value, lo, hi, fallback) {
|
|
2035
|
+
const n = typeof value === "number" ? value : Number.NaN;
|
|
2036
|
+
if (!Number.isFinite(n)) return fallback;
|
|
2037
|
+
return n < lo ? lo : n > hi ? hi : n;
|
|
2038
|
+
}
|
|
2039
|
+
function intIn(value, lo, hi, fallback) {
|
|
2040
|
+
const n = typeof value === "number" ? value : Number.NaN;
|
|
2041
|
+
if (!Number.isFinite(n)) return fallback;
|
|
2042
|
+
const rounded = Math.round(n);
|
|
2043
|
+
return rounded < lo ? lo : rounded > hi ? hi : rounded;
|
|
2044
|
+
}
|
|
2045
|
+
function timeOr(value, fallback) {
|
|
2046
|
+
if (typeof value !== "string") return fallback;
|
|
2047
|
+
return HHMM_RE.test(value.trim()) ? value.trim() : fallback;
|
|
2048
|
+
}
|
|
2049
|
+
function isValidTimeZone(tz) {
|
|
2050
|
+
if (typeof tz !== "string" || tz.trim() === "") return false;
|
|
2051
|
+
try {
|
|
2052
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz.trim() });
|
|
2053
|
+
return true;
|
|
2054
|
+
} catch {
|
|
2055
|
+
return false;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
function oneOf(value, allowed, fallback) {
|
|
2059
|
+
return typeof value === "string" && allowed.includes(value) ? value : fallback;
|
|
2060
|
+
}
|
|
2061
|
+
function locationDefaults(inputCity) {
|
|
2062
|
+
const known = findCity(inputCity) ?? findCity(DEFAULTS.location.city);
|
|
2063
|
+
return known ? { city: known.id, lat: known.lat, lon: known.lon, tz: known.tz } : clone(DEFAULTS.location);
|
|
2064
|
+
}
|
|
2065
|
+
function sanitizeSettings(input) {
|
|
2066
|
+
const src = isPlainObject(input) ? input : {};
|
|
2067
|
+
const rawLocation = field(src, "location");
|
|
2068
|
+
const locDefaults = locationDefaults(field(rawLocation, "city"));
|
|
2069
|
+
const rawCity = field(rawLocation, "city");
|
|
2070
|
+
const matchedCity = typeof rawCity === "string" ? findCity(rawCity) : void 0;
|
|
2071
|
+
const city = matchedCity?.id ?? (typeof rawCity === "string" && rawCity.trim() ? rawCity.trim() : DEFAULTS.location.city);
|
|
2072
|
+
const quiet = field(src, "quietHours");
|
|
2073
|
+
const sound = field(src, "sound");
|
|
2074
|
+
const media = field(src, "media");
|
|
2075
|
+
return {
|
|
2076
|
+
auto: boolOr(field(src, "auto"), DEFAULTS.auto),
|
|
2077
|
+
intervalMinutes: intIn(
|
|
2078
|
+
field(src, "intervalMinutes"),
|
|
2079
|
+
LIMITS.intervalMinutes[0],
|
|
2080
|
+
LIMITS.intervalMinutes[1],
|
|
2081
|
+
DEFAULTS.intervalMinutes
|
|
2082
|
+
),
|
|
2083
|
+
durationSeconds: intIn(
|
|
2084
|
+
field(src, "durationSeconds"),
|
|
2085
|
+
LIMITS.durationSeconds[0],
|
|
2086
|
+
LIMITS.durationSeconds[1],
|
|
2087
|
+
DEFAULTS.durationSeconds
|
|
2088
|
+
),
|
|
2089
|
+
firstDelayMinutes: intIn(
|
|
2090
|
+
field(src, "firstDelayMinutes"),
|
|
2091
|
+
LIMITS.firstDelayMinutes[0],
|
|
2092
|
+
LIMITS.firstDelayMinutes[1],
|
|
2093
|
+
DEFAULTS.firstDelayMinutes
|
|
2094
|
+
),
|
|
2095
|
+
quietHours: {
|
|
2096
|
+
enabled: boolOr(field(quiet, "enabled"), DEFAULTS.quietHours.enabled),
|
|
2097
|
+
start: timeOr(field(quiet, "start"), DEFAULTS.quietHours.start),
|
|
2098
|
+
end: timeOr(field(quiet, "end"), DEFAULTS.quietHours.end)
|
|
2099
|
+
},
|
|
2100
|
+
deferOnTyping: boolOr(field(src, "deferOnTyping"), DEFAULTS.deferOnTyping),
|
|
2101
|
+
manualExtends: boolOr(field(src, "manualExtends"), DEFAULTS.manualExtends),
|
|
2102
|
+
form: oneOf(field(src, "form"), FORMS, DEFAULTS.form),
|
|
2103
|
+
rememberForm: boolOr(field(src, "rememberForm"), DEFAULTS.rememberForm),
|
|
2104
|
+
caption: boolOr(field(src, "caption"), DEFAULTS.caption),
|
|
2105
|
+
location: {
|
|
2106
|
+
city,
|
|
2107
|
+
lat: numberIn(field(rawLocation, "lat"), -90, 90, locDefaults.lat),
|
|
2108
|
+
lon: numberIn(field(rawLocation, "lon"), -180, 180, locDefaults.lon),
|
|
2109
|
+
tz: isValidTimeZone(field(rawLocation, "tz")) ? field(rawLocation, "tz").trim() : locDefaults.tz
|
|
2110
|
+
},
|
|
2111
|
+
sound: {
|
|
2112
|
+
enabled: boolOr(field(sound, "enabled"), DEFAULTS.sound.enabled),
|
|
2113
|
+
volume: numberIn(
|
|
2114
|
+
field(sound, "volume"),
|
|
2115
|
+
LIMITS.volume[0],
|
|
2116
|
+
LIMITS.volume[1],
|
|
2117
|
+
DEFAULTS.sound.volume
|
|
2118
|
+
)
|
|
2119
|
+
},
|
|
2120
|
+
media: {
|
|
2121
|
+
mode: oneOf(field(media, "mode"), MEDIA_MODES, DEFAULTS.media.mode),
|
|
2122
|
+
// 目录必须是绝对路径;写相对路径就没法在宿主侧做围栏,直接退回空
|
|
2123
|
+
dir: absPathOr(field(media, "dir"), DEFAULTS.media.dir),
|
|
2124
|
+
urls: urlListOr(field(media, "urls"), DEFAULTS.media.urls),
|
|
2125
|
+
grade: numberIn(field(media, "grade"), LIMITS.grade[0], LIMITS.grade[1], DEFAULTS.media.grade),
|
|
2126
|
+
motion: numberIn(field(media, "motion"), LIMITS.motion[0], LIMITS.motion[1], DEFAULTS.media.motion),
|
|
2127
|
+
shuffle: boolOr(field(media, "shuffle"), DEFAULTS.media.shuffle)
|
|
2128
|
+
},
|
|
2129
|
+
reducedMotion: oneOf(field(src, "reducedMotion"), REDUCED_MOTION, DEFAULTS.reducedMotion)
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
function absPathOr(value, fallback) {
|
|
2133
|
+
if (typeof value !== "string") return fallback;
|
|
2134
|
+
const text = value.trim();
|
|
2135
|
+
if (!text) return fallback;
|
|
2136
|
+
return ABS_PATH_RE.test(text) ? text : fallback;
|
|
2137
|
+
}
|
|
2138
|
+
function urlListOr(value, fallback) {
|
|
2139
|
+
const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\n,]+/) : [];
|
|
2140
|
+
const out = [];
|
|
2141
|
+
for (const item of list) {
|
|
2142
|
+
if (typeof item !== "string") continue;
|
|
2143
|
+
const text = item.trim();
|
|
2144
|
+
if (!text || !MEDIA_URL_RE.test(text)) continue;
|
|
2145
|
+
if (out.includes(text)) continue;
|
|
2146
|
+
out.push(text);
|
|
2147
|
+
if (out.length >= MAX_MEDIA_URLS) break;
|
|
2148
|
+
}
|
|
2149
|
+
return out.length ? out : [...fallback];
|
|
2150
|
+
}
|
|
2151
|
+
function deepMerge(base, patch) {
|
|
2152
|
+
if (!isPlainObject(patch)) return patch === void 0 ? clone(base) : clone(patch);
|
|
2153
|
+
const out = isPlainObject(base) ? clone(base) : {};
|
|
2154
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2155
|
+
if (value === void 0) continue;
|
|
2156
|
+
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : clone(value);
|
|
2157
|
+
}
|
|
2158
|
+
return out;
|
|
2159
|
+
}
|
|
2160
|
+
function mergeSettings(base, patch) {
|
|
2161
|
+
const merged = deepMerge(deepMerge(DEFAULTS, base), patch);
|
|
2162
|
+
return sanitizeSettings(merged);
|
|
2163
|
+
}
|
|
2164
|
+
var RHYTHM_EVENTS = Object.freeze(["shown", "hidden", "skip-today", "manual-open"]);
|
|
2165
|
+
|
|
2166
|
+
// src/client/settings-panel.jsx
|
|
2167
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
2168
|
+
function Row({ label, hint, children, stack = false }) {
|
|
2169
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-row", "data-stack": stack ? "1" : "0", children: [
|
|
2170
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-row-label", children: [
|
|
2171
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: label }),
|
|
2172
|
+
hint ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("small", { children: hint }) : null
|
|
2173
|
+
] }),
|
|
2174
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "it-row-control", children })
|
|
2175
|
+
] });
|
|
2176
|
+
}
|
|
2177
|
+
function Switch({ checked, onChange, label }) {
|
|
2178
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2179
|
+
"button",
|
|
2180
|
+
{
|
|
2181
|
+
type: "button",
|
|
2182
|
+
className: "it-switch",
|
|
2183
|
+
role: "switch",
|
|
2184
|
+
"aria-checked": checked ? "true" : "false",
|
|
2185
|
+
"aria-label": label,
|
|
2186
|
+
onClick: () => onChange(!checked)
|
|
2187
|
+
}
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
var DURATIONS = [60, 180, 300, 600];
|
|
2191
|
+
var INTERVALS = [15, 30, 60, 90, 120, 180, 240, 360, 480];
|
|
2192
|
+
function SettingsPanel({ runtime, t = (k) => k, getLang = () => "zh", subscribeLang, pickDirectory }) {
|
|
2193
|
+
const [state, setState] = (0, import_react3.useState)(() => runtime.getState());
|
|
2194
|
+
const lang = typeof getLang === "function" ? getLang() : "zh";
|
|
2195
|
+
const [, bumpLang] = (0, import_react3.useState)(0);
|
|
2196
|
+
(0, import_react3.useEffect)(() => {
|
|
2197
|
+
if (typeof subscribeLang !== "function") return void 0;
|
|
2198
|
+
const unsub = subscribeLang(() => bumpLang((value) => value + 1));
|
|
2199
|
+
return () => unsub?.();
|
|
2200
|
+
}, [subscribeLang]);
|
|
2201
|
+
const [custom, setCustom] = (0, import_react3.useState)(false);
|
|
2202
|
+
const [scan, setScan] = (0, import_react3.useState)(null);
|
|
2203
|
+
const [fetchNote, setFetchNote] = (0, import_react3.useState)(null);
|
|
2204
|
+
(0, import_react3.useEffect)(() => runtime.subscribe(setState), [runtime]);
|
|
2205
|
+
const settings = state.settings ?? DEFAULTS;
|
|
2206
|
+
const save = (patch) => void runtime.saveSettings(patch);
|
|
2207
|
+
const cityName = (city) => lang === "en" ? city.en : city.zh;
|
|
2208
|
+
const nextText = (() => {
|
|
2209
|
+
if (!settings.auto) return t("settings.next.off");
|
|
2210
|
+
const due = state.nextDueMs;
|
|
2211
|
+
if (!due) return "—";
|
|
2212
|
+
const diff = due - Date.now();
|
|
2213
|
+
if (diff <= 0) return t("settings.next.soon");
|
|
2214
|
+
const mins = Math.round(diff / 6e4);
|
|
2215
|
+
return mins >= 60 ? `${Math.round(mins / 60)} ${lang === "en" ? "h" : "小时"}` : `${mins} ${t("unit.minutes")}`;
|
|
2216
|
+
})();
|
|
2217
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-settings", children: [
|
|
2218
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2219
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.rhythm") }),
|
|
2220
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.auto"), hint: t("settings.auto.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Switch, { checked: settings.auto, label: t("settings.auto"), onChange: (value) => save({ auto: value }) }) }),
|
|
2221
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.interval"), hint: t("settings.interval.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2222
|
+
"select",
|
|
2223
|
+
{
|
|
2224
|
+
value: settings.intervalMinutes,
|
|
2225
|
+
onChange: (event) => save({ intervalMinutes: Number(event.target.value) }),
|
|
2226
|
+
children: INTERVALS.map((minutes) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("option", { value: minutes, children: [
|
|
2227
|
+
minutes,
|
|
2228
|
+
" ",
|
|
2229
|
+
t("unit.minutes")
|
|
2230
|
+
] }, minutes))
|
|
2231
|
+
}
|
|
2232
|
+
) }),
|
|
2233
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.duration"), stack: true, children: [
|
|
2234
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
2235
|
+
"select",
|
|
2236
|
+
{
|
|
2237
|
+
value: DURATIONS.includes(settings.durationSeconds) ? settings.durationSeconds : "custom",
|
|
2238
|
+
onChange: (event) => {
|
|
2239
|
+
if (event.target.value === "custom") return;
|
|
2240
|
+
save({ durationSeconds: Number(event.target.value) });
|
|
2241
|
+
},
|
|
2242
|
+
children: [
|
|
2243
|
+
DURATIONS.map((seconds) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: seconds, children: seconds >= 60 ? `${seconds / 60} ${t("unit.minutes")}` : `${seconds} ${t("unit.seconds")}` }, seconds)),
|
|
2244
|
+
DURATIONS.includes(settings.durationSeconds) ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("option", { value: "custom", children: [
|
|
2245
|
+
settings.durationSeconds,
|
|
2246
|
+
" ",
|
|
2247
|
+
t("unit.seconds")
|
|
2248
|
+
] })
|
|
2249
|
+
]
|
|
2250
|
+
}
|
|
2251
|
+
),
|
|
2252
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2253
|
+
"input",
|
|
2254
|
+
{
|
|
2255
|
+
type: "number",
|
|
2256
|
+
min: 30,
|
|
2257
|
+
max: 1800,
|
|
2258
|
+
step: 10,
|
|
2259
|
+
className: "it-field-sm",
|
|
2260
|
+
value: settings.durationSeconds,
|
|
2261
|
+
onChange: (event) => save({ durationSeconds: Number(event.target.value) })
|
|
2262
|
+
}
|
|
2263
|
+
)
|
|
2264
|
+
] }),
|
|
2265
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.firstDelay"), hint: t("settings.firstDelay.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2266
|
+
"input",
|
|
2267
|
+
{
|
|
2268
|
+
type: "number",
|
|
2269
|
+
min: 0,
|
|
2270
|
+
max: 120,
|
|
2271
|
+
step: 5,
|
|
2272
|
+
className: "it-field-sm",
|
|
2273
|
+
value: settings.firstDelayMinutes,
|
|
2274
|
+
onChange: (event) => save({ firstDelayMinutes: Number(event.target.value) })
|
|
2275
|
+
}
|
|
2276
|
+
) }),
|
|
2277
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.quiet"), hint: t("settings.quiet.hint"), children: [
|
|
2278
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2279
|
+
Switch,
|
|
2280
|
+
{
|
|
2281
|
+
checked: settings.quietHours.enabled,
|
|
2282
|
+
label: t("settings.quiet"),
|
|
2283
|
+
onChange: (value) => save({ quietHours: { ...settings.quietHours, enabled: value } })
|
|
2284
|
+
}
|
|
2285
|
+
),
|
|
2286
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2287
|
+
"input",
|
|
2288
|
+
{
|
|
2289
|
+
type: "time",
|
|
2290
|
+
value: settings.quietHours.start,
|
|
2291
|
+
onChange: (event) => save({ quietHours: { ...settings.quietHours, start: event.target.value } })
|
|
2292
|
+
}
|
|
2293
|
+
),
|
|
2294
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: { opacity: 0.5 }, children: "–" }),
|
|
2295
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2296
|
+
"input",
|
|
2297
|
+
{
|
|
2298
|
+
type: "time",
|
|
2299
|
+
value: settings.quietHours.end,
|
|
2300
|
+
onChange: (event) => save({ quietHours: { ...settings.quietHours, end: event.target.value } })
|
|
2301
|
+
}
|
|
2302
|
+
)
|
|
2303
|
+
] }),
|
|
2304
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.deferTyping"), hint: t("settings.deferTyping.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2305
|
+
Switch,
|
|
2306
|
+
{
|
|
2307
|
+
checked: settings.deferOnTyping,
|
|
2308
|
+
label: t("settings.deferTyping"),
|
|
2309
|
+
onChange: (value) => save({ deferOnTyping: value })
|
|
2310
|
+
}
|
|
2311
|
+
) }),
|
|
2312
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.manualExtends"), hint: t("settings.manualExtends.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2313
|
+
Switch,
|
|
2314
|
+
{
|
|
2315
|
+
checked: settings.manualExtends,
|
|
2316
|
+
label: t("settings.manualExtends"),
|
|
2317
|
+
onChange: (value) => save({ manualExtends: value })
|
|
2318
|
+
}
|
|
2319
|
+
) }),
|
|
2320
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.next"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "it-hint", children: nextText }) })
|
|
2321
|
+
] }),
|
|
2322
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2323
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.form") }),
|
|
2324
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.form"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("select", { value: settings.form, onChange: (event) => save({ form: event.target.value }), children: [
|
|
2325
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "full", children: t("settings.form.full") }),
|
|
2326
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "pip", children: t("settings.form.pip") })
|
|
2327
|
+
] }) }),
|
|
2328
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.rememberForm"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2329
|
+
Switch,
|
|
2330
|
+
{
|
|
2331
|
+
checked: settings.rememberForm,
|
|
2332
|
+
label: t("settings.rememberForm"),
|
|
2333
|
+
onChange: (value) => save({ rememberForm: value })
|
|
2334
|
+
}
|
|
2335
|
+
) }),
|
|
2336
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.caption"), hint: t("settings.caption.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Switch, { checked: settings.caption, label: t("settings.caption"), onChange: (value) => save({ caption: value }) }) })
|
|
2337
|
+
] }),
|
|
2338
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2339
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.media") }),
|
|
2340
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.media.mode"), hint: t("settings.media.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
2341
|
+
"select",
|
|
2342
|
+
{
|
|
2343
|
+
value: settings.media.mode,
|
|
2344
|
+
onChange: (event) => save({ media: { ...settings.media, mode: event.target.value } }),
|
|
2345
|
+
children: [
|
|
2346
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "off", children: t("settings.media.off") }),
|
|
2347
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "dir", children: t("settings.media.dir") }),
|
|
2348
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "url", children: t("settings.media.url") })
|
|
2349
|
+
]
|
|
2350
|
+
}
|
|
2351
|
+
) }),
|
|
2352
|
+
settings.media.mode === "dir" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2353
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.media.dir"), hint: t("settings.media.dir.hint"), stack: true, children: [
|
|
2354
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2355
|
+
"input",
|
|
2356
|
+
{
|
|
2357
|
+
type: "text",
|
|
2358
|
+
className: "it-field",
|
|
2359
|
+
placeholder: "/Users/you/Pictures/sea",
|
|
2360
|
+
value: settings.media.dir,
|
|
2361
|
+
onChange: (event) => save({ media: { ...settings.media, dir: event.target.value } })
|
|
2362
|
+
}
|
|
2363
|
+
),
|
|
2364
|
+
typeof pickDirectory === "function" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2365
|
+
"button",
|
|
2366
|
+
{
|
|
2367
|
+
type: "button",
|
|
2368
|
+
className: "it-action",
|
|
2369
|
+
onClick: async () => {
|
|
2370
|
+
try {
|
|
2371
|
+
const picked = await pickDirectory();
|
|
2372
|
+
if (typeof picked === "string" && picked) {
|
|
2373
|
+
save({ media: { ...settings.media, dir: picked } });
|
|
2374
|
+
const res = await runtime.scanMedia(picked);
|
|
2375
|
+
setScan(res?.ok ? res.value : { ok: false });
|
|
2376
|
+
}
|
|
2377
|
+
} catch {
|
|
2378
|
+
setScan({ ok: false });
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
children: t("settings.media.pick")
|
|
2382
|
+
}
|
|
2383
|
+
) : null,
|
|
2384
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2385
|
+
"button",
|
|
2386
|
+
{
|
|
2387
|
+
type: "button",
|
|
2388
|
+
className: "it-action",
|
|
2389
|
+
onClick: async () => {
|
|
2390
|
+
const res = await runtime.scanMedia(settings.media.dir);
|
|
2391
|
+
setScan(res?.ok ? res.value : { ok: false });
|
|
2392
|
+
},
|
|
2393
|
+
children: t("settings.media.scan")
|
|
2394
|
+
}
|
|
2395
|
+
)
|
|
2396
|
+
] }),
|
|
2397
|
+
scan ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "it-hint it-row-note", style: { paddingBottom: 6 }, children: scan.ok ? scan.count > 0 ? `${t("settings.media.scanOk")}:${scan.count}(${scan.images} / ${scan.videos})` : t("settings.media.scanEmpty") : t("settings.media.scanFail") }) : null,
|
|
2398
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.media.shuffle"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2399
|
+
Switch,
|
|
2400
|
+
{
|
|
2401
|
+
checked: settings.media.shuffle,
|
|
2402
|
+
label: t("settings.media.shuffle"),
|
|
2403
|
+
onChange: (value) => save({ media: { ...settings.media, shuffle: value } })
|
|
2404
|
+
}
|
|
2405
|
+
) })
|
|
2406
|
+
] }) : null,
|
|
2407
|
+
settings.media.mode === "url" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2408
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.media.urls"), hint: t("settings.media.urls.hint"), stack: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2409
|
+
"textarea",
|
|
2410
|
+
{
|
|
2411
|
+
rows: 4,
|
|
2412
|
+
className: "it-field",
|
|
2413
|
+
value: settings.media.urls.join("\n"),
|
|
2414
|
+
onChange: (event) => save({ media: { ...settings.media, urls: event.target.value.split("\n") } })
|
|
2415
|
+
}
|
|
2416
|
+
) }),
|
|
2417
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.media.fetch"), children: [
|
|
2418
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2419
|
+
"button",
|
|
2420
|
+
{
|
|
2421
|
+
type: "button",
|
|
2422
|
+
className: "it-action",
|
|
2423
|
+
onClick: async () => {
|
|
2424
|
+
setFetchNote(null);
|
|
2425
|
+
const urls = settings.media.urls.slice(0, 8);
|
|
2426
|
+
let ok = 0;
|
|
2427
|
+
for (const url of urls) {
|
|
2428
|
+
const res = await runtime.resolveMedia(url);
|
|
2429
|
+
if (res?.ok && res.value?.item) ok += 1;
|
|
2430
|
+
}
|
|
2431
|
+
setFetchNote(ok > 0 ? t("settings.media.fetchOk") : t("settings.media.fetchFail"));
|
|
2432
|
+
},
|
|
2433
|
+
children: t("settings.media.fetch")
|
|
2434
|
+
}
|
|
2435
|
+
),
|
|
2436
|
+
fetchNote ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "it-hint", children: fetchNote }) : null
|
|
2437
|
+
] })
|
|
2438
|
+
] }) : null,
|
|
2439
|
+
settings.media.mode !== "off" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2440
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.media.grade"), hint: t("settings.media.grade.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2441
|
+
"input",
|
|
2442
|
+
{
|
|
2443
|
+
type: "range",
|
|
2444
|
+
min: 0,
|
|
2445
|
+
max: 1,
|
|
2446
|
+
step: 0.05,
|
|
2447
|
+
value: settings.media.grade,
|
|
2448
|
+
"aria-label": t("settings.media.grade"),
|
|
2449
|
+
onChange: (event) => save({ media: { ...settings.media, grade: Number(event.target.value) } })
|
|
2450
|
+
}
|
|
2451
|
+
) }),
|
|
2452
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.media.motion"), hint: t("settings.media.motion.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2453
|
+
"input",
|
|
2454
|
+
{
|
|
2455
|
+
type: "range",
|
|
2456
|
+
min: 0,
|
|
2457
|
+
max: 1,
|
|
2458
|
+
step: 0.05,
|
|
2459
|
+
value: settings.media.motion,
|
|
2460
|
+
"aria-label": t("settings.media.motion"),
|
|
2461
|
+
onChange: (event) => save({ media: { ...settings.media, motion: Number(event.target.value) } })
|
|
2462
|
+
}
|
|
2463
|
+
) })
|
|
2464
|
+
] }) : null
|
|
2465
|
+
] }),
|
|
2466
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2467
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.place") }),
|
|
2468
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.city"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
2469
|
+
"select",
|
|
2470
|
+
{
|
|
2471
|
+
value: custom ? "__custom__" : settings.location.city,
|
|
2472
|
+
onChange: (event) => {
|
|
2473
|
+
if (event.target.value === "__custom__") {
|
|
2474
|
+
setCustom(true);
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
setCustom(false);
|
|
2478
|
+
const city = CITIES.find((item) => item.id === event.target.value);
|
|
2479
|
+
if (!city) return;
|
|
2480
|
+
save({ location: { city: city.id, lat: city.lat, lon: city.lon, tz: city.tz } });
|
|
2481
|
+
},
|
|
2482
|
+
children: [
|
|
2483
|
+
CITIES.map((city) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: city.id, children: cityName(city) }, city.id)),
|
|
2484
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "__custom__", children: t("settings.city.custom") })
|
|
2485
|
+
]
|
|
2486
|
+
}
|
|
2487
|
+
) }),
|
|
2488
|
+
custom ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.city.custom"), stack: true, children: [
|
|
2489
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2490
|
+
"input",
|
|
2491
|
+
{
|
|
2492
|
+
type: "number",
|
|
2493
|
+
step: "0.01",
|
|
2494
|
+
className: "it-field-sm",
|
|
2495
|
+
value: settings.location.lat,
|
|
2496
|
+
"aria-label": t("settings.lat"),
|
|
2497
|
+
onChange: (event) => save({ location: { ...settings.location, lat: Number(event.target.value) } })
|
|
2498
|
+
}
|
|
2499
|
+
),
|
|
2500
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2501
|
+
"input",
|
|
2502
|
+
{
|
|
2503
|
+
type: "number",
|
|
2504
|
+
step: "0.01",
|
|
2505
|
+
className: "it-field-sm",
|
|
2506
|
+
value: settings.location.lon,
|
|
2507
|
+
"aria-label": t("settings.lon"),
|
|
2508
|
+
onChange: (event) => save({ location: { ...settings.location, lon: Number(event.target.value) } })
|
|
2509
|
+
}
|
|
2510
|
+
),
|
|
2511
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2512
|
+
"input",
|
|
2513
|
+
{
|
|
2514
|
+
type: "text",
|
|
2515
|
+
className: "it-field",
|
|
2516
|
+
value: settings.location.tz,
|
|
2517
|
+
"aria-label": t("settings.tz"),
|
|
2518
|
+
onChange: (event) => save({ location: { ...settings.location, tz: event.target.value } })
|
|
2519
|
+
}
|
|
2520
|
+
)
|
|
2521
|
+
] }) : null
|
|
2522
|
+
] }),
|
|
2523
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2524
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.sound") }),
|
|
2525
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Row, { label: t("settings.sound"), hint: t("settings.sound.hint"), children: [
|
|
2526
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2527
|
+
Switch,
|
|
2528
|
+
{
|
|
2529
|
+
checked: settings.sound.enabled,
|
|
2530
|
+
label: t("settings.sound"),
|
|
2531
|
+
onChange: (value) => {
|
|
2532
|
+
if (value) runtime.unlockSound();
|
|
2533
|
+
save({ sound: { ...settings.sound, enabled: value } });
|
|
2534
|
+
if (!value) runtime.getState().sound?.disable?.();
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
),
|
|
2538
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2539
|
+
"input",
|
|
2540
|
+
{
|
|
2541
|
+
type: "range",
|
|
2542
|
+
min: 0,
|
|
2543
|
+
max: 1,
|
|
2544
|
+
step: 0.01,
|
|
2545
|
+
value: settings.sound.volume,
|
|
2546
|
+
"aria-label": t("settings.volume"),
|
|
2547
|
+
onChange: (event) => save({ sound: { ...settings.sound, volume: Number(event.target.value) } })
|
|
2548
|
+
}
|
|
2549
|
+
)
|
|
2550
|
+
] })
|
|
2551
|
+
] }),
|
|
2552
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2553
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h4", { children: t("settings.group.access") }),
|
|
2554
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Row, { label: t("settings.reducedMotion"), hint: t("settings.reducedMotion.hint"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
2555
|
+
"select",
|
|
2556
|
+
{
|
|
2557
|
+
value: settings.reducedMotion,
|
|
2558
|
+
onChange: (event) => save({ reducedMotion: event.target.value }),
|
|
2559
|
+
children: [
|
|
2560
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "auto", children: t("settings.reducedMotion.auto") }),
|
|
2561
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "on", children: t("settings.reducedMotion.on") }),
|
|
2562
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: "off", children: t("settings.reducedMotion.off") })
|
|
2563
|
+
]
|
|
2564
|
+
}
|
|
2565
|
+
) })
|
|
2566
|
+
] }),
|
|
2567
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-group", children: [
|
|
2568
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-actions", children: [
|
|
2569
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: "it-action", "data-primary": "1", onClick: () => runtime.show("manual"), children: t("action.preview") }),
|
|
2570
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: "it-action", onClick: () => void runtime.skipToday(), children: t("action.skip") })
|
|
2571
|
+
] }),
|
|
2572
|
+
state.offline ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "it-warn", children: t("hint.offline") }) : null,
|
|
2573
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "it-hint", children: [
|
|
2574
|
+
t("hint.keys"),
|
|
2575
|
+
" · ",
|
|
2576
|
+
t("settings.stateFile"),
|
|
2577
|
+
": ~/.dsh/intertidal/state.json"
|
|
2578
|
+
] })
|
|
2579
|
+
] })
|
|
2580
|
+
] });
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// src/client/api.js
|
|
2584
|
+
var BASE2 = "/intertidal";
|
|
2585
|
+
var MIRROR_KEY = "dsh-intertidal:settings";
|
|
2586
|
+
var TIMEOUT_MS = 4e3;
|
|
2587
|
+
function readMirror(storage = safeStorage()) {
|
|
2588
|
+
try {
|
|
2589
|
+
const raw = storage?.getItem(MIRROR_KEY);
|
|
2590
|
+
if (!raw) return null;
|
|
2591
|
+
const parsed = JSON.parse(raw);
|
|
2592
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
2593
|
+
} catch {
|
|
2594
|
+
return null;
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
function writeMirror(settings, storage = safeStorage()) {
|
|
2598
|
+
try {
|
|
2599
|
+
storage?.setItem(MIRROR_KEY, JSON.stringify(settings));
|
|
2600
|
+
} catch {
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
function safeStorage() {
|
|
2604
|
+
try {
|
|
2605
|
+
return globalThis.localStorage ?? null;
|
|
2606
|
+
} catch {
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
async function call(path, { method = "GET", body, signal } = {}, fetchImpl) {
|
|
2611
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
2612
|
+
if (typeof doFetch !== "function") return { ok: false, status: 0, error: "no-fetch" };
|
|
2613
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
2614
|
+
const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null;
|
|
2615
|
+
try {
|
|
2616
|
+
const init = { method, cache: "no-store", credentials: "same-origin", signal: controller?.signal ?? signal };
|
|
2617
|
+
if (body !== void 0) {
|
|
2618
|
+
init.headers = { "content-type": "application/json" };
|
|
2619
|
+
init.body = JSON.stringify(body);
|
|
2620
|
+
}
|
|
2621
|
+
const res = await doFetch(`${BASE2}${path}`, init);
|
|
2622
|
+
if (!res.ok) return { ok: false, status: res.status, error: `http-${res.status}` };
|
|
2623
|
+
const value = await res.json();
|
|
2624
|
+
return { ok: true, value };
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
return { ok: false, status: 0, error: String(error?.name === "AbortError" ? "timeout" : error?.message ?? error) };
|
|
2627
|
+
} finally {
|
|
2628
|
+
if (timer) clearTimeout(timer);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
function createApi({ fetchImpl, storage = safeStorage(), onStatus } = {}) {
|
|
2632
|
+
let offline = false;
|
|
2633
|
+
const note = (ok) => {
|
|
2634
|
+
if (offline === !ok) return;
|
|
2635
|
+
offline = !ok;
|
|
2636
|
+
try {
|
|
2637
|
+
onStatus?.(offline);
|
|
2638
|
+
} catch {
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
return {
|
|
2642
|
+
get offline() {
|
|
2643
|
+
return offline;
|
|
2644
|
+
},
|
|
2645
|
+
/** GET /state → `{ settings, rhythm, now }`;失败时退镜像 + null。 */
|
|
2646
|
+
async state() {
|
|
2647
|
+
const result = await call("/state", {}, fetchImpl);
|
|
2648
|
+
note(result.ok);
|
|
2649
|
+
if (result.ok) {
|
|
2650
|
+
writeMirror(result.value?.settings, storage);
|
|
2651
|
+
return result;
|
|
2652
|
+
}
|
|
2653
|
+
const mirror = readMirror(storage);
|
|
2654
|
+
return { ok: false, error: result.error, value: mirror ? { settings: mirror, rhythm: null, now: null } : null };
|
|
2655
|
+
},
|
|
2656
|
+
/** GET /now;带地点参数就按参数算,不带就用宿主设置。 */
|
|
2657
|
+
async now(location) {
|
|
2658
|
+
const query = location && Number.isFinite(location.lat) && Number.isFinite(location.lon) ? `?lat=${encodeURIComponent(location.lat)}&lon=${encodeURIComponent(location.lon)}&tz=${encodeURIComponent(location.tz ?? "")}` : "";
|
|
2659
|
+
const result = await call(`/now${query}`, {}, fetchImpl);
|
|
2660
|
+
note(result.ok);
|
|
2661
|
+
return result;
|
|
2662
|
+
},
|
|
2663
|
+
/** POST /settings:整份覆盖式提交(宿主做合并与钳制)。 */
|
|
2664
|
+
async saveSettings(patch) {
|
|
2665
|
+
const result = await call("/settings", { method: "POST", body: patch }, fetchImpl);
|
|
2666
|
+
note(result.ok);
|
|
2667
|
+
if (result.ok) writeMirror(result.value, storage);
|
|
2668
|
+
else {
|
|
2669
|
+
const mirror = readMirror(storage) ?? {};
|
|
2670
|
+
writeMirror({ ...mirror, ...patch }, storage);
|
|
2671
|
+
}
|
|
2672
|
+
return result;
|
|
2673
|
+
},
|
|
2674
|
+
/** POST /rhythm:`shown` / `hidden` / `skip-today` / `manual-open`。 */
|
|
2675
|
+
async rhythm(event) {
|
|
2676
|
+
const result = await call("/rhythm", { method: "POST", body: { event } }, fetchImpl);
|
|
2677
|
+
note(result.ok);
|
|
2678
|
+
return result;
|
|
2679
|
+
},
|
|
2680
|
+
/** GET /media:这一次浮现用哪张素材(`fresh` 强制重新抽一张)。 */
|
|
2681
|
+
async media(fresh = false) {
|
|
2682
|
+
const result = await call(`/media${fresh ? "?fresh=1" : ""}`, {}, fetchImpl);
|
|
2683
|
+
return result;
|
|
2684
|
+
},
|
|
2685
|
+
/** POST /media/scan:设置页验证目录里有多少素材。 */
|
|
2686
|
+
async scanMedia(dir) {
|
|
2687
|
+
return call("/media/scan", { method: "POST", body: { dir } }, fetchImpl);
|
|
2688
|
+
},
|
|
2689
|
+
/** POST /media/resolve:把一个网页/图片链接抓成本地缓存。 */
|
|
2690
|
+
async resolveMedia(url) {
|
|
2691
|
+
return call("/media/resolve", { method: "POST", body: { url } }, fetchImpl);
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
// src/client/rhythm.js
|
|
2697
|
+
var SUPPRESS = Object.freeze({
|
|
2698
|
+
autoOff: "auto-off",
|
|
2699
|
+
quiet: "quiet",
|
|
2700
|
+
typing: "typing",
|
|
2701
|
+
dialog: "dialog",
|
|
2702
|
+
skippedToday: "skipped-today",
|
|
2703
|
+
reducedMotion: "reduced-motion"
|
|
2704
|
+
});
|
|
2705
|
+
var FADE = { manual: 600, timeout: 3e3, pip: 1500 };
|
|
2706
|
+
var INPUT_IDLE_MS = 2e4;
|
|
2707
|
+
var INPUT_MAX_WAIT_MS = 10 * 6e4;
|
|
2708
|
+
var TICK_MS = 1e3;
|
|
2709
|
+
function parseClock(text) {
|
|
2710
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? "").trim());
|
|
2711
|
+
if (!m) return null;
|
|
2712
|
+
const h = Number(m[1]);
|
|
2713
|
+
const min = Number(m[2]);
|
|
2714
|
+
if (h > 23 || min > 59) return null;
|
|
2715
|
+
return h * 60 + min;
|
|
2716
|
+
}
|
|
2717
|
+
function isQuietTime(quiet, date) {
|
|
2718
|
+
if (!quiet || quiet.enabled !== true) return false;
|
|
2719
|
+
const start = parseClock(quiet.start);
|
|
2720
|
+
const end = parseClock(quiet.end);
|
|
2721
|
+
if (start === null || end === null) return false;
|
|
2722
|
+
if (start === end) return false;
|
|
2723
|
+
const nowMin = date.getHours() * 60 + date.getMinutes();
|
|
2724
|
+
return start < end ? nowMin >= start && nowMin < end : nowMin >= start || nowMin < end;
|
|
2725
|
+
}
|
|
2726
|
+
function localDateKey(date) {
|
|
2727
|
+
const y = date.getFullYear();
|
|
2728
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
2729
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
2730
|
+
return `${y}-${m}-${d}`;
|
|
2731
|
+
}
|
|
2732
|
+
function skippedToday(rhythm, date) {
|
|
2733
|
+
const list = Array.isArray(rhythm?.skipDates) ? rhythm.skipDates : [];
|
|
2734
|
+
return list.includes(localDateKey(date));
|
|
2735
|
+
}
|
|
2736
|
+
function nextDueAt(settings, rhythm, nowMs = Date.now()) {
|
|
2737
|
+
const intervalMs = Math.max(1, Number(settings?.intervalMinutes) || DEFAULTS.intervalMinutes) * 6e4;
|
|
2738
|
+
const firstDelayMs = Math.max(0, Number(settings?.firstDelayMinutes ?? DEFAULTS.firstDelayMinutes)) * 6e4;
|
|
2739
|
+
const hide = Number(rhythm?.lastHideAt) || 0;
|
|
2740
|
+
const shown = Number(rhythm?.lastShownAt) || 0;
|
|
2741
|
+
const anchorSec = settings?.manualExtends === false ? hide : Math.max(hide, shown);
|
|
2742
|
+
if (!anchorSec) {
|
|
2743
|
+
const installed = Number(rhythm?.installedAt) || 0;
|
|
2744
|
+
return (installed ? installed * 1e3 : nowMs) + firstDelayMs;
|
|
2745
|
+
}
|
|
2746
|
+
return anchorSec * 1e3 + intervalMs;
|
|
2747
|
+
}
|
|
2748
|
+
function inputSuppression(lastInputAt, nowMs, dueAt = nowMs, { idleMs = INPUT_IDLE_MS, maxWaitMs = INPUT_MAX_WAIT_MS } = {}) {
|
|
2749
|
+
if (!lastInputAt) return { waiting: false, giveUp: false };
|
|
2750
|
+
if (nowMs - lastInputAt >= idleMs) return { waiting: false, giveUp: false };
|
|
2751
|
+
return { waiting: true, giveUp: nowMs - dueAt > maxWaitMs };
|
|
2752
|
+
}
|
|
2753
|
+
function reducedMotionActive(setting, systemReduce) {
|
|
2754
|
+
if (setting === "on") return true;
|
|
2755
|
+
if (setting === "off") return false;
|
|
2756
|
+
return Boolean(systemReduce);
|
|
2757
|
+
}
|
|
2758
|
+
var PHASE_TEXT = { dawn: "dawn", noon: "noon", afternoon: "afternoon", dusk: "dusk", night: "night" };
|
|
2759
|
+
function createRuntime({ api, storage = globalThis.localStorage, doc = globalThis.document, win = globalThis.window, now = () => Date.now() } = {}) {
|
|
2760
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2761
|
+
let timer = null;
|
|
2762
|
+
let disposed = false;
|
|
2763
|
+
let lastInputAt = 0;
|
|
2764
|
+
let soundUnlocked = false;
|
|
2765
|
+
const state = {
|
|
2766
|
+
ready: false,
|
|
2767
|
+
offline: false,
|
|
2768
|
+
settings: null,
|
|
2769
|
+
rhythm: null,
|
|
2770
|
+
sky: null,
|
|
2771
|
+
media: null,
|
|
2772
|
+
form: "hidden",
|
|
2773
|
+
exiting: null,
|
|
2774
|
+
paused: false,
|
|
2775
|
+
pausedRemaining: 0,
|
|
2776
|
+
deadline: 0,
|
|
2777
|
+
remainingMs: 0,
|
|
2778
|
+
nextDueMs: null,
|
|
2779
|
+
suppression: null,
|
|
2780
|
+
stats: null,
|
|
2781
|
+
note: null
|
|
2782
|
+
};
|
|
2783
|
+
const snapshot = () => ({
|
|
2784
|
+
...state,
|
|
2785
|
+
settings: state.settings ? { ...state.settings } : null,
|
|
2786
|
+
rhythm: state.rhythm ? { ...state.rhythm } : null
|
|
2787
|
+
});
|
|
2788
|
+
function emit() {
|
|
2789
|
+
const value = snapshot();
|
|
2790
|
+
for (const fn of listeners) {
|
|
2791
|
+
try {
|
|
2792
|
+
fn(value);
|
|
2793
|
+
} catch {
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
function subscribe(fn) {
|
|
2798
|
+
listeners.add(fn);
|
|
2799
|
+
try {
|
|
2800
|
+
fn(snapshot());
|
|
2801
|
+
} catch {
|
|
2802
|
+
}
|
|
2803
|
+
return () => listeners.delete(fn);
|
|
2804
|
+
}
|
|
2805
|
+
function note(text) {
|
|
2806
|
+
state.note = text;
|
|
2807
|
+
emit();
|
|
2808
|
+
setTimeout(() => {
|
|
2809
|
+
if (state.note !== text) return;
|
|
2810
|
+
state.note = null;
|
|
2811
|
+
emit();
|
|
2812
|
+
}, 4e3);
|
|
2813
|
+
}
|
|
2814
|
+
const reducedMotion = () => reducedMotionActive(state.settings?.reducedMotion, systemReduceMotion());
|
|
2815
|
+
function systemReduceMotion() {
|
|
2816
|
+
try {
|
|
2817
|
+
return Boolean(win?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches);
|
|
2818
|
+
} catch {
|
|
2819
|
+
return false;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
function markInput() {
|
|
2823
|
+
if (state.form !== "hidden") return;
|
|
2824
|
+
lastInputAt = now();
|
|
2825
|
+
}
|
|
2826
|
+
const inputEvents = ["keydown", "pointerdown", "mousemove", "wheel", "touchstart"];
|
|
2827
|
+
function bindInput() {
|
|
2828
|
+
for (const name2 of inputEvents) win?.addEventListener?.(name2, markInput, { passive: true });
|
|
2829
|
+
}
|
|
2830
|
+
function unbindInput() {
|
|
2831
|
+
for (const name2 of inputEvents) win?.removeEventListener?.(name2, markInput);
|
|
2832
|
+
}
|
|
2833
|
+
function hasBlockingDialog() {
|
|
2834
|
+
try {
|
|
2835
|
+
const nodes = doc?.querySelectorAll?.('[role="dialog"], [role="alertdialog"]') ?? [];
|
|
2836
|
+
for (const node of nodes) {
|
|
2837
|
+
if (node?.offsetParent !== null || node?.getClientRects?.().length) return true;
|
|
2838
|
+
}
|
|
2839
|
+
} catch {
|
|
2840
|
+
}
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2843
|
+
function autoSuppression(at = now()) {
|
|
2844
|
+
if (!state.settings?.auto) return SUPPRESS.autoOff;
|
|
2845
|
+
if (reducedMotion()) return SUPPRESS.reducedMotion;
|
|
2846
|
+
if (isQuietTime(state.settings.quietHours, new Date(at))) return SUPPRESS.quiet;
|
|
2847
|
+
if (skippedToday(state.rhythm, new Date(at))) return SUPPRESS.skippedToday;
|
|
2848
|
+
if (state.settings.deferOnTyping && inputSuppression(lastInputAt, at, state.nextDueMs ?? at).waiting) return SUPPRESS.typing;
|
|
2849
|
+
if (hasBlockingDialog()) return SUPPRESS.dialog;
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
function computeNextDue() {
|
|
2853
|
+
state.nextDueMs = nextDueAt(state.settings, state.rhythm, now());
|
|
2854
|
+
return state.nextDueMs;
|
|
2855
|
+
}
|
|
2856
|
+
async function show(kind) {
|
|
2857
|
+
if (state.form !== "hidden" || state.exiting) return;
|
|
2858
|
+
const settings = state.settings ?? DEFAULTS;
|
|
2859
|
+
const form = settings.form === "pip" ? "pip" : "full";
|
|
2860
|
+
const durationMs = Math.max(1, Number(settings.durationSeconds) || DEFAULTS.durationSeconds) * 1e3;
|
|
2861
|
+
state.form = form;
|
|
2862
|
+
state.deadline = now() + durationMs;
|
|
2863
|
+
state.remainingMs = durationMs;
|
|
2864
|
+
state.paused = false;
|
|
2865
|
+
state.pausedRemaining = 0;
|
|
2866
|
+
state.exiting = null;
|
|
2867
|
+
state.suppression = kind === "auto" ? state.suppression : null;
|
|
2868
|
+
if (settings.sound?.enabled && soundUnlocked) state.sound?.enable?.(settings.sound.volume);
|
|
2869
|
+
emit();
|
|
2870
|
+
void pickMedia(true);
|
|
2871
|
+
void api?.rhythm?.(kind === "auto" ? "shown" : "manual-open")?.then?.((res) => {
|
|
2872
|
+
if (res?.ok && res.value) {
|
|
2873
|
+
state.rhythm = { ...res.value };
|
|
2874
|
+
emit();
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
refreshSky();
|
|
2878
|
+
}
|
|
2879
|
+
function hide(kind = "manual") {
|
|
2880
|
+
if (state.form === "hidden" || state.exiting) return;
|
|
2881
|
+
const fade = FADE[kind] ?? FADE.manual;
|
|
2882
|
+
state.exiting = { kind, until: now() + fade };
|
|
2883
|
+
emit();
|
|
2884
|
+
setTimeout(() => finalizeHide(), fade);
|
|
2885
|
+
}
|
|
2886
|
+
function finalizeHide() {
|
|
2887
|
+
if (state.form === "hidden") return;
|
|
2888
|
+
state.form = "hidden";
|
|
2889
|
+
state.exiting = null;
|
|
2890
|
+
state.deadline = 0;
|
|
2891
|
+
state.remainingMs = 0;
|
|
2892
|
+
state.paused = false;
|
|
2893
|
+
state.pausedRemaining = 0;
|
|
2894
|
+
state.sound?.disable?.();
|
|
2895
|
+
emit();
|
|
2896
|
+
void api?.rhythm?.("hidden")?.then?.((res) => {
|
|
2897
|
+
if (res?.ok && res.value) {
|
|
2898
|
+
state.rhythm = { ...res.value };
|
|
2899
|
+
computeNextDue();
|
|
2900
|
+
emit();
|
|
2901
|
+
}
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
function toggleForm() {
|
|
2905
|
+
if (state.form === "hidden") return;
|
|
2906
|
+
state.form = state.form === "full" ? "pip" : "full";
|
|
2907
|
+
if (state.settings?.rememberForm) void saveSettings({ form: state.form });
|
|
2908
|
+
emit();
|
|
2909
|
+
}
|
|
2910
|
+
function toPip() {
|
|
2911
|
+
if (state.form !== "full") return;
|
|
2912
|
+
toggleForm();
|
|
2913
|
+
}
|
|
2914
|
+
function togglePause() {
|
|
2915
|
+
if (state.form === "hidden" || state.exiting) return;
|
|
2916
|
+
if (state.paused) {
|
|
2917
|
+
state.paused = false;
|
|
2918
|
+
state.deadline = now() + state.pausedRemaining;
|
|
2919
|
+
state.pausedRemaining = 0;
|
|
2920
|
+
} else {
|
|
2921
|
+
state.paused = true;
|
|
2922
|
+
state.pausedRemaining = Math.max(0, state.deadline - now());
|
|
2923
|
+
state.deadline = 0;
|
|
2924
|
+
}
|
|
2925
|
+
emit();
|
|
2926
|
+
}
|
|
2927
|
+
async function skipToday() {
|
|
2928
|
+
const res = await api?.rhythm?.("skip-today");
|
|
2929
|
+
if (res?.ok && res.value) {
|
|
2930
|
+
state.rhythm = { ...res.value };
|
|
2931
|
+
} else {
|
|
2932
|
+
const list = Array.isArray(state.rhythm?.skipDates) ? [...state.rhythm.skipDates] : [];
|
|
2933
|
+
const key = localDateKey(/* @__PURE__ */ new Date());
|
|
2934
|
+
if (!list.includes(key)) list.push(key);
|
|
2935
|
+
state.rhythm = { ...state.rhythm ?? {}, skipDates: list };
|
|
2936
|
+
}
|
|
2937
|
+
if (state.form !== "hidden") hide("manual");
|
|
2938
|
+
computeNextDue();
|
|
2939
|
+
emit();
|
|
2940
|
+
return state.rhythm;
|
|
2941
|
+
}
|
|
2942
|
+
async function saveSettings(patch) {
|
|
2943
|
+
const merged = mergeSettings(state.settings ?? DEFAULTS, patch);
|
|
2944
|
+
state.settings = merged;
|
|
2945
|
+
emit();
|
|
2946
|
+
if (patch?.sound && state.sound?.setVolume) state.sound.setVolume(merged.sound.volume);
|
|
2947
|
+
const res = await api?.saveSettings?.(patch);
|
|
2948
|
+
if (res?.ok && res.value) {
|
|
2949
|
+
state.settings = mergeSettings(res.value, {});
|
|
2950
|
+
state.offline = false;
|
|
2951
|
+
} else if (res) {
|
|
2952
|
+
state.offline = true;
|
|
2953
|
+
note("hint.offline");
|
|
2954
|
+
}
|
|
2955
|
+
computeNextDue();
|
|
2956
|
+
if (patch?.media) void pickMedia(true);
|
|
2957
|
+
emit();
|
|
2958
|
+
return state.settings;
|
|
2959
|
+
}
|
|
2960
|
+
async function pickMedia(fresh = false) {
|
|
2961
|
+
const media = state.settings?.media;
|
|
2962
|
+
if (!media || media.mode === "off") {
|
|
2963
|
+
if (state.media) {
|
|
2964
|
+
state.media = null;
|
|
2965
|
+
emit();
|
|
2966
|
+
}
|
|
2967
|
+
return null;
|
|
2968
|
+
}
|
|
2969
|
+
let res = null;
|
|
2970
|
+
try {
|
|
2971
|
+
res = await api?.media?.(fresh);
|
|
2972
|
+
} catch {
|
|
2973
|
+
res = null;
|
|
2974
|
+
}
|
|
2975
|
+
const item = res?.ok ? res.value?.item ?? null : null;
|
|
2976
|
+
state.media = item;
|
|
2977
|
+
emit();
|
|
2978
|
+
return item;
|
|
2979
|
+
}
|
|
2980
|
+
async function refreshSky() {
|
|
2981
|
+
const location = state.settings?.location;
|
|
2982
|
+
const res = await api?.now?.(location);
|
|
2983
|
+
if (res?.ok && res.value) state.sky = res.value;
|
|
2984
|
+
else if (!state.sky) state.sky = null;
|
|
2985
|
+
emit();
|
|
2986
|
+
}
|
|
2987
|
+
function tick() {
|
|
2988
|
+
if (disposed) return;
|
|
2989
|
+
const at = now();
|
|
2990
|
+
if (state.form !== "hidden") {
|
|
2991
|
+
if (!state.exiting) {
|
|
2992
|
+
if (state.paused) {
|
|
2993
|
+
state.remainingMs = state.pausedRemaining;
|
|
2994
|
+
emit();
|
|
2995
|
+
} else {
|
|
2996
|
+
state.remainingMs = Math.max(0, state.deadline - at);
|
|
2997
|
+
if (state.remainingMs <= 0) {
|
|
2998
|
+
hide(state.form === "pip" ? "pip" : "timeout");
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
emit();
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
computeNextDue();
|
|
3007
|
+
const due = state.nextDueMs !== null && at >= state.nextDueMs;
|
|
3008
|
+
if (!due) {
|
|
3009
|
+
emit();
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const reason = autoSuppression(at);
|
|
3013
|
+
state.suppression = reason;
|
|
3014
|
+
if (!reason) {
|
|
3015
|
+
void show("auto");
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
if (reason === SUPPRESS.typing && state.settings?.deferOnTyping) {
|
|
3019
|
+
const input = inputSuppression(lastInputAt, at, state.nextDueMs ?? at);
|
|
3020
|
+
if (input.giveUp) {
|
|
3021
|
+
lastInputAt = 0;
|
|
3022
|
+
void api?.rhythm?.("hidden")?.then?.((res) => {
|
|
3023
|
+
if (res?.ok && res.value) {
|
|
3024
|
+
state.rhythm = { ...res.value };
|
|
3025
|
+
computeNextDue();
|
|
3026
|
+
emit();
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
emit();
|
|
3032
|
+
}
|
|
3033
|
+
async function init() {
|
|
3034
|
+
if (state.ready || disposed) return;
|
|
3035
|
+
let res = null;
|
|
3036
|
+
try {
|
|
3037
|
+
res = await api?.state?.();
|
|
3038
|
+
} catch {
|
|
3039
|
+
res = null;
|
|
3040
|
+
}
|
|
3041
|
+
if (res?.ok && res.value) {
|
|
3042
|
+
state.settings = mergeSettings(res.value.settings, {});
|
|
3043
|
+
state.rhythm = res.value.rhythm ?? null;
|
|
3044
|
+
state.sky = res.value.now ?? null;
|
|
3045
|
+
state.offline = false;
|
|
3046
|
+
} else {
|
|
3047
|
+
state.settings = mergeSettings(res?.value?.settings ?? null, {});
|
|
3048
|
+
state.rhythm = res?.value?.rhythm ?? null;
|
|
3049
|
+
state.sky = res?.value?.now ?? null;
|
|
3050
|
+
state.offline = true;
|
|
3051
|
+
}
|
|
3052
|
+
if (disposed) return;
|
|
3053
|
+
state.ready = true;
|
|
3054
|
+
computeNextDue();
|
|
3055
|
+
bindInput();
|
|
3056
|
+
timer = setInterval(tick, TICK_MS);
|
|
3057
|
+
timer?.unref?.();
|
|
3058
|
+
emit();
|
|
3059
|
+
}
|
|
3060
|
+
function dispose() {
|
|
3061
|
+
disposed = true;
|
|
3062
|
+
if (timer) clearInterval(timer);
|
|
3063
|
+
timer = null;
|
|
3064
|
+
unbindInput();
|
|
3065
|
+
listeners.clear();
|
|
3066
|
+
state.sound?.dispose?.();
|
|
3067
|
+
state.form = "hidden";
|
|
3068
|
+
}
|
|
3069
|
+
function reportStats(stats) {
|
|
3070
|
+
state.stats = stats;
|
|
3071
|
+
emit();
|
|
3072
|
+
}
|
|
3073
|
+
function attachSound(sound) {
|
|
3074
|
+
state.sound = sound;
|
|
3075
|
+
return () => {
|
|
3076
|
+
sound?.dispose?.();
|
|
3077
|
+
if (state.sound === sound) state.sound = null;
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
function unlockSound() {
|
|
3081
|
+
soundUnlocked = true;
|
|
3082
|
+
return state.sound?.enable?.(state.settings?.sound?.volume ?? DEFAULTS.sound.volume) ?? false;
|
|
3083
|
+
}
|
|
3084
|
+
return {
|
|
3085
|
+
getState: snapshot,
|
|
3086
|
+
subscribe,
|
|
3087
|
+
init,
|
|
3088
|
+
dispose,
|
|
3089
|
+
show,
|
|
3090
|
+
hide,
|
|
3091
|
+
toggleForm,
|
|
3092
|
+
toPip,
|
|
3093
|
+
togglePause,
|
|
3094
|
+
skipToday,
|
|
3095
|
+
saveSettings,
|
|
3096
|
+
refreshSky,
|
|
3097
|
+
tick,
|
|
3098
|
+
attachSound,
|
|
3099
|
+
pickMedia,
|
|
3100
|
+
/** 设置页"检查目录"用:交给宿主扫一遍,返回 {ok,count,images,videos,names}。 */
|
|
3101
|
+
async scanMedia(dir) {
|
|
3102
|
+
try {
|
|
3103
|
+
return await api?.scanMedia?.(dir) ?? { ok: false };
|
|
3104
|
+
} catch {
|
|
3105
|
+
return { ok: false };
|
|
3106
|
+
}
|
|
3107
|
+
},
|
|
3108
|
+
/** 设置页"抓取并缓存"用。 */
|
|
3109
|
+
async resolveMedia(url) {
|
|
3110
|
+
try {
|
|
3111
|
+
return await api?.resolveMedia?.(url) ?? { ok: false };
|
|
3112
|
+
} catch {
|
|
3113
|
+
return { ok: false };
|
|
3114
|
+
}
|
|
3115
|
+
},
|
|
3116
|
+
reportStats,
|
|
3117
|
+
unlockSound,
|
|
3118
|
+
phaseText: (phase) => PHASE_TEXT[phase] ?? "afternoon",
|
|
3119
|
+
get reducedMotion() {
|
|
3120
|
+
return reducedMotion();
|
|
3121
|
+
},
|
|
3122
|
+
get soundUnlocked() {
|
|
3123
|
+
return soundUnlocked;
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/client/sound.js
|
|
3129
|
+
var FADE_IN = 0.15;
|
|
3130
|
+
var FADE_OUT = 0.4;
|
|
3131
|
+
function createSurfSound() {
|
|
3132
|
+
let ctx = null;
|
|
3133
|
+
let source = null;
|
|
3134
|
+
let gain = null;
|
|
3135
|
+
let lfo = null;
|
|
3136
|
+
let lfoGain = null;
|
|
3137
|
+
let filter = null;
|
|
3138
|
+
let enabled = false;
|
|
3139
|
+
let volume = 0.35;
|
|
3140
|
+
function ensureContext() {
|
|
3141
|
+
if (ctx) return ctx;
|
|
3142
|
+
const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
|
3143
|
+
if (!Ctor) return null;
|
|
3144
|
+
try {
|
|
3145
|
+
ctx = new Ctor();
|
|
3146
|
+
return ctx;
|
|
3147
|
+
} catch {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
function noiseBuffer(audio) {
|
|
3152
|
+
const length = Math.floor(audio.sampleRate * 2);
|
|
3153
|
+
const buffer = audio.createBuffer(1, length, audio.sampleRate);
|
|
3154
|
+
const data = buffer.getChannelData(0);
|
|
3155
|
+
let last = 0;
|
|
3156
|
+
for (let i = 0; i < length; i += 1) {
|
|
3157
|
+
const white = Math.random() * 2 - 1;
|
|
3158
|
+
last = last * 0.92 + white * 0.08;
|
|
3159
|
+
data[i] = last * 3.2;
|
|
3160
|
+
}
|
|
3161
|
+
return buffer;
|
|
3162
|
+
}
|
|
3163
|
+
function enable(nextVolume = volume) {
|
|
3164
|
+
const audio = ensureContext();
|
|
3165
|
+
if (!audio) return false;
|
|
3166
|
+
try {
|
|
3167
|
+
volume = clampVolume(nextVolume);
|
|
3168
|
+
if (audio.state === "suspended") void audio.resume();
|
|
3169
|
+
if (!source) {
|
|
3170
|
+
source = audio.createBufferSource();
|
|
3171
|
+
source.buffer = noiseBuffer(audio);
|
|
3172
|
+
source.loop = true;
|
|
3173
|
+
filter = audio.createBiquadFilter();
|
|
3174
|
+
filter.type = "bandpass";
|
|
3175
|
+
filter.frequency.value = 700;
|
|
3176
|
+
filter.Q.value = 0.6;
|
|
3177
|
+
gain = audio.createGain();
|
|
3178
|
+
gain.gain.value = 0;
|
|
3179
|
+
lfo = audio.createOscillator();
|
|
3180
|
+
lfo.type = "sine";
|
|
3181
|
+
lfo.frequency.value = 0.08;
|
|
3182
|
+
lfoGain = audio.createGain();
|
|
3183
|
+
lfoGain.gain.value = 400;
|
|
3184
|
+
lfo.connect(lfoGain);
|
|
3185
|
+
lfoGain.connect(filter.frequency);
|
|
3186
|
+
lfo.start();
|
|
3187
|
+
source.connect(filter);
|
|
3188
|
+
filter.connect(gain);
|
|
3189
|
+
gain.connect(audio.destination);
|
|
3190
|
+
source.start();
|
|
3191
|
+
}
|
|
3192
|
+
const now = audio.currentTime;
|
|
3193
|
+
gain.gain.cancelScheduledValues(now);
|
|
3194
|
+
gain.gain.setValueAtTime(gain.gain.value, now);
|
|
3195
|
+
gain.gain.linearRampToValueAtTime(volume, now + FADE_IN);
|
|
3196
|
+
enabled = true;
|
|
3197
|
+
return true;
|
|
3198
|
+
} catch {
|
|
3199
|
+
return false;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
function disable() {
|
|
3203
|
+
enabled = false;
|
|
3204
|
+
const audio = ctx;
|
|
3205
|
+
if (!audio || !gain) return;
|
|
3206
|
+
try {
|
|
3207
|
+
const now = audio.currentTime;
|
|
3208
|
+
gain.gain.cancelScheduledValues(now);
|
|
3209
|
+
gain.gain.setValueAtTime(gain.gain.value, now);
|
|
3210
|
+
gain.gain.linearRampToValueAtTime(0, now + FADE_OUT);
|
|
3211
|
+
} catch {
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
function setVolume(next) {
|
|
3215
|
+
volume = clampVolume(next);
|
|
3216
|
+
if (!ctx || !gain || !enabled) return;
|
|
3217
|
+
try {
|
|
3218
|
+
const now = ctx.currentTime;
|
|
3219
|
+
gain.gain.cancelScheduledValues(now);
|
|
3220
|
+
gain.gain.linearRampToValueAtTime(volume, now + FADE_IN);
|
|
3221
|
+
} catch {
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
function dispose() {
|
|
3225
|
+
disable();
|
|
3226
|
+
const audio = ctx;
|
|
3227
|
+
const nodes = { source, lfo };
|
|
3228
|
+
ctx = null;
|
|
3229
|
+
source = null;
|
|
3230
|
+
lfo = null;
|
|
3231
|
+
filter = null;
|
|
3232
|
+
lfoGain = null;
|
|
3233
|
+
gain = null;
|
|
3234
|
+
if (!audio) return;
|
|
3235
|
+
setTimeout(() => {
|
|
3236
|
+
try {
|
|
3237
|
+
nodes.source?.stop();
|
|
3238
|
+
} catch {
|
|
3239
|
+
}
|
|
3240
|
+
try {
|
|
3241
|
+
nodes.lfo?.stop();
|
|
3242
|
+
} catch {
|
|
3243
|
+
}
|
|
3244
|
+
void audio.close?.().catch?.(() => {
|
|
3245
|
+
});
|
|
3246
|
+
}, 500);
|
|
3247
|
+
}
|
|
3248
|
+
return {
|
|
3249
|
+
enable,
|
|
3250
|
+
disable,
|
|
3251
|
+
setVolume,
|
|
3252
|
+
dispose,
|
|
3253
|
+
get enabled() {
|
|
3254
|
+
return enabled;
|
|
3255
|
+
}
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
function clampVolume(value) {
|
|
3259
|
+
const n = Number(value);
|
|
3260
|
+
if (!Number.isFinite(n)) return 0.35;
|
|
3261
|
+
return Math.min(1, Math.max(0, n));
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// src/client/styles.js
|
|
3265
|
+
var STYLE = `
|
|
3266
|
+
:root { --intertidal-z: 900; }
|
|
3267
|
+
|
|
3268
|
+
.it-root {
|
|
3269
|
+
position: fixed;
|
|
3270
|
+
z-index: var(--intertidal-z);
|
|
3271
|
+
background: #0a1420;
|
|
3272
|
+
color: #f5f7fa;
|
|
3273
|
+
transition: opacity 800ms ease, transform 400ms cubic-bezier(.2,.7,.3,1), border-radius 400ms ease;
|
|
3274
|
+
will-change: opacity, transform;
|
|
3275
|
+
overflow: hidden;
|
|
3276
|
+
font: 400 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", system-ui, sans-serif;
|
|
3277
|
+
}
|
|
3278
|
+
.it-root[data-form="full"] {
|
|
3279
|
+
inset: 0;
|
|
3280
|
+
border-radius: 0;
|
|
3281
|
+
opacity: 1;
|
|
3282
|
+
}
|
|
3283
|
+
.it-root[data-form="pip"] {
|
|
3284
|
+
right: 24px; bottom: 24px; left: auto; top: auto;
|
|
3285
|
+
width: min(38vw, 640px);
|
|
3286
|
+
aspect-ratio: 16 / 9;
|
|
3287
|
+
border-radius: 14px;
|
|
3288
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, .38), 0 1px 0 rgba(255, 255, 255, .08) inset;
|
|
3289
|
+
transform: none;
|
|
3290
|
+
opacity: 1;
|
|
3291
|
+
cursor: grab;
|
|
3292
|
+
}
|
|
3293
|
+
.it-root[data-dragging="1"] { cursor: grabbing; }
|
|
3294
|
+
.it-root[data-exit="1"] { opacity: 0; }
|
|
3295
|
+
.it-root[data-exit="pip"] { transform: translateY(8px) scale(.985); }
|
|
3296
|
+
|
|
3297
|
+
.it-canvas-holder { position: absolute; inset: 0; }
|
|
3298
|
+
.it-canvas-holder canvas { display: block; width: 100%; height: 100%; }
|
|
3299
|
+
.it-fallback {
|
|
3300
|
+
position: absolute; inset: 0;
|
|
3301
|
+
background: linear-gradient(180deg, #7ea7c9 0%, #bcd3e2 52%, #2a5d80 52.2%, #10334d 100%);
|
|
3302
|
+
animation: it-breathe 16s ease-in-out infinite alternate;
|
|
3303
|
+
}
|
|
3304
|
+
@keyframes it-breathe { from { filter: brightness(.96) } to { filter: brightness(1.04) } }
|
|
3305
|
+
|
|
3306
|
+
.it-caption {
|
|
3307
|
+
position: absolute; left: 22px; bottom: 20px;
|
|
3308
|
+
font-size: 11px; letter-spacing: .04em;
|
|
3309
|
+
color: #fff; opacity: .45;
|
|
3310
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
|
|
3311
|
+
pointer-events: none;
|
|
3312
|
+
user-select: none;
|
|
3313
|
+
}
|
|
3314
|
+
.it-root[data-form="pip"] .it-caption { left: 12px; bottom: 10px; font-size: 10px; }
|
|
3315
|
+
|
|
3316
|
+
.it-controls {
|
|
3317
|
+
position: absolute; top: 14px; right: 14px;
|
|
3318
|
+
display: flex; align-items: center; gap: 8px;
|
|
3319
|
+
}
|
|
3320
|
+
.it-root[data-form="pip"] .it-controls { top: 8px; right: 8px; }
|
|
3321
|
+
|
|
3322
|
+
/* 按钮组:悬停/聚焦才出现,别在沉浸时碍眼 */
|
|
3323
|
+
.it-btnbar {
|
|
3324
|
+
display: flex; gap: 6px;
|
|
3325
|
+
opacity: 0; transition: opacity 220ms ease;
|
|
3326
|
+
}
|
|
3327
|
+
.it-root:hover .it-btnbar, .it-root:focus-within .it-btnbar { opacity: 1; }
|
|
3328
|
+
|
|
3329
|
+
/* 倒计时:一直可见,等宽数字,不抢画面 */
|
|
3330
|
+
.it-countdown {
|
|
3331
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
3332
|
+
height: 30px; min-width: 52px; padding: 0 11px;
|
|
3333
|
+
font-size: 12px; line-height: 1;
|
|
3334
|
+
font-variant-numeric: tabular-nums;
|
|
3335
|
+
letter-spacing: .02em;
|
|
3336
|
+
border-radius: 15px;
|
|
3337
|
+
color: rgba(255, 255, 255, .82);
|
|
3338
|
+
background: rgba(10, 18, 28, .32);
|
|
3339
|
+
border: 1px solid rgba(255, 255, 255, .14);
|
|
3340
|
+
backdrop-filter: blur(8px);
|
|
3341
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
|
|
3342
|
+
user-select: none; pointer-events: none;
|
|
3343
|
+
transition: opacity 400ms ease;
|
|
3344
|
+
}
|
|
3345
|
+
.it-countdown[data-paused="1"] { opacity: .5; }
|
|
3346
|
+
.it-root[data-form="pip"] .it-countdown { height: 24px; min-width: 44px; padding: 0 8px; font-size: 11px; border-radius: 12px; }
|
|
3347
|
+
|
|
3348
|
+
.it-btn {
|
|
3349
|
+
-webkit-appearance: none; appearance: none;
|
|
3350
|
+
height: 30px; min-width: 30px; padding: 0 10px;
|
|
3351
|
+
border-radius: 15px;
|
|
3352
|
+
border: 1px solid rgba(255, 255, 255, .22);
|
|
3353
|
+
background: rgba(12, 22, 34, .38);
|
|
3354
|
+
color: rgba(255, 255, 255, .86);
|
|
3355
|
+
font: inherit; font-size: 12px; line-height: 1;
|
|
3356
|
+
cursor: pointer;
|
|
3357
|
+
backdrop-filter: blur(8px);
|
|
3358
|
+
transition: background 160ms ease, border-color 160ms ease;
|
|
3359
|
+
}
|
|
3360
|
+
.it-btn:hover { background: rgba(12, 22, 34, .62); border-color: rgba(255, 255, 255, .4); }
|
|
3361
|
+
.it-btn:focus-visible { outline: 2px solid rgba(255, 255, 255, .8); outline-offset: 2px; }
|
|
3362
|
+
.it-btn[data-icon="1"] { width: 30px; padding: 0; font-size: 14px; }
|
|
3363
|
+
|
|
3364
|
+
.it-toast {
|
|
3365
|
+
position: absolute; left: 50%; bottom: 26px; transform: translateX(-50%);
|
|
3366
|
+
padding: 6px 12px; border-radius: 12px;
|
|
3367
|
+
background: rgba(10, 18, 28, .62);
|
|
3368
|
+
font-size: 12px; color: rgba(255, 255, 255, .9);
|
|
3369
|
+
pointer-events: none;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
.it-error {
|
|
3373
|
+
position: absolute; inset: 0;
|
|
3374
|
+
display: flex; align-items: center; justify-content: center;
|
|
3375
|
+
gap: 12px;
|
|
3376
|
+
background: linear-gradient(180deg, #12202e, #0a1420);
|
|
3377
|
+
font-size: 13px; color: rgba(255, 255, 255, .78);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/* —— 设置页 ——
|
|
3381
|
+
布局要点:DSH 的设置面板宽度不定(可能只有 500px),所以每一行都必须能换行:
|
|
3382
|
+
标签列给一个 220px 的 flex-basis(不够就整体折到下一行),控件列自己也能折行。
|
|
3383
|
+
早先标签列没有 flex-basis、控件是 flex:none,结果标签被挤成"一行一个词"。 */
|
|
3384
|
+
.it-settings { display: flex; flex-direction: column; gap: 26px; max-width: 100%; }
|
|
3385
|
+
.it-group { display: flex; flex-direction: column; gap: 4px; }
|
|
3386
|
+
.it-group > h4 {
|
|
3387
|
+
margin: 0 0 6px; font-size: 12px; font-weight: 600; letter-spacing: .06em;
|
|
3388
|
+
text-transform: uppercase; opacity: .55;
|
|
3389
|
+
}
|
|
3390
|
+
.it-row {
|
|
3391
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
3392
|
+
gap: 8px 18px; flex-wrap: wrap;
|
|
3393
|
+
padding: 9px 0; border-bottom: 1px solid rgba(127, 137, 150, .14);
|
|
3394
|
+
}
|
|
3395
|
+
.it-row:last-child { border-bottom: none; }
|
|
3396
|
+
.it-row-label {
|
|
3397
|
+
display: flex; flex-direction: column; gap: 3px;
|
|
3398
|
+
flex: 1 1 220px; min-width: 0; padding-top: 4px;
|
|
3399
|
+
}
|
|
3400
|
+
.it-row-label > span { line-height: 1.45; }
|
|
3401
|
+
.it-row-label small { opacity: .55; font-size: 11px; line-height: 1.5; }
|
|
3402
|
+
.it-row-control {
|
|
3403
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
3404
|
+
flex: 0 1 auto; justify-content: flex-end; max-width: 100%;
|
|
3405
|
+
}
|
|
3406
|
+
.it-row-note { flex: 1 1 100%; }
|
|
3407
|
+
/* 一行里有多个控件(输入框 + 按钮 + 按钮)时,把控件块整体折到标签下面并左对齐,
|
|
3408
|
+
否则窄面板里会出现"输入框在左、按钮飘到下一行右端"的散架感。 */
|
|
3409
|
+
.it-row[data-stack="1"] .it-row-control {
|
|
3410
|
+
flex: 1 1 100%; justify-content: flex-start; align-items: stretch;
|
|
3411
|
+
}
|
|
3412
|
+
.it-row[data-stack="1"] .it-row-control .it-field { flex: 1 1 220px; }
|
|
3413
|
+
.it-row[data-stack="1"] .it-row-control .it-action { flex: 0 0 auto; }
|
|
3414
|
+
.it-settings select,
|
|
3415
|
+
.it-settings input[type="text"],
|
|
3416
|
+
.it-settings input[type="number"],
|
|
3417
|
+
.it-settings input[type="time"],
|
|
3418
|
+
.it-settings textarea {
|
|
3419
|
+
height: 30px; padding: 0 10px;
|
|
3420
|
+
border-radius: 7px;
|
|
3421
|
+
border: 1px solid rgba(127, 137, 150, .34);
|
|
3422
|
+
background: transparent; color: inherit;
|
|
3423
|
+
font: inherit; font-size: 12px;
|
|
3424
|
+
max-width: 100%;
|
|
3425
|
+
}
|
|
3426
|
+
.it-settings textarea {
|
|
3427
|
+
height: auto; min-height: 62px; padding: 7px 10px;
|
|
3428
|
+
line-height: 1.5; resize: vertical;
|
|
3429
|
+
flex: 1 1 260px; min-width: 200px;
|
|
3430
|
+
}
|
|
3431
|
+
.it-field { flex: 1 1 200px; min-width: 160px; }
|
|
3432
|
+
.it-field-sm { flex: 0 1 96px; min-width: 84px; }
|
|
3433
|
+
.it-settings input[type="range"] { width: 140px; max-width: 100%; }
|
|
3434
|
+
.it-settings select { min-width: 132px; }
|
|
3435
|
+
.it-switch {
|
|
3436
|
+
position: relative; width: 38px; height: 22px; flex: none;
|
|
3437
|
+
border-radius: 11px; border: none; padding: 0;
|
|
3438
|
+
background: rgba(127, 137, 150, .38);
|
|
3439
|
+
cursor: pointer; transition: background 160ms ease;
|
|
3440
|
+
}
|
|
3441
|
+
.it-switch[aria-checked="true"] { background: #2f7d6b; }
|
|
3442
|
+
.it-switch::after {
|
|
3443
|
+
content: ""; position: absolute; top: 3px; left: 3px;
|
|
3444
|
+
width: 16px; height: 16px; border-radius: 50%;
|
|
3445
|
+
background: #fff; transition: transform 160ms ease;
|
|
3446
|
+
}
|
|
3447
|
+
.it-switch[aria-checked="true"]::after { transform: translateX(16px); }
|
|
3448
|
+
.it-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
3449
|
+
.it-action {
|
|
3450
|
+
height: 30px; padding: 0 14px; border-radius: 8px;
|
|
3451
|
+
border: 1px solid rgba(127, 137, 150, .34);
|
|
3452
|
+
background: transparent; color: inherit; font: inherit; font-size: 12px;
|
|
3453
|
+
cursor: pointer;
|
|
3454
|
+
}
|
|
3455
|
+
.it-action:hover { border-color: rgba(127, 137, 150, .6); }
|
|
3456
|
+
.it-action[data-primary="1"] { background: #2f7d6b; border-color: #2f7d6b; color: #fff; }
|
|
3457
|
+
.it-hint { font-size: 11px; opacity: .55; }
|
|
3458
|
+
.it-warn { font-size: 11px; color: #c2823a; }
|
|
3459
|
+
`;
|
|
3460
|
+
var STYLE_ID = "dsh-intertidal";
|
|
3461
|
+
var STYLE_ATTR = "data-plugin-css";
|
|
3462
|
+
function injectStyles(doc = globalThis.document) {
|
|
3463
|
+
if (!doc) return () => {
|
|
3464
|
+
};
|
|
3465
|
+
for (const node of doc.querySelectorAll(`style[${STYLE_ATTR}="${STYLE_ID}"]`)) node.remove();
|
|
3466
|
+
const style = doc.createElement("style");
|
|
3467
|
+
style.setAttribute(STYLE_ATTR, STYLE_ID);
|
|
3468
|
+
style.textContent = STYLE;
|
|
3469
|
+
doc.head.appendChild(style);
|
|
3470
|
+
return () => {
|
|
3471
|
+
style.remove();
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// src/client/i18n.js
|
|
3476
|
+
var NS = "intertidal";
|
|
3477
|
+
var zh = {
|
|
3478
|
+
"panel.title": "潮间",
|
|
3479
|
+
"panel.caption": "此刻",
|
|
3480
|
+
"panel.remaining": "剩余",
|
|
3481
|
+
"panel.paused": "已暂停",
|
|
3482
|
+
"panel.crashed": "潮间出了点问题,点此关闭",
|
|
3483
|
+
"action.open": "涨一次潮",
|
|
3484
|
+
"action.close": "关闭",
|
|
3485
|
+
"action.pip": "缩成小窗",
|
|
3486
|
+
"action.expand": "展开整屏",
|
|
3487
|
+
"action.pause": "暂停计时",
|
|
3488
|
+
"action.resume": "继续计时",
|
|
3489
|
+
"action.skip": "今天跳过",
|
|
3490
|
+
"action.preview": "看一眼海",
|
|
3491
|
+
"hint.keys": "Esc 关闭 · C 缩窗 · Space 暂停",
|
|
3492
|
+
"hint.offline": "暂时无法保存设置",
|
|
3493
|
+
"sound.unlock": "点一下才能播放海浪声",
|
|
3494
|
+
"settings.title": "潮间",
|
|
3495
|
+
"settings.group.rhythm": "节奏",
|
|
3496
|
+
"settings.group.form": "形态",
|
|
3497
|
+
"settings.group.place": "地点",
|
|
3498
|
+
"settings.group.sound": "声音",
|
|
3499
|
+
"settings.group.access": "无障碍",
|
|
3500
|
+
"settings.auto": "自动浮现",
|
|
3501
|
+
"settings.auto.hint": "到点自己浮起来,到点自己退走",
|
|
3502
|
+
"settings.interval": "间隔",
|
|
3503
|
+
"settings.interval.hint": "两次浮现之间隔多久",
|
|
3504
|
+
"settings.duration": "每次时长",
|
|
3505
|
+
"settings.firstDelay": "首次投放延迟",
|
|
3506
|
+
"settings.firstDelay.hint": "装完先安静一会儿,别一装就糊一脸",
|
|
3507
|
+
"settings.quiet": "静默时段",
|
|
3508
|
+
"settings.quiet.hint": "这段时间内不自动浮现(手动不受限)",
|
|
3509
|
+
"settings.deferTyping": "输入中推迟",
|
|
3510
|
+
"settings.deferTyping.hint": "正在打字时等你停手 20 秒;最多等 10 分钟则跳过本次",
|
|
3511
|
+
"settings.manualExtends": "手动看过后顺延",
|
|
3512
|
+
"settings.manualExtends.hint": "你主动看一眼,下一次定时就往后挪",
|
|
3513
|
+
"settings.form": "浮现形式",
|
|
3514
|
+
"settings.form.full": "整屏",
|
|
3515
|
+
"settings.form.pip": "右下角小窗",
|
|
3516
|
+
"settings.rememberForm": "记住上次形态",
|
|
3517
|
+
"settings.caption": "角落小字",
|
|
3518
|
+
"settings.caption.hint": "「此刻 · 上海 · 17:18」,不显示任何数据源",
|
|
3519
|
+
"settings.city": "城市",
|
|
3520
|
+
"settings.city.custom": "自定义经纬度",
|
|
3521
|
+
"settings.lat": "纬度",
|
|
3522
|
+
"settings.lon": "经度",
|
|
3523
|
+
"settings.tz": "时区",
|
|
3524
|
+
"settings.sound": "海浪声",
|
|
3525
|
+
"settings.volume": "音量",
|
|
3526
|
+
"settings.sound.hint": "程序化合成,不需要任何素材文件",
|
|
3527
|
+
"settings.group.media": "画面素材",
|
|
3528
|
+
"settings.media.mode": "素材来源",
|
|
3529
|
+
"settings.media.off": "程序化海(默认)",
|
|
3530
|
+
"settings.media.dir": "本地目录",
|
|
3531
|
+
"settings.media.url": "网页 / 图片链接",
|
|
3532
|
+
"settings.media.hint": "用真实照片或视频当海的底子;不选就还是程序化海。素材加载失败会自动退回程序化海。",
|
|
3533
|
+
"settings.media.dir.hint": "从目录里随机取一张图片或视频,全程不联网",
|
|
3534
|
+
"settings.media.dir.empty": "还没选目录",
|
|
3535
|
+
"settings.media.pick": "选择目录",
|
|
3536
|
+
"settings.media.scan": "检查目录",
|
|
3537
|
+
"settings.media.scanOk": "目录里有素材",
|
|
3538
|
+
"settings.media.scanEmpty": "目录里没有图片或视频",
|
|
3539
|
+
"settings.media.scanFail": "读不到这个目录",
|
|
3540
|
+
"settings.media.urls": "链接",
|
|
3541
|
+
"settings.media.urls.hint": "一行一条,最多 8 条。直链图片/视频直接用;网页会尝试取 og:image 首图。",
|
|
3542
|
+
"settings.media.fetch": "抓取并缓存",
|
|
3543
|
+
"settings.media.fetchOk": "已缓存,下次浮现就会用",
|
|
3544
|
+
"settings.media.fetchFail": "抓不到,检查链接或网络",
|
|
3545
|
+
"settings.media.grade": "天光着色",
|
|
3546
|
+
"settings.media.grade.hint": "按此刻的太阳给素材压暗/提亮、偏暖/偏冷;0 = 保持原图",
|
|
3547
|
+
"settings.media.motion": "缓慢推拉",
|
|
3548
|
+
"settings.media.motion.hint": "Ken Burns,让静图不像壁纸;0 = 完全静止",
|
|
3549
|
+
"settings.media.shuffle": "每次浮现换一张",
|
|
3550
|
+
"settings.reducedMotion": "减少动态",
|
|
3551
|
+
"settings.reducedMotion.auto": "跟随系统",
|
|
3552
|
+
"settings.reducedMotion.on": "强制开",
|
|
3553
|
+
"settings.reducedMotion.off": "强制关",
|
|
3554
|
+
"settings.reducedMotion.hint": "开启后画面静止,且自动浮现默认关闭",
|
|
3555
|
+
"settings.next": "下一次浮现",
|
|
3556
|
+
"settings.next.soon": "马上就到",
|
|
3557
|
+
"settings.next.off": "自动已关闭",
|
|
3558
|
+
"settings.stateFile": "状态文件",
|
|
3559
|
+
"unit.minutes": "分钟",
|
|
3560
|
+
"unit.seconds": "秒",
|
|
3561
|
+
"phase.dawn": "晨曦",
|
|
3562
|
+
"phase.noon": "正午",
|
|
3563
|
+
"phase.afternoon": "午后",
|
|
3564
|
+
"phase.dusk": "黄昏",
|
|
3565
|
+
"phase.night": "夜"
|
|
3566
|
+
};
|
|
3567
|
+
var en = {
|
|
3568
|
+
"panel.title": "Intertidal",
|
|
3569
|
+
"panel.caption": "now",
|
|
3570
|
+
"panel.remaining": "remaining",
|
|
3571
|
+
"panel.paused": "paused",
|
|
3572
|
+
"panel.crashed": "Intertidal hit a problem — click to close",
|
|
3573
|
+
"action.open": "Raise the tide",
|
|
3574
|
+
"action.close": "Close",
|
|
3575
|
+
"action.pip": "Shrink to corner",
|
|
3576
|
+
"action.expand": "Expand",
|
|
3577
|
+
"action.pause": "Pause timer",
|
|
3578
|
+
"action.resume": "Resume timer",
|
|
3579
|
+
"action.skip": "Skip today",
|
|
3580
|
+
"action.preview": "Look at the sea",
|
|
3581
|
+
"hint.keys": "Esc close · C shrink · Space pause",
|
|
3582
|
+
"hint.offline": "Settings cannot be saved right now",
|
|
3583
|
+
"sound.unlock": "Click once to allow surf audio",
|
|
3584
|
+
"settings.title": "Intertidal",
|
|
3585
|
+
"settings.group.rhythm": "Rhythm",
|
|
3586
|
+
"settings.group.form": "Form",
|
|
3587
|
+
"settings.group.place": "Place",
|
|
3588
|
+
"settings.group.sound": "Sound",
|
|
3589
|
+
"settings.group.access": "Accessibility",
|
|
3590
|
+
"settings.auto": "Automatic",
|
|
3591
|
+
"settings.auto.hint": "It comes up on its own and leaves on its own",
|
|
3592
|
+
"settings.interval": "Interval",
|
|
3593
|
+
"settings.interval.hint": "How long between two appearances",
|
|
3594
|
+
"settings.duration": "Duration",
|
|
3595
|
+
"settings.firstDelay": "First appearance delay",
|
|
3596
|
+
"settings.firstDelay.hint": "Stay quiet right after install",
|
|
3597
|
+
"settings.quiet": "Quiet hours",
|
|
3598
|
+
"settings.quiet.hint": "No automatic appearance in this window (manual still works)",
|
|
3599
|
+
"settings.deferTyping": "Defer while typing",
|
|
3600
|
+
"settings.deferTyping.hint": "Waits 20s after you stop typing; gives up after 10 minutes",
|
|
3601
|
+
"settings.manualExtends": "Manual viewing pushes the next one",
|
|
3602
|
+
"settings.manualExtends.hint": "Looking on purpose moves the next timed appearance later",
|
|
3603
|
+
"settings.form": "Appearance",
|
|
3604
|
+
"settings.form.full": "Full screen",
|
|
3605
|
+
"settings.form.pip": "Corner window",
|
|
3606
|
+
"settings.rememberForm": "Remember last form",
|
|
3607
|
+
"settings.caption": "Corner caption",
|
|
3608
|
+
"settings.caption.hint": "“Now · Shanghai · 17:18” — no data source involved",
|
|
3609
|
+
"settings.city": "City",
|
|
3610
|
+
"settings.city.custom": "Custom coordinates",
|
|
3611
|
+
"settings.lat": "Latitude",
|
|
3612
|
+
"settings.lon": "Longitude",
|
|
3613
|
+
"settings.tz": "Time zone",
|
|
3614
|
+
"settings.sound": "Surf sound",
|
|
3615
|
+
"settings.volume": "Volume",
|
|
3616
|
+
"settings.sound.hint": "Synthesised, no asset files",
|
|
3617
|
+
"settings.group.media": "Scenery",
|
|
3618
|
+
"settings.media.mode": "Source",
|
|
3619
|
+
"settings.media.off": "Procedural sea (default)",
|
|
3620
|
+
"settings.media.dir": "Local folder",
|
|
3621
|
+
"settings.media.url": "Web / image links",
|
|
3622
|
+
"settings.media.hint": "Use real photos or video as the sea bed; leave it off for the procedural sea. Falls back automatically if a file fails to load.",
|
|
3623
|
+
"settings.media.dir.hint": "Picks a random image or video from the folder. No network at all.",
|
|
3624
|
+
"settings.media.dir.empty": "No folder chosen",
|
|
3625
|
+
"settings.media.pick": "Choose folder",
|
|
3626
|
+
"settings.media.scan": "Check folder",
|
|
3627
|
+
"settings.media.scanOk": "Files found",
|
|
3628
|
+
"settings.media.scanEmpty": "No images or videos in that folder",
|
|
3629
|
+
"settings.media.scanFail": "Cannot read that folder",
|
|
3630
|
+
"settings.media.urls": "Links",
|
|
3631
|
+
"settings.media.urls.hint": "One per line, up to 8. Direct image/video URLs are used as-is; pages are scraped for og:image.",
|
|
3632
|
+
"settings.media.fetch": "Fetch and cache",
|
|
3633
|
+
"settings.media.fetchOk": "Cached — the next appearance will use it",
|
|
3634
|
+
"settings.media.fetchFail": "Could not fetch; check the link or network",
|
|
3635
|
+
"settings.media.grade": "Time-of-day grade",
|
|
3636
|
+
"settings.media.grade.hint": "Darkens/brightens and warms/cools the photo to match the sun right now; 0 keeps the original",
|
|
3637
|
+
"settings.media.motion": "Slow push",
|
|
3638
|
+
"settings.media.motion.hint": "Ken Burns, so a still photo does not look like wallpaper; 0 is perfectly still",
|
|
3639
|
+
"settings.media.shuffle": "New one every appearance",
|
|
3640
|
+
"settings.reducedMotion": "Reduced motion",
|
|
3641
|
+
"settings.reducedMotion.auto": "Follow system",
|
|
3642
|
+
"settings.reducedMotion.on": "Force on",
|
|
3643
|
+
"settings.reducedMotion.off": "Force off",
|
|
3644
|
+
"settings.reducedMotion.hint": "Static frame, and automatic appearance defaults to off",
|
|
3645
|
+
"settings.next": "Next appearance",
|
|
3646
|
+
"settings.next.soon": "any moment",
|
|
3647
|
+
"settings.next.off": "automatic is off",
|
|
3648
|
+
"settings.stateFile": "State file",
|
|
3649
|
+
"unit.minutes": "min",
|
|
3650
|
+
"unit.seconds": "s",
|
|
3651
|
+
"phase.dawn": "dawn",
|
|
3652
|
+
"phase.noon": "noon",
|
|
3653
|
+
"phase.afternoon": "afternoon",
|
|
3654
|
+
"phase.dusk": "dusk",
|
|
3655
|
+
"phase.night": "night"
|
|
3656
|
+
};
|
|
3657
|
+
function normalizeLang(value) {
|
|
3658
|
+
return String(value ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
3659
|
+
}
|
|
3660
|
+
function createTranslator(locale, doc = globalThis.document, nav = globalThis.navigator) {
|
|
3661
|
+
const active = () => {
|
|
3662
|
+
try {
|
|
3663
|
+
const snapshot = locale?.getSnapshot?.();
|
|
3664
|
+
if (snapshot && snapshot.active) return normalizeLang(snapshot.active);
|
|
3665
|
+
} catch {
|
|
3666
|
+
}
|
|
3667
|
+
try {
|
|
3668
|
+
const attr = doc?.documentElement?.getAttribute?.("lang");
|
|
3669
|
+
if (attr) return normalizeLang(attr);
|
|
3670
|
+
} catch {
|
|
3671
|
+
}
|
|
3672
|
+
return normalizeLang(nav?.language ?? "zh");
|
|
3673
|
+
};
|
|
3674
|
+
const t = (key) => {
|
|
3675
|
+
const dict = active() === "en" ? en : zh;
|
|
3676
|
+
return dict[key] ?? zh[key] ?? key;
|
|
3677
|
+
};
|
|
3678
|
+
return {
|
|
3679
|
+
t,
|
|
3680
|
+
getLang: active,
|
|
3681
|
+
/** 语言变化时通知组件重渲染(拿得到 subscribe 才有)。 */
|
|
3682
|
+
subscribe(callback) {
|
|
3683
|
+
try {
|
|
3684
|
+
return locale?.subscribe?.(() => callback(active())) ?? (() => {
|
|
3685
|
+
});
|
|
3686
|
+
} catch {
|
|
3687
|
+
return () => {
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// src/client/index.jsx
|
|
3695
|
+
var logger = {
|
|
3696
|
+
warn: (...args) => console.warn("[dsh-intertidal]", ...args)
|
|
3697
|
+
};
|
|
3698
|
+
var PANEL_ID = "intertidal";
|
|
3699
|
+
var plugin = {
|
|
3700
|
+
name: "dsh-intertidal",
|
|
3701
|
+
// `slots` 与 `locale` 都是 DSH 的常驻服务,一并声明:这样 apply 里拿到的 locale 一定是
|
|
3702
|
+
// 已就绪的那个(早先只 inject slots、用 ctx.get('locale') 现取,曾经拿到 undefined,
|
|
3703
|
+
// 于是插件语言跟着 documentElement.lang 跑 —— 表现就是"DSH 中文、插件英文")。
|
|
3704
|
+
inject: ["slots", "locale"],
|
|
3705
|
+
apply(ctx) {
|
|
3706
|
+
const locale = ctx.locale ?? ctx.get?.("locale") ?? null;
|
|
3707
|
+
const { t, getLang, subscribe: subscribeLang } = createTranslator(locale);
|
|
3708
|
+
if (locale && typeof locale.register === "function") {
|
|
3709
|
+
ctx.effect(() => {
|
|
3710
|
+
try {
|
|
3711
|
+
return locale.register(NS, { zh, en });
|
|
3712
|
+
} catch (error) {
|
|
3713
|
+
logger.warn("注册文案失败(可能已被注册过):", error);
|
|
3714
|
+
return () => {
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
}, "dsh-intertidal: dictionaries");
|
|
3718
|
+
}
|
|
3719
|
+
ctx.effect(() => {
|
|
3720
|
+
const remove = injectStyles();
|
|
3721
|
+
return () => {
|
|
3722
|
+
remove();
|
|
3723
|
+
for (const node of globalThis.document?.querySelectorAll?.('style[data-plugin-css="dsh-intertidal"]') ?? []) {
|
|
3724
|
+
node.remove();
|
|
3725
|
+
}
|
|
3726
|
+
};
|
|
3727
|
+
}, "dsh-intertidal: styles");
|
|
3728
|
+
const api = createApi({
|
|
3729
|
+
onStatus: (offline) => {
|
|
3730
|
+
if (offline) logger.warn("宿主接口不可用,退镜像设置运行");
|
|
3731
|
+
}
|
|
3732
|
+
});
|
|
3733
|
+
const runtime = createRuntime({ api });
|
|
3734
|
+
ctx.effect(() => {
|
|
3735
|
+
const dropSound = runtime.attachSound(createSurfSound());
|
|
3736
|
+
void runtime.init();
|
|
3737
|
+
return () => {
|
|
3738
|
+
dropSound();
|
|
3739
|
+
runtime.dispose();
|
|
3740
|
+
};
|
|
3741
|
+
}, "dsh-intertidal: runtime");
|
|
3742
|
+
const withRuntime = () => ({
|
|
3743
|
+
runtime,
|
|
3744
|
+
ctx,
|
|
3745
|
+
t,
|
|
3746
|
+
getLang,
|
|
3747
|
+
subscribeLang,
|
|
3748
|
+
// 可选:DSH 的工作区选择器(`uiWorkspace.pickDirectory`),拿不到就让手输路径
|
|
3749
|
+
pickDirectory: () => {
|
|
3750
|
+
try {
|
|
3751
|
+
return ctx.get?.("uiWorkspace")?.pickDirectory?.();
|
|
3752
|
+
} catch {
|
|
3753
|
+
return null;
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
});
|
|
3757
|
+
try {
|
|
3758
|
+
ctx.slots.inject(
|
|
3759
|
+
"shell.overlay",
|
|
3760
|
+
() => ctx.slots.register(
|
|
3761
|
+
{
|
|
3762
|
+
name: "shell.overlay",
|
|
3763
|
+
id: "intertidal.sea",
|
|
3764
|
+
order: 500,
|
|
3765
|
+
label: () => t("panel.title"),
|
|
3766
|
+
locale: NS,
|
|
3767
|
+
inject: withRuntime
|
|
3768
|
+
},
|
|
3769
|
+
Overlay
|
|
3770
|
+
)
|
|
3771
|
+
);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
logger.warn("注册整窗覆盖层失败:", error);
|
|
3774
|
+
}
|
|
3775
|
+
try {
|
|
3776
|
+
ctx.slots.inject(
|
|
3777
|
+
"settings.section",
|
|
3778
|
+
() => ctx.slots.register(
|
|
3779
|
+
{
|
|
3780
|
+
name: "settings.section",
|
|
3781
|
+
id: PANEL_ID,
|
|
3782
|
+
order: 88,
|
|
3783
|
+
label: () => t("settings.title"),
|
|
3784
|
+
locale: NS,
|
|
3785
|
+
inject: withRuntime
|
|
3786
|
+
},
|
|
3787
|
+
SettingsPanel
|
|
3788
|
+
)
|
|
3789
|
+
);
|
|
3790
|
+
} catch (error) {
|
|
3791
|
+
logger.warn("注册设置页失败:", error);
|
|
3792
|
+
}
|
|
3793
|
+
try {
|
|
3794
|
+
ctx.inject(["shortcuts"], (scope) => {
|
|
3795
|
+
scope.effect(() => {
|
|
3796
|
+
try {
|
|
3797
|
+
const dispose = scope.shortcuts.register({
|
|
3798
|
+
id: "intertidal.open",
|
|
3799
|
+
label: () => t("action.open"),
|
|
3800
|
+
aliases: ["涨一次潮", "look at the sea", "intertidal"],
|
|
3801
|
+
defaults: {},
|
|
3802
|
+
regions: ["page"],
|
|
3803
|
+
modals: [],
|
|
3804
|
+
resolve: () => ({
|
|
3805
|
+
status: "handled",
|
|
3806
|
+
run: () => void runtime.show("manual")
|
|
3807
|
+
})
|
|
3808
|
+
});
|
|
3809
|
+
return () => dispose?.();
|
|
3810
|
+
} catch (error) {
|
|
3811
|
+
logger.warn("注册快捷键命令失败(不影响其他功能):", error);
|
|
3812
|
+
return () => {
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
});
|
|
3817
|
+
} catch (error) {
|
|
3818
|
+
logger.warn("shortcuts 服务不可用:", error);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
};
|
|
3822
|
+
var inject = plugin.inject;
|
|
3823
|
+
var apply = plugin.apply;
|
|
3824
|
+
var name = plugin.name;
|
|
3825
|
+
|
|
3826
|
+
return module.exports;
|
|
3827
|
+
}
|
|
3828
|
+
});
|