dsh-offwork 1.1.1 → 1.1.2
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/CHANGELOG.md +12 -0
- package/README.md +36 -21
- package/client.js +2558 -822
- package/client.source.js +1592 -432
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +6 -2
- package/pet-interactions.js +80 -9
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +129 -5
- package/pet-video.js +221 -72
- package/pet-view.js +69 -4
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tools/build.mjs +39 -16
- package/tools/check-package.mjs +8 -0
- package/ui-utils.js +44 -0
package/pet-video.js
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
|
-
// Independent
|
|
1
|
+
// Independent media presentation; never writes settings or hydration records.
|
|
2
2
|
const PET_VIDEO_SUPPORT = new WeakMap();
|
|
3
3
|
|
|
4
|
+
export function petClipUrls(models, baseURI) {
|
|
5
|
+
try {
|
|
6
|
+
const base = new URL(baseURI);
|
|
7
|
+
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password) return {};
|
|
8
|
+
const prefix = new URL('offwork-clock/media/', base);
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Object.entries(models).map(([model, actions]) => [
|
|
11
|
+
model,
|
|
12
|
+
Object.fromEntries(
|
|
13
|
+
Object.entries(actions).flatMap(([action, path]) => {
|
|
14
|
+
if (typeof path !== 'string' || !path.startsWith('offwork-clock/media/')) return [];
|
|
15
|
+
const url = new URL(path, base);
|
|
16
|
+
return url.origin === base.origin &&
|
|
17
|
+
url.pathname.startsWith(prefix.pathname) &&
|
|
18
|
+
!url.search &&
|
|
19
|
+
!url.hash
|
|
20
|
+
? [[action, url.href]]
|
|
21
|
+
: [];
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
4
31
|
export function petVideoAlphaSupported(host, source) {
|
|
5
32
|
if (PET_VIDEO_SUPPORT.has(host)) return PET_VIDEO_SUPPORT.get(host);
|
|
6
33
|
const result = new Promise((resolve) => {
|
|
7
34
|
const video = host.document.createElement('video');
|
|
8
|
-
let timer
|
|
35
|
+
let timer,
|
|
36
|
+
frame,
|
|
37
|
+
delayed,
|
|
38
|
+
done = false;
|
|
9
39
|
const finish = (supported) => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
done = true;
|
|
10
42
|
host.clearTimeout(timer);
|
|
43
|
+
host.clearTimeout(delayed);
|
|
44
|
+
if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);
|
|
11
45
|
video.onloadeddata = video.onerror = null;
|
|
12
46
|
video.pause();
|
|
13
47
|
video.removeAttribute('src');
|
|
@@ -18,145 +52,241 @@ export function petVideoAlphaSupported(host, source) {
|
|
|
18
52
|
video.muted = true;
|
|
19
53
|
video.playsInline = true;
|
|
20
54
|
video.preload = 'auto';
|
|
21
|
-
|
|
55
|
+
const inspect = (presented = false) => {
|
|
56
|
+
if (done) return true;
|
|
22
57
|
try {
|
|
23
58
|
const canvas = host.document.createElement('canvas');
|
|
24
59
|
canvas.width = canvas.height = 16;
|
|
25
60
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
26
61
|
context.drawImage(video, 0, 0, 16, 16);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
62
|
+
const solid = context.getImageData(1, 1, 1, 1).data[3];
|
|
63
|
+
const clear = context.getImageData(14, 14, 1, 1).data[3];
|
|
64
|
+
if (solid > 240 && clear < 8) {
|
|
65
|
+
finish(true);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (presented && solid > 240 && clear > 240) {
|
|
69
|
+
finish(false);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
31
72
|
} catch {
|
|
32
|
-
|
|
73
|
+
/* A frame can be undecoded when loadeddata fires. */
|
|
33
74
|
}
|
|
75
|
+
return false;
|
|
34
76
|
};
|
|
35
|
-
video.
|
|
36
|
-
|
|
77
|
+
video.onloadeddata = () => {
|
|
78
|
+
if (inspect()) return;
|
|
79
|
+
if (video.requestVideoFrameCallback)
|
|
80
|
+
frame = video.requestVideoFrameCallback(() => inspect(true));
|
|
81
|
+
const poll = () => {
|
|
82
|
+
if (!inspect(true)) delayed = host.setTimeout(poll, 80);
|
|
83
|
+
};
|
|
84
|
+
delayed = host.setTimeout(poll, 80);
|
|
85
|
+
try {
|
|
86
|
+
Promise.resolve(video.play()).catch(() => {
|
|
87
|
+
if (!inspect()) finish(null);
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
if (!inspect()) finish(null);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
video.onerror = () => finish(null);
|
|
94
|
+
timer = host.setTimeout(() => {
|
|
95
|
+
if (!inspect()) finish(null);
|
|
96
|
+
}, 5000);
|
|
37
97
|
video.src = source;
|
|
38
98
|
video.load();
|
|
39
99
|
});
|
|
40
100
|
PET_VIDEO_SUPPORT.set(host, result);
|
|
101
|
+
result.then((supported) => {
|
|
102
|
+
if (supported === null && PET_VIDEO_SUPPORT.get(host) === result)
|
|
103
|
+
PET_VIDEO_SUPPORT.delete(host);
|
|
104
|
+
});
|
|
41
105
|
return result;
|
|
42
106
|
}
|
|
43
107
|
|
|
44
|
-
export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
108
|
+
export function createPetVideoMotion(element, host, base, clips, probeSource, options = {}) {
|
|
45
109
|
const layer = element.querySelector('.owc-pet-video-layer');
|
|
110
|
+
const poster = element.querySelector('.owc-pet-video-poster');
|
|
46
111
|
if (!layer || !clips) return base;
|
|
47
112
|
const document = host.document;
|
|
48
113
|
const reduced = host.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
114
|
+
const autoIdle = options.autoIdle !== false;
|
|
115
|
+
const useFallback = options.fallback !== false;
|
|
116
|
+
const failed = new Set(),
|
|
117
|
+
retired = new Map();
|
|
52
118
|
let enabled = false,
|
|
53
119
|
resting = false,
|
|
54
120
|
dragging = false,
|
|
55
|
-
unsupported = false
|
|
121
|
+
unsupported = false;
|
|
122
|
+
let destroyed = false,
|
|
56
123
|
idleTimer = null,
|
|
57
|
-
|
|
58
|
-
|
|
124
|
+
current = null,
|
|
125
|
+
displayed = null,
|
|
126
|
+
lastState = '';
|
|
59
127
|
const allowed = () => enabled && !destroyed && !document.hidden && !reduced?.matches;
|
|
128
|
+
const canIdle = () =>
|
|
129
|
+
autoIdle &&
|
|
130
|
+
allowed() &&
|
|
131
|
+
!resting &&
|
|
132
|
+
!dragging &&
|
|
133
|
+
!unsupported &&
|
|
134
|
+
Object.hasOwn(clips, 'idle') &&
|
|
135
|
+
!failed.has('idle');
|
|
136
|
+
function notify(phase, action = null) {
|
|
137
|
+
if (destroyed || lastState === phase + ':' + action) return;
|
|
138
|
+
lastState = phase + ':' + action;
|
|
139
|
+
options.onStateChange?.({ phase, action });
|
|
140
|
+
}
|
|
60
141
|
function clearIdleTimer() {
|
|
61
142
|
host.clearTimeout(idleTimer);
|
|
62
143
|
idleTimer = null;
|
|
63
144
|
}
|
|
64
145
|
function startIdle() {
|
|
65
|
-
if (
|
|
66
|
-
allowed() &&
|
|
67
|
-
!resting &&
|
|
68
|
-
!dragging &&
|
|
69
|
-
!current &&
|
|
70
|
-
!unsupported &&
|
|
71
|
-
Object.hasOwn(clips, 'idle') &&
|
|
72
|
-
!failed.has('idle')
|
|
73
|
-
)
|
|
74
|
-
play('idle');
|
|
146
|
+
if (canIdle() && !current) play('idle');
|
|
75
147
|
}
|
|
76
148
|
function scheduleIdle(delay = 0) {
|
|
77
149
|
clearIdleTimer();
|
|
78
|
-
if (!
|
|
150
|
+
if (!canIdle()) return;
|
|
79
151
|
idleTimer = host.setTimeout(() => {
|
|
80
152
|
idleTimer = null;
|
|
81
153
|
startIdle();
|
|
82
154
|
}, delay);
|
|
83
155
|
}
|
|
84
|
-
function dispose(
|
|
156
|
+
function dispose(run) {
|
|
157
|
+
if (!run || run.disposed) return;
|
|
158
|
+
run.disposed = true;
|
|
159
|
+
host.clearTimeout(run.timer);
|
|
160
|
+
const video = run.video;
|
|
161
|
+
if (!video) return;
|
|
85
162
|
video.onplaying = video.onended = video.onerror = video.onwaiting = video.onstalled = null;
|
|
86
163
|
video.pause();
|
|
87
164
|
video.removeAttribute('src');
|
|
88
165
|
video.load();
|
|
89
166
|
video.remove();
|
|
90
167
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
function flushRetired() {
|
|
169
|
+
for (const [run, timer] of retired) {
|
|
170
|
+
host.clearTimeout(timer);
|
|
171
|
+
dispose(run);
|
|
172
|
+
}
|
|
173
|
+
retired.clear();
|
|
174
|
+
}
|
|
175
|
+
function retire(run, fade) {
|
|
176
|
+
if (!run?.video || !fade || !allowed()) return dispose(run);
|
|
96
177
|
host.clearTimeout(run.timer);
|
|
178
|
+
run.video.onplaying =
|
|
179
|
+
run.video.onended =
|
|
180
|
+
run.video.onerror =
|
|
181
|
+
run.video.onwaiting =
|
|
182
|
+
run.video.onstalled =
|
|
183
|
+
null;
|
|
184
|
+
run.video.pause();
|
|
185
|
+
run.video.style.opacity = '0';
|
|
186
|
+
const timer = host.setTimeout(() => {
|
|
187
|
+
retired.delete(run);
|
|
188
|
+
dispose(run);
|
|
189
|
+
}, 150);
|
|
190
|
+
retired.set(run, timer);
|
|
191
|
+
}
|
|
192
|
+
function settleVisibleFrame() {
|
|
193
|
+
if (!displayed?.video) {
|
|
194
|
+
if (poster?.style) {
|
|
195
|
+
poster.style.transition = 'none';
|
|
196
|
+
poster.style.opacity = '1';
|
|
197
|
+
poster.getBoundingClientRect?.();
|
|
198
|
+
poster.style.opacity = '';
|
|
199
|
+
poster.style.transition = '';
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
displayed.video.style.transition = 'none';
|
|
204
|
+
displayed.video.style.opacity = '1';
|
|
205
|
+
if (poster?.style) {
|
|
206
|
+
poster.style.transition = 'none';
|
|
207
|
+
poster.style.opacity = '0';
|
|
208
|
+
}
|
|
209
|
+
displayed.video.getBoundingClientRect?.();
|
|
210
|
+
displayed.video.style.transition = '';
|
|
211
|
+
if (poster?.style) {
|
|
212
|
+
poster.style.opacity = '';
|
|
213
|
+
poster.style.transition = '';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function stopMedia(fade = false) {
|
|
217
|
+
if (!fade && poster?.style) poster.style.transition = 'none';
|
|
218
|
+
clearIdleTimer();
|
|
219
|
+
flushRetired();
|
|
220
|
+
const pending = current,
|
|
221
|
+
visible = displayed;
|
|
222
|
+
current = displayed = null;
|
|
97
223
|
delete element.dataset.videoPlaying;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
retired.delete(video);
|
|
107
|
-
dispose(video);
|
|
108
|
-
}, 150);
|
|
109
|
-
retired.set(video, timer);
|
|
110
|
-
} else dispose(video);
|
|
224
|
+
delete element.dataset.videoLoading;
|
|
225
|
+
delete element.dataset.action;
|
|
226
|
+
if (pending && pending !== visible) dispose(pending);
|
|
227
|
+
retire(visible, fade);
|
|
228
|
+
if (!fade && poster?.style) {
|
|
229
|
+
poster.getBoundingClientRect?.();
|
|
230
|
+
poster.style.transition = '';
|
|
231
|
+
}
|
|
111
232
|
}
|
|
112
233
|
function fallback(run, broken = false) {
|
|
113
234
|
if (current !== run) return;
|
|
114
235
|
if (broken) failed.add(run.action);
|
|
115
|
-
|
|
116
|
-
|
|
236
|
+
stopMedia();
|
|
237
|
+
notify(unsupported ? 'unsupported' : 'unavailable', run.action);
|
|
238
|
+
if (allowed() && run.action !== 'idle' && useFallback) {
|
|
117
239
|
base.play(run.action);
|
|
118
240
|
scheduleIdle(1400);
|
|
119
241
|
}
|
|
120
242
|
}
|
|
121
243
|
function play(action) {
|
|
122
244
|
if (!allowed() || dragging || element.dataset.action === 'drag') return false;
|
|
123
|
-
if (action === 'idle' && (resting || current || unsupported))
|
|
245
|
+
if (action === 'idle' && (resting || (current && !options.interruptible) || unsupported))
|
|
246
|
+
return false;
|
|
124
247
|
if (
|
|
125
248
|
current &&
|
|
126
249
|
(action === 'look' ||
|
|
127
250
|
current.action === action ||
|
|
128
|
-
(current.action === 'celebrate' && action === 'greet'))
|
|
251
|
+
(!options.interruptible && current.action === 'celebrate' && action === 'greet'))
|
|
129
252
|
)
|
|
130
253
|
return false;
|
|
131
254
|
if (!Object.hasOwn(clips, action) || failed.has(action) || unsupported) {
|
|
132
255
|
if (action === 'idle') return false;
|
|
133
|
-
if (current?.action === 'idle')
|
|
134
|
-
const played = base.play(action);
|
|
256
|
+
if (current?.action === 'idle') stopMedia();
|
|
257
|
+
const played = useFallback && base.play(action);
|
|
135
258
|
if (played) scheduleIdle(1400);
|
|
136
|
-
|
|
259
|
+
if (!played) notify(unsupported ? 'unsupported' : 'unavailable', action);
|
|
260
|
+
return !!played;
|
|
137
261
|
}
|
|
138
|
-
|
|
262
|
+
clearIdleTimer();
|
|
263
|
+
settleVisibleFrame();
|
|
264
|
+
flushRetired();
|
|
265
|
+
if (current && current !== displayed) dispose(current);
|
|
139
266
|
base.release(true);
|
|
140
|
-
const run = { action, video: null, timer: null };
|
|
267
|
+
const run = { action, video: null, timer: null, disposed: false };
|
|
141
268
|
current = run;
|
|
142
|
-
element.dataset.action = action;
|
|
269
|
+
element.dataset.action = displayed?.action || action;
|
|
270
|
+
element.dataset.videoLoading = action;
|
|
271
|
+
notify('loading', action);
|
|
143
272
|
run.timer = host.setTimeout(() => fallback(run, true), 9000);
|
|
144
|
-
|
|
273
|
+
petVideoAlphaSupported(host, probeSource).then((supported) => {
|
|
145
274
|
if (current !== run || !allowed()) return;
|
|
146
|
-
if (
|
|
147
|
-
unsupported =
|
|
275
|
+
if (supported !== true) {
|
|
276
|
+
unsupported = supported === false;
|
|
148
277
|
return fallback(run);
|
|
149
278
|
}
|
|
150
279
|
const video = document.createElement('video');
|
|
151
280
|
run.video = video;
|
|
152
|
-
video.muted = true;
|
|
153
|
-
video.defaultMuted = true;
|
|
281
|
+
video.muted = video.defaultMuted = true;
|
|
154
282
|
video.playsInline = true;
|
|
155
283
|
video.loop = action === 'idle';
|
|
156
284
|
video.disablePictureInPicture = true;
|
|
157
285
|
video.preload = 'auto';
|
|
286
|
+
video.style.opacity = '0';
|
|
158
287
|
video.setAttribute('aria-hidden', 'true');
|
|
159
288
|
video.setAttribute('playsinline', '');
|
|
289
|
+
video.setAttribute('tabindex', '-1');
|
|
160
290
|
video.onplaying = () => {
|
|
161
291
|
if (current !== run || !allowed()) return;
|
|
162
292
|
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.duration > 15)
|
|
@@ -166,7 +296,14 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
|
166
296
|
action === 'idle'
|
|
167
297
|
? null
|
|
168
298
|
: host.setTimeout(() => fallback(run, true), video.duration * 1000 + 2000);
|
|
299
|
+
const previous = displayed;
|
|
300
|
+
displayed = run;
|
|
301
|
+
video.style.opacity = '1';
|
|
302
|
+
element.dataset.action = action;
|
|
169
303
|
element.dataset.videoPlaying = 'true';
|
|
304
|
+
delete element.dataset.videoLoading;
|
|
305
|
+
if (previous !== run) retire(previous, true);
|
|
306
|
+
notify('playing', action);
|
|
170
307
|
};
|
|
171
308
|
video.onwaiting = video.onstalled = () => {
|
|
172
309
|
if (current !== run) return;
|
|
@@ -174,9 +311,15 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
|
174
311
|
run.timer = host.setTimeout(() => fallback(run, true), 4000);
|
|
175
312
|
};
|
|
176
313
|
video.onended = () => {
|
|
177
|
-
if (current
|
|
178
|
-
|
|
314
|
+
if (current !== run) return;
|
|
315
|
+
host.clearTimeout(run.timer);
|
|
316
|
+
if (canIdle()) {
|
|
317
|
+
current = null;
|
|
318
|
+
if (displayed !== run) dispose(run);
|
|
179
319
|
scheduleIdle();
|
|
320
|
+
} else {
|
|
321
|
+
stopMedia(true);
|
|
322
|
+
notify('idle');
|
|
180
323
|
}
|
|
181
324
|
};
|
|
182
325
|
video.onerror = () => fallback(run, true);
|
|
@@ -190,6 +333,11 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
|
190
333
|
});
|
|
191
334
|
return true;
|
|
192
335
|
}
|
|
336
|
+
function stop() {
|
|
337
|
+
stopMedia();
|
|
338
|
+
base.release(true);
|
|
339
|
+
notify('idle');
|
|
340
|
+
}
|
|
193
341
|
const visibility = () => {
|
|
194
342
|
if (!allowed()) stop();
|
|
195
343
|
else scheduleIdle();
|
|
@@ -198,16 +346,22 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
|
198
346
|
reduced?.addEventListener?.('change', visibility);
|
|
199
347
|
return {
|
|
200
348
|
play,
|
|
349
|
+
stop,
|
|
350
|
+
retry(action) {
|
|
351
|
+
failed.delete(action);
|
|
352
|
+
return play(action);
|
|
353
|
+
},
|
|
201
354
|
configure(next) {
|
|
202
|
-
if (!next.enabled || (next.resting && !resting))
|
|
355
|
+
if (!next.enabled || (next.resting && !resting)) stopMedia();
|
|
203
356
|
enabled = next.enabled;
|
|
204
357
|
resting = next.resting;
|
|
205
358
|
base.configure(next);
|
|
359
|
+
if (!enabled) notify('idle');
|
|
206
360
|
startIdle();
|
|
207
361
|
},
|
|
208
362
|
drag(direction) {
|
|
209
363
|
dragging = true;
|
|
210
|
-
|
|
364
|
+
stopMedia();
|
|
211
365
|
base.drag(direction);
|
|
212
366
|
},
|
|
213
367
|
release(cancelled) {
|
|
@@ -217,12 +371,7 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
|
|
|
217
371
|
},
|
|
218
372
|
destroy() {
|
|
219
373
|
destroyed = true;
|
|
220
|
-
|
|
221
|
-
for (const [video, timer] of retired) {
|
|
222
|
-
host.clearTimeout(timer);
|
|
223
|
-
dispose(video);
|
|
224
|
-
}
|
|
225
|
-
retired.clear();
|
|
374
|
+
stopMedia();
|
|
226
375
|
document.removeEventListener('visibilitychange', visibility);
|
|
227
376
|
reduced?.removeEventListener?.('change', visibility);
|
|
228
377
|
base.destroy();
|
package/pet-view.js
CHANGED
|
@@ -42,6 +42,8 @@ export function petLayout(
|
|
|
42
42
|
size = 132,
|
|
43
43
|
contentHeight = 420,
|
|
44
44
|
modelId = 'xiaoxie-original',
|
|
45
|
+
footerHeight = 30,
|
|
46
|
+
footerWidth = 148,
|
|
45
47
|
) {
|
|
46
48
|
const model = petModel(modelId);
|
|
47
49
|
const originX = viewport.left ?? 0,
|
|
@@ -55,14 +57,14 @@ export function petLayout(
|
|
|
55
57
|
Math.min(
|
|
56
58
|
size,
|
|
57
59
|
Math.max(1, (width - edge * 2) / model.widthScale),
|
|
58
|
-
Math.max(1, Math.floor((height - edge * 2 -
|
|
60
|
+
Math.max(1, Math.floor((height - edge * 2 - footerHeight) / model.heightScale)),
|
|
59
61
|
),
|
|
60
62
|
);
|
|
61
63
|
const petWidth = Math.min(
|
|
62
64
|
Math.max(1, width - edge * 2),
|
|
63
|
-
Math.max(
|
|
65
|
+
Math.max(footerWidth, artworkSize * model.widthScale),
|
|
64
66
|
);
|
|
65
|
-
const petHeight = Math.ceil(artworkSize * model.heightScale) +
|
|
67
|
+
const petHeight = Math.ceil(artworkSize * model.heightScale) + footerHeight;
|
|
66
68
|
const bound = (n, max) => Math.max(Math.min(edge, max), Math.min(n, max));
|
|
67
69
|
const x = bound(
|
|
68
70
|
Number.isFinite(position?.x) ? position.x - originX : width - petWidth - 32,
|
|
@@ -253,6 +255,10 @@ export function petArtwork(
|
|
|
253
255
|
export const PET_MESSAGES = {
|
|
254
256
|
zh: {
|
|
255
257
|
petGreet: '与角色互动',
|
|
258
|
+
petNeedsSession: '等待会话',
|
|
259
|
+
petNeedsSetup: '请配置作息',
|
|
260
|
+
petLoadFailed: '读取失败',
|
|
261
|
+
petRetryHint: '右键菜单可重新读取设置',
|
|
256
262
|
petCharacter: '角色形象',
|
|
257
263
|
petWhaleActions: '鲸蓝少女 · 长裙动态',
|
|
258
264
|
petWhaleOrnateActions: '鲸蓝少女 · 短裙动态',
|
|
@@ -266,9 +272,29 @@ export const PET_MESSAGES = {
|
|
|
266
272
|
petHint: '左键互动 · 右键菜单 · 拖动移动',
|
|
267
273
|
petMode: '宠物模式',
|
|
268
274
|
petClock: '下班倒计时',
|
|
275
|
+
petPreviewActions: '动作试播',
|
|
276
|
+
petPreviewIdle: '待机',
|
|
277
|
+
petPreviewGreet: '挥手',
|
|
278
|
+
petPreviewDrink: '喝茶',
|
|
279
|
+
petPreviewStop: '停止',
|
|
280
|
+
petTryIdle: '试播待机',
|
|
281
|
+
petTryGreet: '试播挥手',
|
|
282
|
+
petTryDrink: '试播喝茶',
|
|
283
|
+
petStopPreview: '停止试播',
|
|
284
|
+
petPreviewHint: '仅预览,不记喝水次数,也不更换页面角色。',
|
|
285
|
+
petPreviewLoading: '正在准备动作…',
|
|
286
|
+
petPreviewPlaying: '正在试播',
|
|
287
|
+
petPreviewUnsupported: '当前浏览器不支持透明动作视频,已显示静态预览。',
|
|
288
|
+
petPreviewUnavailable: '动作暂时无法播放,已恢复静态预览。可以重试。',
|
|
289
|
+
petPreviewEnable: '勾选“角色动作反馈”后可试播;应用后才保存。',
|
|
290
|
+
petPreviewReduced: '系统已开启减少动态效果,当前显示静态预览。',
|
|
269
291
|
},
|
|
270
292
|
en: {
|
|
271
293
|
petGreet: 'Interact with character',
|
|
294
|
+
petNeedsSession: 'Open a session',
|
|
295
|
+
petNeedsSetup: 'Set your schedule',
|
|
296
|
+
petLoadFailed: 'Settings unavailable',
|
|
297
|
+
petRetryHint: 'Retry loading settings from the context menu',
|
|
272
298
|
petCharacter: 'Character',
|
|
273
299
|
petWhaleActions: 'Blue whale · Animated long dress',
|
|
274
300
|
petWhaleOrnateActions: 'Blue whale · Animated short dress',
|
|
@@ -282,6 +308,23 @@ export const PET_MESSAGES = {
|
|
|
282
308
|
petHint: 'Click to interact · right-click for menu · drag to move',
|
|
283
309
|
petMode: 'Pet mode',
|
|
284
310
|
petClock: 'Shift countdown',
|
|
311
|
+
petPreviewActions: 'Preview actions',
|
|
312
|
+
petPreviewIdle: 'Idle',
|
|
313
|
+
petPreviewGreet: 'Wave',
|
|
314
|
+
petPreviewDrink: 'Drink',
|
|
315
|
+
petPreviewStop: 'Stop',
|
|
316
|
+
petTryIdle: 'Preview idle',
|
|
317
|
+
petTryGreet: 'Preview wave',
|
|
318
|
+
petTryDrink: 'Preview drinking',
|
|
319
|
+
petStopPreview: 'Stop preview',
|
|
320
|
+
petPreviewHint: 'Preview only: no drinking record or change to the page character.',
|
|
321
|
+
petPreviewLoading: 'Preparing action…',
|
|
322
|
+
petPreviewPlaying: 'Previewing',
|
|
323
|
+
petPreviewUnsupported:
|
|
324
|
+
'This browser cannot show transparent action videos. Showing a static preview.',
|
|
325
|
+
petPreviewUnavailable: 'Action unavailable. Static preview restored; you can retry.',
|
|
326
|
+
petPreviewEnable: 'Enable character reactions to preview. Apply to save the choice.',
|
|
327
|
+
petPreviewReduced: 'Reduced motion is enabled. Showing a static preview.',
|
|
285
328
|
},
|
|
286
329
|
};
|
|
287
330
|
|
|
@@ -291,8 +334,9 @@ export const PET_STYLE = `
|
|
|
291
334
|
.owc-v2 button.owc-pet-handle{padding:0;background:none!important;border-radius:50%;min-height:0;display:block;cursor:grab;touch-action:none;line-height:0}
|
|
292
335
|
.owc-v2 button.owc-pet-handle:active{cursor:grabbing}
|
|
293
336
|
.owc-pet-art{overflow:visible;pointer-events:none;filter:drop-shadow(0 3px 5px #32557418)}
|
|
294
|
-
.owc-pet-video-art{display:block;position:relative}
|
|
337
|
+
.owc-pet-video-art{display:block;position:relative;isolation:isolate}
|
|
295
338
|
.owc-pet-video-poster,.owc-pet-video-layer,.owc-pet-video-layer video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}
|
|
339
|
+
.owc-pet-video-poster,.owc-pet-video-layer video{mix-blend-mode:plus-lighter}
|
|
296
340
|
.owc-pet-video-poster{opacity:1;transition:opacity .12s linear}
|
|
297
341
|
.owc-pet-video-layer video{opacity:0;transition:opacity .12s linear}
|
|
298
342
|
.owc-pet-video-art[data-video-playing=true] .owc-pet-video-poster{opacity:0}
|
|
@@ -309,6 +353,27 @@ export const PET_STYLE = `
|
|
|
309
353
|
.owc-v2 .owc-pet-clock{display:flex;align-items:center;color:var(--owc-text);line-height:1.5;cursor:default;padding:4px 10px;min-height:26px;gap:5px;white-space:nowrap;border-radius:999px;font-size:11px;background:var(--dsw-alias-bg-overlay);border:1px solid color-mix(in srgb,var(--owc-accent) 25%,transparent);box-shadow:0 3px 12px #142e4812;font-variant-numeric:tabular-nums;max-width:100%;box-sizing:border-box;overflow:hidden}
|
|
310
354
|
.owc-pet-clock-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
311
355
|
.owc-pet-clock strong{flex:none}
|
|
356
|
+
.owc-v2 .owc-pet-timers{width:196px;max-width:100%;margin-top:4px;box-sizing:border-box;border:1px solid var(--owc-border);border-radius:13px;background:var(--owc-surface);box-shadow:var(--owc-shadow);overflow:hidden;font-variant-numeric:tabular-nums}
|
|
357
|
+
.owc-v2 .owc-pet-timers .owc-pet-clock{width:100%;height:34px;min-height:0;padding:5px 10px;gap:6px;border:0;border-radius:0;background:transparent;box-shadow:none;font-size:10px;color:var(--owc-muted)}
|
|
358
|
+
.owc-pet-timers .owc-pet-clock svg{flex-shrink:0}
|
|
359
|
+
.owc-pet-timers .owc-pet-clock strong{margin-left:auto;font-size:16px;line-height:1;color:var(--owc-text);letter-spacing:.2px}
|
|
360
|
+
.owc-pet-timer-pair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-top:1px solid var(--owc-border)}
|
|
361
|
+
.owc-v2 .owc-pet-timer-row{display:flex;align-items:center;justify-content:center;gap:5px;box-sizing:border-box;min-width:0;height:28px;padding:3px 6px;font-size:11px;white-space:nowrap;color:var(--owc-muted)}
|
|
362
|
+
.owc-pet-timer-row+.owc-pet-timer-row{border-left:1px solid var(--owc-border)}
|
|
363
|
+
.owc-pet-timer-label{display:flex;align-items:center;flex-shrink:0}
|
|
364
|
+
.owc-pet-timer-label svg{flex-shrink:0}
|
|
365
|
+
.owc-pet-timer-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:550;color:var(--owc-text)}
|
|
366
|
+
.owc-timer-pause{font-size:9px;margin-right:3px;color:var(--owc-muted)}
|
|
367
|
+
.owc-v2 .owc-pet-timer-row[data-countdown-due=true]{background:color-mix(in srgb,var(--owc-accent) 8%,transparent);color:color-mix(in srgb,var(--owc-accent) 55%,var(--owc-text))}
|
|
368
|
+
.owc-pet-timer-row[data-countdown-due=true] strong{color:inherit}
|
|
369
|
+
.owc-v2 [data-countdown-due=true]{color:var(--owc-accent);border-color:var(--owc-accent)}
|
|
370
|
+
.owc-countdown-card{padding:16px;display:flex;flex-direction:column;gap:14px}
|
|
371
|
+
.owc-countdown-value{font-size:32px;line-height:1.3;font-weight:650;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
|
372
|
+
.owc-countdown-actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
373
|
+
.owc-countdown-actions button{flex:1;min-width:110px;min-height:40px}
|
|
374
|
+
.owc-countdown-card label{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}
|
|
375
|
+
.owc-v2 .owc-countdown-card select{flex:0 0 120px;width:120px;max-width:120px}
|
|
376
|
+
.owc-countdown-card p{margin:0;font-size:12px;line-height:1.6;overflow-wrap:anywhere}
|
|
312
377
|
.owc-pet-panel{position:fixed;border-radius:20px;cursor:default;touch-action:manipulation;max-width:calc(100vw - 24px)}
|
|
313
378
|
.owc-pet-panel>.owc-content{max-height:inherit}
|
|
314
379
|
.owc-pet-panel .owc-glass{opacity:max(var(--owc-alpha),.94)}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { classicClockPresentation } from '../ui-utils.js';
|
|
4
|
+
import { calculate } from '../clock.js';
|
|
5
|
+
const settings = {
|
|
6
|
+
payMode: 'monthly',
|
|
7
|
+
monthlySalary: 10000,
|
|
8
|
+
workdaysPerMonth: 21.75,
|
|
9
|
+
dailySalary: 0,
|
|
10
|
+
start: '09:00',
|
|
11
|
+
end: '18:00',
|
|
12
|
+
breakStart: '12:00',
|
|
13
|
+
breakEnd: '13:00',
|
|
14
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
15
|
+
timezone: 'Asia/Shanghai',
|
|
16
|
+
};
|
|
17
|
+
const present = (time, config = settings) => {
|
|
18
|
+
const now = Date.parse(time);
|
|
19
|
+
return classicClockPresentation({
|
|
20
|
+
hasSession: true,
|
|
21
|
+
loaded: true,
|
|
22
|
+
configured: true,
|
|
23
|
+
failed: false,
|
|
24
|
+
state: calculate(config, now),
|
|
25
|
+
now,
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
test('classic overview distinguishes startup, missing configuration and failed reads without a zero countdown', () => {
|
|
29
|
+
const base = {
|
|
30
|
+
hasSession: true,
|
|
31
|
+
loaded: true,
|
|
32
|
+
configured: true,
|
|
33
|
+
failed: false,
|
|
34
|
+
state: null,
|
|
35
|
+
now: 0,
|
|
36
|
+
};
|
|
37
|
+
for (const [patch, phase] of [
|
|
38
|
+
[{ hasSession: false }, 'waiting'],
|
|
39
|
+
[{ loaded: false }, 'loading'],
|
|
40
|
+
[{ configured: false }, 'setup'],
|
|
41
|
+
[{ failed: true }, 'error'],
|
|
42
|
+
]) {
|
|
43
|
+
const view = classicClockPresentation({ ...base, ...patch });
|
|
44
|
+
assert.equal(view.phase, phase);
|
|
45
|
+
assert.equal(view.seconds, null);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
test('classic overview shows the correct countdown target for before, working and lunch', () => {
|
|
49
|
+
assert.equal(present('2026-11-05T00:00:00Z').seconds, 3600);
|
|
50
|
+
const working = present('2026-11-05T02:00:00Z');
|
|
51
|
+
assert.equal(working.phase, 'working');
|
|
52
|
+
assert.equal(working.seconds, 8 * 3600);
|
|
53
|
+
const lunch = present('2026-11-05T04:30:00Z');
|
|
54
|
+
assert.equal(lunch.phase, 'lunch');
|
|
55
|
+
assert.equal(lunch.seconds, 1800);
|
|
56
|
+
});
|
|
57
|
+
test('completed shifts and rest days show a state instead of a meaningless zero', () => {
|
|
58
|
+
for (const [date, phase] of [
|
|
59
|
+
['2026-11-05T11:00:00Z', 'done'],
|
|
60
|
+
['2026-11-07T02:00:00Z', 'rest'],
|
|
61
|
+
]) {
|
|
62
|
+
const result = present(date);
|
|
63
|
+
assert.equal(result.phase, phase);
|
|
64
|
+
assert.equal(result.seconds, null);
|
|
65
|
+
}
|
|
66
|
+
const night = {
|
|
67
|
+
...settings,
|
|
68
|
+
start: '22:00',
|
|
69
|
+
end: '06:00',
|
|
70
|
+
breakStart: '02:00',
|
|
71
|
+
breakEnd: '02:00',
|
|
72
|
+
};
|
|
73
|
+
assert.equal(present('2026-11-05T15:00:00Z', night).seconds, 7 * 3600);
|
|
74
|
+
const completed = present('2026-11-05T23:00:00Z', night);
|
|
75
|
+
assert.equal(completed.phase, 'done');
|
|
76
|
+
assert.equal(completed.seconds, null);
|
|
77
|
+
});
|