@clowder-ai/companion 0.1.0-alpha.3 → 0.1.0-alpha.31

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.
Files changed (70) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +54 -5
  3. package/manifest.json +3 -3
  4. package/package.json +5 -3
  5. package/plugin.yaml +3 -3
  6. package/renderer/app-assist-controller.mjs +125 -0
  7. package/renderer/call-controls-presentation.mjs +179 -0
  8. package/renderer/call-duration.mjs +59 -0
  9. package/renderer/call-transcript-view.mjs +240 -0
  10. package/renderer/chat-unread.mjs +44 -0
  11. package/renderer/client.mjs +75 -3
  12. package/renderer/compact-size.mjs +8 -0
  13. package/renderer/companion-identity.mjs +42 -0
  14. package/renderer/conversation-draft.mjs +304 -0
  15. package/renderer/conversation-history.mjs +176 -0
  16. package/renderer/conversation-surface.mjs +201 -0
  17. package/renderer/conversation.mjs +155 -21
  18. package/renderer/decision-panel.mjs +122 -0
  19. package/renderer/decision-view.mjs +124 -0
  20. package/renderer/errors.mjs +4 -4
  21. package/renderer/index.html +202 -25
  22. package/renderer/living-body.mjs +147 -0
  23. package/renderer/living-edge.mjs +16 -0
  24. package/renderer/living-geometry.mjs +96 -0
  25. package/renderer/meeting-state.mjs +55 -0
  26. package/renderer/motion-facts.mjs +108 -0
  27. package/renderer/native-window-travel.mjs +89 -0
  28. package/renderer/native-work-motion.mjs +39 -0
  29. package/renderer/peer.mjs +1 -1
  30. package/renderer/pet-controls.mjs +139 -27
  31. package/renderer/pet-motion-assets.mjs +22 -0
  32. package/renderer/pet-motion.mjs +343 -0
  33. package/renderer/recent-bubble.mjs +93 -0
  34. package/renderer/reply-letter-controller.mjs +361 -0
  35. package/renderer/reply-letter-state.mjs +38 -0
  36. package/renderer/reply-letter-view.mjs +116 -0
  37. package/renderer/settings-controller.mjs +171 -0
  38. package/renderer/settings-view.mjs +241 -0
  39. package/renderer/skins/xianxian-codex.webp +0 -0
  40. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  41. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  42. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  43. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  44. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  45. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  46. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  47. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  48. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  49. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  53. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  54. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  55. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  56. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  57. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  58. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  59. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  60. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  61. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  62. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  63. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  64. package/renderer/skins/xianxian-working-row.png +0 -0
  65. package/renderer/style.css +325 -27
  66. package/renderer/surface.mjs +308 -51
  67. package/renderer/transcript-view.mjs +117 -15
  68. package/renderer/visible-text-receipt.mjs +241 -0
  69. package/renderer/work-overview.mjs +95 -0
  70. package/source-lock.json +122 -2
package/NOTICE CHANGED
@@ -1,7 +1,12 @@
1
- Companion imagery and theme tokens originate in Clowder AI (MIT).
2
- See source-lock.json for the exact public source revision and content hashes.
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.1.0**. The Host computes
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. Older 1.0-only
23
- Hosts reject this manifest before installation. Media admission and capture
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 original animated skin bytes remain unchanged. Public static skin derivatives
28
- and the shared color tokens retain their exact provenance in `source-lock.json`.
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",
3
+ "version": "0.1.0-alpha.31",
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.1.0",
40
+ "bridgeVersion": "1.6.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
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",
3
+ "version": "0.1.0-alpha.31",
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.17",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.13"
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",
3
+ "version": "0.1.0-alpha.31",
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.1.0",
40
+ "bridgeVersion": "1.6.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
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 };