@clowder-ai/companion 0.1.0-alpha.3 → 0.1.0-alpha.30
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/NOTICE +7 -2
- package/README.md +54 -5
- package/manifest.json +3 -3
- package/package.json +5 -3
- package/plugin.yaml +3 -3
- package/renderer/app-assist-controller.mjs +125 -0
- package/renderer/call-controls-presentation.mjs +179 -0
- package/renderer/call-duration.mjs +59 -0
- package/renderer/call-transcript-view.mjs +240 -0
- package/renderer/chat-unread.mjs +44 -0
- package/renderer/client.mjs +75 -3
- package/renderer/compact-size.mjs +8 -0
- package/renderer/companion-identity.mjs +42 -0
- package/renderer/conversation-draft.mjs +304 -0
- package/renderer/conversation-history.mjs +176 -0
- package/renderer/conversation-surface.mjs +202 -0
- package/renderer/conversation.mjs +155 -21
- package/renderer/decision-panel.mjs +122 -0
- package/renderer/decision-view.mjs +124 -0
- package/renderer/errors.mjs +4 -4
- package/renderer/index.html +202 -25
- package/renderer/living-body.mjs +147 -0
- package/renderer/living-edge.mjs +16 -0
- package/renderer/living-geometry.mjs +96 -0
- package/renderer/meeting-state.mjs +55 -0
- package/renderer/motion-facts.mjs +108 -0
- package/renderer/native-window-travel.mjs +89 -0
- package/renderer/native-work-motion.mjs +39 -0
- package/renderer/peer.mjs +1 -1
- package/renderer/pet-controls.mjs +139 -27
- package/renderer/pet-motion-assets.mjs +22 -0
- package/renderer/pet-motion.mjs +343 -0
- package/renderer/recent-bubble.mjs +93 -0
- package/renderer/reply-letter-controller.mjs +361 -0
- package/renderer/reply-letter-state.mjs +38 -0
- package/renderer/reply-letter-view.mjs +116 -0
- package/renderer/settings-controller.mjs +171 -0
- package/renderer/settings-view.mjs +241 -0
- package/renderer/skins/xianxian-codex.webp +0 -0
- package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
- package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
- package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
- package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
- package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
- package/renderer/skins/xianxian-living/clips/think.webm +0 -0
- package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
- package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
- package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
- package/renderer/skins/xianxian-living/layers/body.webp +0 -0
- package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
- package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
- package/renderer/skins/xianxian-living/layers/head.webp +0 -0
- package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
- package/renderer/skins/xianxian-living/layers/meta.json +88 -0
- package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
- package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
- package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
- package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
- package/renderer/skins/xianxian-sleeping-row.png +0 -0
- package/renderer/skins/xianxian-staged-thought-row.png +0 -0
- package/renderer/skins/xianxian-working-row.png +0 -0
- package/renderer/style.css +325 -27
- package/renderer/surface.mjs +309 -51
- package/renderer/transcript-view.mjs +117 -15
- package/renderer/visible-text-receipt.mjs +241 -0
- package/renderer/work-overview.mjs +95 -0
- package/source-lock.json +122 -2
package/NOTICE
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
See source-lock.json for
|
|
1
|
+
The original companion atlases and theme tokens originate in Clowder AI (MIT).
|
|
2
|
+
See source-lock.json for their exact public source revision and content hashes.
|
|
3
3
|
The legacy ragdoll-v1 and yarn-ball stills are AI-assisted derivatives of the
|
|
4
4
|
listed public reference, restoring the complete cat silhouette and transparent
|
|
5
5
|
background. Their reference and resulting hashes are recorded separately.
|
|
6
|
+
The living Xianxian layers and clips are F317 assets: CVO-generated source video,
|
|
7
|
+
keyframes by codex-astra, and deterministic extraction/alignment by opus55.
|
|
8
|
+
source-lock.json pins the 20 bundled distributable files, their geometry file,
|
|
9
|
+
and the source package manifest digest. Source video and private workspace paths
|
|
10
|
+
are not bundled.
|
|
6
11
|
The conversation transport and controls continue the F317 companion implementation
|
|
7
12
|
by the Clowder AI contributors. No private runtime data or pet metadata is included.
|
package/README.md
CHANGED
|
@@ -10,22 +10,71 @@ visible and ends the call in one click. Screen sharing has its own explicit
|
|
|
10
10
|
picker and termination badge. There is no microphone action on a double-click,
|
|
11
11
|
page load, body tap or drag.
|
|
12
12
|
|
|
13
|
+
When a compatible Host advertises receive-only audio, **只听** opens the same
|
|
14
|
+
conversation with playback but never requests or toggles a microphone. Older
|
|
15
|
+
Hosts omit that capability, so the control stays hidden and ordinary voice keeps
|
|
16
|
+
the exact legacy connect request. Text remains available independently.
|
|
17
|
+
|
|
13
18
|
Typing works with voice off. The Host uses the existing owner conversation,
|
|
14
19
|
configured duty cat, ordinary message routing and idempotency. The small history
|
|
15
20
|
view reads a bounded recent subset; **完整聊天** opens the canonical conversation.
|
|
16
21
|
No second transcript database, credentials or selectable Host identity live here.
|
|
22
|
+
When the Host supplies a valid per-message identity snapshot, each history row
|
|
23
|
+
keeps its real author while separately showing the partner, Live carrier and
|
|
24
|
+
deep cat saved with that message. Legacy rows omit this context instead of
|
|
25
|
+
borrowing today's selected companion.
|
|
17
26
|
|
|
18
27
|
## Host integration
|
|
19
28
|
|
|
20
|
-
This surface requires the additive desktop bridge **1.
|
|
29
|
+
This surface requires the additive desktop bridge **1.6.0**. The Host computes
|
|
21
30
|
placement within the display work area, keeps the cat as its stable anchor, and
|
|
22
|
-
owns bounded drag gestures and transparent-area hit testing.
|
|
23
|
-
|
|
31
|
+
owns bounded drag gestures and transparent-area hit testing. Hosts below 1.6
|
|
32
|
+
reject this manifest before installation. Media admission and capture
|
|
24
33
|
remain in the trusted Host; this package receives neither streams nor provider
|
|
25
34
|
SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
|
|
35
|
+
Bridge 1.4 adds one untrusted first-visible-subtitle observation containing only
|
|
36
|
+
an epoch-millisecond time, the current call fence and a Host-correlatable source
|
|
37
|
+
reference. It does not claim compositor paint, an unoccluded window or reading.
|
|
38
|
+
Message and native-item references use the same bounded, single-line opaque ID
|
|
39
|
+
contract as the transcript surfaces that supply them. A Host
|
|
40
|
+
`invalid_request` rejects and remembers that candidate for the current call so
|
|
41
|
+
another current reference may be observed without retrying the rejected one.
|
|
42
|
+
Other failures retry after 1 and 2 seconds, then leave the call's
|
|
43
|
+
first-visible result unknown; later text never backfills a manufactured success.
|
|
44
|
+
Bridge 1.5 adds Host-bound conversation pages and versioned body continuations.
|
|
45
|
+
The renderer can only return opaque cursors issued by the Host; it cannot select
|
|
46
|
+
an owner, thread, message, call or byte offset. History progress, response phase
|
|
47
|
+
and body completeness remain separate, so a processing draft or truncated body
|
|
48
|
+
cannot masquerade as a completed reply.
|
|
49
|
+
|
|
50
|
+
It also binds the one-line composer to the Host-owned durable draft. Versioned
|
|
51
|
+
text submission carries the Host-issued conversation `scopeRef`, draft revision
|
|
52
|
+
and caller message identity. An unknown settlement is read back but never
|
|
53
|
+
automatically resent; an explicit retry keeps the original text, identity,
|
|
54
|
+
revision and route. If the canonical thread changes, the old unconfirmed input
|
|
55
|
+
stays visible as invalidated local text and is never replayed into the new scope.
|
|
56
|
+
The legacy text and conversation methods remain unchanged for older pairs.
|
|
57
|
+
|
|
58
|
+
Bridge 1.6 separates passive call speech from the bounded nameplate and control
|
|
59
|
+
panel, and adds an optional application-assistance projection. Assistance is
|
|
60
|
+
off by default; settling a prompt only opens the ordinary menu and grants no
|
|
61
|
+
media, screen-selection or call authority.
|
|
62
|
+
|
|
63
|
+
Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
|
|
64
|
+
facts with task, result and event identities select tool work, workspace fetch,
|
|
65
|
+
screen reading and applied-result delivery. Native travel is independent: the
|
|
66
|
+
renderer observes the actual desktop window coordinates, so a running clip can
|
|
67
|
+
appear only after the Host window really moves. Dragging suppresses that
|
|
68
|
+
observer and freezes a visibly held candidate instead of renaming the running
|
|
69
|
+
clip. Pounce requires an explicit play gesture by default; optional automatic
|
|
70
|
+
play is bounded by idle and cooldown timers. Reduced motion keeps semantic
|
|
71
|
+
poses static; a compatible Host also revokes its native movement lease.
|
|
72
|
+
Other cats retain their own skins. The v3 living assets and their hashes are
|
|
73
|
+
pinned in `source-lock.json`; the installable renderer carries all 21 assets
|
|
74
|
+
itself, including the mid-edge `peek_fade` variant.
|
|
26
75
|
|
|
27
|
-
The
|
|
28
|
-
|
|
76
|
+
The Host keeps a small transparent margin left of the pet hit target so the
|
|
77
|
+
pounce's tail is visible without turning empty space into a click target.
|
|
29
78
|
|
|
30
79
|
## Verification
|
|
31
80
|
|
package/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.6.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-dR1cqLYEPsY28TJ6ureljiV5CP23wubn70G7pePPkNg="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clowder-ai/companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"description": "The desktop voice body for your Clowder companion",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "node scripts/build.mjs",
|
|
23
23
|
"test": "node --test test/*.test.mjs",
|
|
24
|
+
"test:browser": "pnpm build && node --test test/*.browser.mjs",
|
|
24
25
|
"typecheck": "node scripts/check.mjs",
|
|
25
26
|
"lint": "node scripts/check.mjs",
|
|
26
27
|
"prepack": "pnpm build"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@clowder-ai/plugin-contract": "0.1.0-beta.
|
|
30
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
30
|
+
"@clowder-ai/plugin-contract": "0.1.0-beta.29",
|
|
31
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.25",
|
|
32
|
+
"playwright": "1.61.1"
|
|
31
33
|
},
|
|
32
34
|
"repository": {
|
|
33
35
|
"type": "git",
|
package/plugin.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.6.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-dR1cqLYEPsY28TJ6ureljiV5CP23wubn70G7pePPkNg="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const ACTIONS = new Set(['dismiss', 'timeout', 'accept']);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renderer projection only. The Host owns offer creation, expiry and clearing;
|
|
5
|
+
* this controller merely keeps stale reads and replies from reviving UI or
|
|
6
|
+
* turning an old accept into a new action.
|
|
7
|
+
*/
|
|
8
|
+
export class AppAssistController {
|
|
9
|
+
constructor({
|
|
10
|
+
client,
|
|
11
|
+
changed = () => {},
|
|
12
|
+
accepted = () => {},
|
|
13
|
+
scheduleTimeout = setTimeout,
|
|
14
|
+
cancelTimeout = clearTimeout,
|
|
15
|
+
timeoutMs = 15_000,
|
|
16
|
+
}) {
|
|
17
|
+
this.client = client;
|
|
18
|
+
this.changed = changed;
|
|
19
|
+
this.accepted = accepted;
|
|
20
|
+
this.scheduleTimeout = scheduleTimeout;
|
|
21
|
+
this.cancelTimeout = cancelTimeout;
|
|
22
|
+
this.timeoutMs = timeoutMs;
|
|
23
|
+
this.operation = 0;
|
|
24
|
+
this.timer = null;
|
|
25
|
+
this.timerOfferRef = null;
|
|
26
|
+
this.state = {
|
|
27
|
+
scopeRef: null,
|
|
28
|
+
revision: -1,
|
|
29
|
+
status: null,
|
|
30
|
+
offer: null,
|
|
31
|
+
hiddenRef: null,
|
|
32
|
+
pending: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
publish(patch) {
|
|
37
|
+
this.state = { ...this.state, ...patch };
|
|
38
|
+
this.changed(this.state);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clearTimer() {
|
|
42
|
+
if (this.timer !== null) this.cancelTimeout(this.timer);
|
|
43
|
+
this.timer = null;
|
|
44
|
+
this.timerOfferRef = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
armTimer(offerRef) {
|
|
48
|
+
if (this.timer !== null && this.timerOfferRef === offerRef) return;
|
|
49
|
+
this.clearTimer();
|
|
50
|
+
this.timerOfferRef = offerRef;
|
|
51
|
+
this.timer = this.scheduleTimeout(() => {
|
|
52
|
+
this.timer = null;
|
|
53
|
+
this.timerOfferRef = null;
|
|
54
|
+
if (this.state.offer?.offerRef === offerRef) void this.settle('timeout');
|
|
55
|
+
}, this.timeoutMs);
|
|
56
|
+
this.timer?.unref?.();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
consume(snapshot) {
|
|
60
|
+
if (!snapshot || typeof snapshot.scopeRef !== 'string' || !Number.isSafeInteger(snapshot.revision)) return false;
|
|
61
|
+
if (this.state.scopeRef !== null && snapshot.scopeRef !== this.state.scopeRef) return false;
|
|
62
|
+
if (snapshot.revision <= this.state.revision) return false;
|
|
63
|
+
|
|
64
|
+
const scopeRef = this.state.scopeRef ?? snapshot.scopeRef;
|
|
65
|
+
const incomingRef = snapshot.offer?.offerRef ?? null;
|
|
66
|
+
const sameHiddenOffer = incomingRef !== null && incomingRef === this.state.hiddenRef;
|
|
67
|
+
const hiddenRef = incomingRef === null || (this.state.hiddenRef && incomingRef !== this.state.hiddenRef)
|
|
68
|
+
? null
|
|
69
|
+
: this.state.hiddenRef;
|
|
70
|
+
const offer = sameHiddenOffer ? null : snapshot.offer;
|
|
71
|
+
const pending = incomingRef && this.state.pending?.offerRef !== incomingRef ? null : this.state.pending;
|
|
72
|
+
this.publish({ scopeRef, revision: snapshot.revision, status: snapshot.status, offer, hiddenRef, pending });
|
|
73
|
+
if (offer) this.armTimer(offer.offerRef);
|
|
74
|
+
else this.clearTimer();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async settle(action) {
|
|
79
|
+
if (!ACTIONS.has(action) || !this.state.offer || !this.state.scopeRef) return false;
|
|
80
|
+
const pending = {
|
|
81
|
+
id: ++this.operation,
|
|
82
|
+
scopeRef: this.state.scopeRef,
|
|
83
|
+
revision: this.state.revision,
|
|
84
|
+
offerRef: this.state.offer.offerRef,
|
|
85
|
+
action,
|
|
86
|
+
};
|
|
87
|
+
this.clearTimer();
|
|
88
|
+
this.publish({ offer: null, hiddenRef: pending.offerRef, pending });
|
|
89
|
+
|
|
90
|
+
let reply;
|
|
91
|
+
try {
|
|
92
|
+
reply = await this.client.settleAppAssist(pending.scopeRef, pending.offerRef, action);
|
|
93
|
+
} catch {
|
|
94
|
+
if (this.state.pending?.id === pending.id) this.publish({ pending: null });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (this.state.pending?.id !== pending.id) return false;
|
|
98
|
+
const matching = reply?.kind === 'app-assist-settlement'
|
|
99
|
+
&& reply.scopeRef === pending.scopeRef
|
|
100
|
+
&& reply.offerRef === pending.offerRef
|
|
101
|
+
&& reply.action === pending.action
|
|
102
|
+
&& Number.isSafeInteger(reply.revision)
|
|
103
|
+
&& reply.revision > pending.revision
|
|
104
|
+
&& reply.revision >= this.state.revision
|
|
105
|
+
&& this.state.status === 'ready';
|
|
106
|
+
if (!matching || reply.outcome !== 'settled') {
|
|
107
|
+
this.publish({ pending: null });
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.publish({ revision: reply.revision, hiddenRef: null, pending: null });
|
|
112
|
+
if (action === 'accept') this.accepted({
|
|
113
|
+
scopeRef: reply.scopeRef,
|
|
114
|
+
revision: reply.revision,
|
|
115
|
+
offerRef: reply.offerRef,
|
|
116
|
+
});
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
close() {
|
|
121
|
+
this.operation++;
|
|
122
|
+
this.clearTimer();
|
|
123
|
+
this.state = { ...this.state, offer: null, pending: null };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Presentation-only state for the active call surface. Media remains owned by explicit actions. */
|
|
2
|
+
export class CallControlsPresentation {
|
|
3
|
+
constructor({
|
|
4
|
+
document,
|
|
5
|
+
root,
|
|
6
|
+
pet,
|
|
7
|
+
input,
|
|
8
|
+
composers = [],
|
|
9
|
+
send = async () => false,
|
|
10
|
+
accepted = () => {},
|
|
11
|
+
changed = () => {},
|
|
12
|
+
collapseDelay = 1800,
|
|
13
|
+
schedule = (callback, delay) => setTimeout(callback, delay),
|
|
14
|
+
cancel = handle => clearTimeout(handle),
|
|
15
|
+
}) {
|
|
16
|
+
this.document = document;
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.pet = pet;
|
|
19
|
+
this.input = input;
|
|
20
|
+
this.composers = composers;
|
|
21
|
+
this.send = send;
|
|
22
|
+
this.accepted = accepted;
|
|
23
|
+
this.changed = changed;
|
|
24
|
+
this.collapseDelay = collapseDelay;
|
|
25
|
+
this.schedule = schedule;
|
|
26
|
+
this.cancel = cancel;
|
|
27
|
+
this.presentation = 'lite';
|
|
28
|
+
this.panel = 'none';
|
|
29
|
+
this.active = false;
|
|
30
|
+
this.mediaLocked = false;
|
|
31
|
+
this.petHovered = false;
|
|
32
|
+
this.controlsHovered = false;
|
|
33
|
+
this.timer = undefined;
|
|
34
|
+
|
|
35
|
+
this.onPetEnter = () => {
|
|
36
|
+
this.petHovered = true;
|
|
37
|
+
if (this.active) this.reveal();
|
|
38
|
+
};
|
|
39
|
+
this.onPetLeave = () => {
|
|
40
|
+
this.petHovered = false;
|
|
41
|
+
this.armCollapse();
|
|
42
|
+
};
|
|
43
|
+
this.onControlsEnter = () => {
|
|
44
|
+
this.controlsHovered = true;
|
|
45
|
+
this.clearTimer();
|
|
46
|
+
};
|
|
47
|
+
this.onControlsLeave = () => {
|
|
48
|
+
this.controlsHovered = false;
|
|
49
|
+
this.armCollapse();
|
|
50
|
+
};
|
|
51
|
+
this.onFocusIn = () => this.clearTimer();
|
|
52
|
+
this.onFocusOut = () => queueMicrotask(() => this.armCollapse());
|
|
53
|
+
this.onInput = () => this.reevaluate();
|
|
54
|
+
|
|
55
|
+
root.dataset.presentation = this.presentation;
|
|
56
|
+
pet.addEventListener('pointerenter', this.onPetEnter);
|
|
57
|
+
pet.addEventListener('pointerleave', this.onPetLeave);
|
|
58
|
+
root.addEventListener('pointerenter', this.onControlsEnter);
|
|
59
|
+
root.addEventListener('pointerleave', this.onControlsLeave);
|
|
60
|
+
root.addEventListener('focusin', this.onFocusIn);
|
|
61
|
+
root.addEventListener('focusout', this.onFocusOut);
|
|
62
|
+
input.addEventListener('input', this.onInput);
|
|
63
|
+
for (const composer of composers) {
|
|
64
|
+
composer.submit = event => this.submit(event, composer.input);
|
|
65
|
+
composer.form.onsubmit = composer.submit;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setComposerState(identity, connecting) {
|
|
70
|
+
for (const { form, input } of this.composers) {
|
|
71
|
+
input.placeholder = identity ? `发消息给${identity.displayName}…` : '发消息给猫猫…';
|
|
72
|
+
form.querySelector('button').disabled = !identity || connecting;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async submit(event, input) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
const text = input.value;
|
|
79
|
+
if (!await this.send(text)) return;
|
|
80
|
+
if (input.value === text) input.value = '';
|
|
81
|
+
this.reevaluate();
|
|
82
|
+
this.accepted();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setActive(active) {
|
|
86
|
+
if (this.active === active) return;
|
|
87
|
+
this.active = active;
|
|
88
|
+
this.clearTimer();
|
|
89
|
+
if (!active) {
|
|
90
|
+
this.mediaLocked = false;
|
|
91
|
+
this.setPresentation('lite');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.setPresentation(this.mediaLocked ? 'controls' : 'lite');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setPanel(panel) {
|
|
98
|
+
this.panel = panel;
|
|
99
|
+
if (panel === 'actions') this.armCollapse();
|
|
100
|
+
else this.clearTimer();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setMediaLocked(locked) {
|
|
104
|
+
this.mediaLocked = locked;
|
|
105
|
+
if (!this.active) return;
|
|
106
|
+
if (locked) this.reveal();
|
|
107
|
+
else this.armCollapse();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
activate() {
|
|
111
|
+
if (!this.active) return false;
|
|
112
|
+
this.reveal();
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
escape(panel) {
|
|
117
|
+
if (!this.active || panel !== 'actions') return false;
|
|
118
|
+
this.clearTimer();
|
|
119
|
+
if (!this.mediaLocked) this.setPresentation('lite');
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
reveal() {
|
|
124
|
+
if (!this.active) return;
|
|
125
|
+
this.clearTimer();
|
|
126
|
+
this.setPresentation('controls');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reevaluate() {
|
|
130
|
+
if (this.canAutoCollapse()) this.armCollapse();
|
|
131
|
+
else this.clearTimer();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
armCollapse() {
|
|
135
|
+
this.clearTimer();
|
|
136
|
+
if (!this.canAutoCollapse()) return;
|
|
137
|
+
this.timer = this.schedule(() => {
|
|
138
|
+
this.timer = undefined;
|
|
139
|
+
if (this.canAutoCollapse()) this.setPresentation('lite');
|
|
140
|
+
}, this.collapseDelay);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
canAutoCollapse() {
|
|
144
|
+
return this.active
|
|
145
|
+
&& this.presentation === 'controls'
|
|
146
|
+
&& !this.mediaLocked
|
|
147
|
+
&& !this.petHovered
|
|
148
|
+
&& !this.controlsHovered
|
|
149
|
+
&& !this.root.contains(this.document.activeElement)
|
|
150
|
+
&& this.input.value.trim() === '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setPresentation(next) {
|
|
154
|
+
if (this.presentation === next) return;
|
|
155
|
+
this.presentation = next;
|
|
156
|
+
this.root.dataset.presentation = next;
|
|
157
|
+
this.changed(next);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
clearTimer() {
|
|
161
|
+
if (this.timer === undefined) return;
|
|
162
|
+
this.cancel(this.timer);
|
|
163
|
+
this.timer = undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close() {
|
|
167
|
+
this.clearTimer();
|
|
168
|
+
this.pet.removeEventListener('pointerenter', this.onPetEnter);
|
|
169
|
+
this.pet.removeEventListener('pointerleave', this.onPetLeave);
|
|
170
|
+
this.root.removeEventListener('pointerenter', this.onControlsEnter);
|
|
171
|
+
this.root.removeEventListener('pointerleave', this.onControlsLeave);
|
|
172
|
+
this.root.removeEventListener('focusin', this.onFocusIn);
|
|
173
|
+
this.root.removeEventListener('focusout', this.onFocusOut);
|
|
174
|
+
this.input.removeEventListener('input', this.onInput);
|
|
175
|
+
for (const composer of this.composers) {
|
|
176
|
+
if (composer.form.onsubmit === composer.submit) composer.form.onsubmit = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function formatDuration(elapsedMs) {
|
|
2
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
3
|
+
const hours = Math.floor(seconds / 3600);
|
|
4
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
5
|
+
const remainder = seconds % 60;
|
|
6
|
+
const clock = `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
|
7
|
+
return hours ? `${hours}:${clock}` : clock;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Presentation clock anchored only after the current media connection is confirmed. */
|
|
11
|
+
export class CallDuration {
|
|
12
|
+
constructor(node, {
|
|
13
|
+
now = Date.now,
|
|
14
|
+
schedule = callback => {
|
|
15
|
+
const handle = setInterval(callback, 1000);
|
|
16
|
+
handle.unref?.();
|
|
17
|
+
return handle;
|
|
18
|
+
},
|
|
19
|
+
cancel = handle => clearInterval(handle),
|
|
20
|
+
} = {}) {
|
|
21
|
+
Object.assign(this, { node, now, schedule, cancel });
|
|
22
|
+
this.startedAt = null;
|
|
23
|
+
this.timer = undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
set(startedAt) {
|
|
27
|
+
startedAt = Number.isFinite(startedAt) ? startedAt : null;
|
|
28
|
+
if (startedAt === this.startedAt) {
|
|
29
|
+
if (startedAt !== null) this.render();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
this.clear();
|
|
33
|
+
this.startedAt = startedAt;
|
|
34
|
+
this.node.hidden = startedAt === null;
|
|
35
|
+
if (startedAt === null) {
|
|
36
|
+
this.node.textContent = '';
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.render();
|
|
40
|
+
this.timer = this.schedule(() => this.render());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render() {
|
|
44
|
+
if (this.startedAt === null) return;
|
|
45
|
+
this.node.textContent = formatDuration(this.now() - this.startedAt);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
clear() {
|
|
49
|
+
if (this.timer === undefined) return;
|
|
50
|
+
this.cancel(this.timer);
|
|
51
|
+
this.timer = undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
close() {
|
|
55
|
+
this.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { formatDuration };
|