heyquilt 0.3.121 → 0.3.138
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/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/ui/voice-call.js
CHANGED
|
@@ -2,25 +2,32 @@
|
|
|
2
2
|
// windows, one connection per person (WebRTC); the setup for each pair travels in presence
|
|
3
3
|
// through this app's server (POST /api/sessions/:id/voice, `voice` events). When you agreed to
|
|
4
4
|
// transcripts and the owner has them on, your own speech is cut into clips at pauses and sent
|
|
5
|
-
// to be written down.
|
|
6
|
-
|
|
5
|
+
// to be written down. A screen you share goes the same way, on a video line every connection opens
|
|
6
|
+
// from the start, so sharing needs no new call setup. The parts with no browser in them are in
|
|
7
|
+
// voice-model.js.
|
|
8
|
+
import { offersTo, newOfferId, nextStep, Segmenter, downsample, wavBytes, toBase64, rms, speakingNow, keepMissing, leftCall, peopleInCall, callSound } from './voice-model.js'
|
|
7
9
|
|
|
8
10
|
const GATHER_MS = 3000 // most addresses come in well under this; the rest aren't worth the wait
|
|
9
11
|
const MIC = { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false }
|
|
12
|
+
// A screen: text stays sharp at a low frame rate. The desktop app picks what is shared (desktop/screen-share.js).
|
|
13
|
+
const SCREEN = { video: { frameRate: { ideal: 15, max: 30 } }, audio: false }
|
|
10
14
|
|
|
11
15
|
export class VoiceCall {
|
|
12
16
|
/**
|
|
13
17
|
* `post(body)`: our part in the call to the app server, answers the call as it sees it.
|
|
14
18
|
* `ice()`: { iceServers }. `onChange()`: something shown changed (who speaks, muted, left).
|
|
15
|
-
* `
|
|
19
|
+
* `onPeople(sound)`: someone came into the call ('callIn') or left it ('callOut').
|
|
20
|
+
* `getStream`: the microphone (tests give a tone). `getScreen`: a screen to share (tests draw one).
|
|
16
21
|
*/
|
|
17
|
-
constructor ({ post, ice, clip, onChange = () => {}, getStream = () => navigator.mediaDevices.getUserMedia(MIC) }) {
|
|
18
|
-
Object.assign(this, { post, ice, clip, onChange, getStream })
|
|
22
|
+
constructor ({ post, ice, clip, onChange = () => {}, onPeople = () => {}, getStream = () => navigator.mediaDevices.getUserMedia(MIC), getScreen = () => navigator.mediaDevices.getDisplayMedia(SCREEN) }) {
|
|
23
|
+
Object.assign(this, { post, ice, clip, onChange, onPeople, getStream, getScreen })
|
|
24
|
+
this.people = new Set() // names in the call with us: in voice, or kept through a relay blip
|
|
19
25
|
this.in = false
|
|
20
26
|
this.muted = false
|
|
21
27
|
this.transcribing = false
|
|
22
28
|
this.me = 0
|
|
23
|
-
this.links = new Map() // peer id -> { pc, n, accepted, answered, failed, since, audio, analyser, name }
|
|
29
|
+
this.links = new Map() // peer id -> { pc, n, accepted, answered, failed, since, audio, analyser, name, screenTx, screen }
|
|
30
|
+
this.screen = null // the screen we share, while we do
|
|
24
31
|
this.sig = {} // peer id -> the signal we leave them
|
|
25
32
|
this.levels = new Map() // 'me' or peer id -> { speaking, at }
|
|
26
33
|
this.view = null
|
|
@@ -45,7 +52,7 @@ export class VoiceCall {
|
|
|
45
52
|
this.timer = setInterval(() => this.measure(), 100)
|
|
46
53
|
// An offer nobody answered is made again after a while: look every few seconds, not only on news.
|
|
47
54
|
this.retry = setInterval(() => { if (this.view) this.update(this.view) }, 5000)
|
|
48
|
-
this.update(await this.post({ muted: false, transcribing, sig: {} }))
|
|
55
|
+
this.update(await this.post({ muted: false, transcribing, sharing: false, sig: {} }))
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/** Leaves: every connection closes, the microphone stops, and presence says we're out. */
|
|
@@ -54,8 +61,11 @@ export class VoiceCall {
|
|
|
54
61
|
this.in = false
|
|
55
62
|
clearInterval(this.timer)
|
|
56
63
|
clearInterval(this.retry)
|
|
64
|
+
this.stopShare()
|
|
57
65
|
this.segmenter?.end()
|
|
58
66
|
for (const id of [...this.links.keys()]) this.drop(id)
|
|
67
|
+
this.people = new Set()
|
|
68
|
+
this.counted = false
|
|
59
69
|
this.stream?.getTracks().forEach((t) => t.stop())
|
|
60
70
|
this.worklet?.disconnect()
|
|
61
71
|
await this.ctx?.close().catch(() => {})
|
|
@@ -73,6 +83,35 @@ export class VoiceCall {
|
|
|
73
83
|
this.onChange()
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
get sharing () { return !!this.screen }
|
|
87
|
+
|
|
88
|
+
/** Shares a screen with everyone in the call: the system or the desktop app asks which. */
|
|
89
|
+
async share () {
|
|
90
|
+
if (!this.in || this.screen) return
|
|
91
|
+
const stream = await this.getScreen()
|
|
92
|
+
const track = stream.getVideoTracks()[0]
|
|
93
|
+
if (!track || !this.in || this.screen) { stream.getTracks().forEach((t) => t.stop()); return }
|
|
94
|
+
track.contentHint = 'detail'
|
|
95
|
+
// "Stop sharing" in the system's own bar, or the shared window closing, ends it here too.
|
|
96
|
+
track.addEventListener('ended', () => { if (this.screen === stream) this.stopShare() })
|
|
97
|
+
this.screen = stream
|
|
98
|
+
for (const l of this.links.values()) l.screenTx?.sender.replaceTrack(track).catch(() => {})
|
|
99
|
+
this.publish()
|
|
100
|
+
this.onChange()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stopShare () {
|
|
104
|
+
if (!this.screen) return
|
|
105
|
+
this.screen.getTracks().forEach((t) => t.stop())
|
|
106
|
+
this.screen = null
|
|
107
|
+
for (const l of this.links.values()) l.screenTx?.sender.replaceTrack(null).catch(() => {})
|
|
108
|
+
this.publish()
|
|
109
|
+
this.onChange()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The screen one person shares with us, once it arrives (they say they share in presence). */
|
|
113
|
+
screenOf (id) { return this.links.get(id)?.screen || null }
|
|
114
|
+
|
|
76
115
|
/** Transcripts on or off for our own speech (we agreed, and the owner has them on). */
|
|
77
116
|
async setTranscribing (on) {
|
|
78
117
|
this.transcribing = !!on
|
|
@@ -92,14 +131,22 @@ export class VoiceCall {
|
|
|
92
131
|
const here = new Set(view.peers.map((p) => p.id))
|
|
93
132
|
// Someone missing from presence is often a relay blip, not a hang-up: their connection is
|
|
94
133
|
// kept while it is up (keepMissing), and goes once it isn't or they've been gone too long.
|
|
134
|
+
// Someone still in the session but out of voice hung up: theirs goes at once.
|
|
95
135
|
const t = Date.now()
|
|
96
136
|
for (const [id, l] of [...this.links]) {
|
|
97
137
|
if (here.has(id)) { l.missingSince = 0; continue }
|
|
98
138
|
l.missingSince = l.missingSince || t
|
|
99
|
-
if (!keepMissing({ state: l.pc.connectionState, missingSince: l.missingSince, now: t })) this.drop(id)
|
|
139
|
+
if (leftCall({ id, here: view.here }) || !keepMissing({ state: l.pc.connectionState, missingSince: l.missingSince, now: t })) this.drop(id)
|
|
100
140
|
}
|
|
101
141
|
for (const id of Object.keys(this.sig)) if (!here.has(Number(id)) && !this.links.has(Number(id))) delete this.sig[id]
|
|
102
142
|
for (const p of view.peers) this.step(p).catch((err) => console.warn('voice', p.name, err))
|
|
143
|
+
// The join and leave sounds follow who is in the call, not presence: a relay blip plays neither.
|
|
144
|
+
const people = peopleInCall(view.peers, this.links)
|
|
145
|
+
const sound = callSound(this.people, people)
|
|
146
|
+
// Those already in when we join are no news: joining plays its own sound.
|
|
147
|
+
if (sound && this.counted) this.onPeople(sound)
|
|
148
|
+
this.people = people
|
|
149
|
+
this.counted = true
|
|
103
150
|
this.onChange()
|
|
104
151
|
}
|
|
105
152
|
|
|
@@ -108,7 +155,7 @@ export class VoiceCall {
|
|
|
108
155
|
const what = nextStep({ me: this.me, peer: p.id, signal: p.signal, link })
|
|
109
156
|
if (link) link.name = p.name
|
|
110
157
|
if (what === 'offer') {
|
|
111
|
-
const l = this.connect(p)
|
|
158
|
+
const l = this.connect(p, newOfferId(), { offering: true })
|
|
112
159
|
const offer = await l.pc.createOffer()
|
|
113
160
|
await l.pc.setLocalDescription(offer)
|
|
114
161
|
await gathered(l.pc)
|
|
@@ -118,6 +165,10 @@ export class VoiceCall {
|
|
|
118
165
|
} else if (what === 'answer') {
|
|
119
166
|
const l = this.connect(p, p.signal.n)
|
|
120
167
|
await l.pc.setRemoteDescription({ type: 'offer', sdp: p.signal.sdp })
|
|
168
|
+
// Their offer's video line, open both ways: either of us can share. An app from before
|
|
169
|
+
// screen sharing offers none, and nobody's screen goes to or comes from it.
|
|
170
|
+
const tx = l.pc.getTransceivers().find((t) => t.receiver.track.kind === 'video')
|
|
171
|
+
if (tx) l.screenTx = screenLine(tx, this.screen)
|
|
121
172
|
const answer = await l.pc.createAnswer()
|
|
122
173
|
await l.pc.setLocalDescription(answer)
|
|
123
174
|
await gathered(l.pc)
|
|
@@ -131,13 +182,16 @@ export class VoiceCall {
|
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
184
|
|
|
134
|
-
/** A fresh connection to one person, replacing any old one. */
|
|
135
|
-
connect (p, n = newOfferId()) {
|
|
185
|
+
/** A fresh connection to one person, replacing any old one. The one offering adds the video line for a screen. */
|
|
186
|
+
connect (p, n = newOfferId(), { offering = false } = {}) {
|
|
136
187
|
this.drop(p.id, { keepSig: true })
|
|
137
|
-
|
|
188
|
+
// One transport for the voice and the screen: its addresses are in the offer once.
|
|
189
|
+
const pc = new RTCPeerConnection({ iceServers: this.servers?.iceServers || [], bundlePolicy: 'max-bundle' })
|
|
138
190
|
for (const t of this.stream.getAudioTracks()) pc.addTrack(t, this.stream)
|
|
139
|
-
const l = { pc, n, accepted: false, answered: false, failed: false, since: Date.now(), name: p.name }
|
|
191
|
+
const l = { pc, n, accepted: false, answered: false, failed: false, since: Date.now(), name: p.name, screenTx: null, screen: null }
|
|
192
|
+
if (offering) l.screenTx = screenLine(pc.addTransceiver('video', { direction: 'sendrecv' }), this.screen)
|
|
140
193
|
pc.ontrack = (e) => {
|
|
194
|
+
if (e.track.kind === 'video') { l.screen = new MediaStream([e.track]); this.onChange(); return }
|
|
141
195
|
if (l.audio) return
|
|
142
196
|
const s = e.streams[0] || new MediaStream([e.track])
|
|
143
197
|
l.audio = new Audio()
|
|
@@ -181,7 +235,7 @@ export class VoiceCall {
|
|
|
181
235
|
publish () {
|
|
182
236
|
if (!this.in) return
|
|
183
237
|
if (this.sending) { this.again = true; return }
|
|
184
|
-
this.sending = this.post({ muted: this.muted, transcribing: this.transcribing, sig: this.sig })
|
|
238
|
+
this.sending = this.post({ muted: this.muted, transcribing: this.transcribing, sharing: !!this.screen, sig: this.sig })
|
|
185
239
|
.then((view) => { this.sending = null; this.update(view) }, () => { this.sending = null })
|
|
186
240
|
.finally(() => { if (this.again) { this.again = false; this.publish() } })
|
|
187
241
|
}
|
|
@@ -221,6 +275,21 @@ export class VoiceCall {
|
|
|
221
275
|
}
|
|
222
276
|
}
|
|
223
277
|
|
|
278
|
+
/**
|
|
279
|
+
* A connection's video line for a screen: open both ways, the codecs that suit a screen first (and
|
|
280
|
+
* fewer of them, so the offer stays small: voice.js MAX_SDP), and our screen on it if we share one.
|
|
281
|
+
*/
|
|
282
|
+
function screenLine (tx, screen) {
|
|
283
|
+
tx.direction = 'sendrecv'
|
|
284
|
+
try {
|
|
285
|
+
const codecs = RTCRtpReceiver.getCapabilities('video').codecs.filter((c) => /^video\/(VP9|VP8|rtx)$/i.test(c.mimeType))
|
|
286
|
+
if (codecs.some((c) => !/rtx/i.test(c.mimeType))) tx.setCodecPreferences(codecs.sort((a, b) => /VP9/i.test(b.mimeType) - /VP9/i.test(a.mimeType)))
|
|
287
|
+
} catch {}
|
|
288
|
+
const track = screen?.getVideoTracks()[0]
|
|
289
|
+
if (track) tx.sender.replaceTrack(track).catch(() => {})
|
|
290
|
+
return tx
|
|
291
|
+
}
|
|
292
|
+
|
|
224
293
|
/** Waits until the connection has gathered its addresses (or GATHER_MS), so one offer or answer carries them all. */
|
|
225
294
|
function gathered (pc) {
|
|
226
295
|
if (pc.iceGatheringState === 'complete') return Promise.resolve()
|
package/src/ui/voice-model.js
CHANGED
|
@@ -141,3 +141,42 @@ export function keepMissing ({ state, missingSince, now = Date.now(), graceMs =
|
|
|
141
141
|
const gone = now - missingSince
|
|
142
142
|
return state === 'connected' ? gone < connectedMs : gone < graceMs
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether someone missing from the call's presence hung up: they are still in the session
|
|
147
|
+
* (`here`, every presence id the app server sees) but no longer in voice. Someone gone from the
|
|
148
|
+
* session altogether may be a relay blip (keepMissing).
|
|
149
|
+
*/
|
|
150
|
+
export const leftCall = ({ id, here }) => Array.isArray(here) && here.includes(id)
|
|
151
|
+
|
|
152
|
+
/** Who is in the call with us, by name: in voice now, or missing from presence with their connection kept. */
|
|
153
|
+
export function peopleInCall (peers, links) {
|
|
154
|
+
return new Set([...peers.map((p) => p.name), ...[...links.values()].map((l) => l.name)].filter(Boolean))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whose shared screen fills the middle of the session: the one picked while they still share,
|
|
159
|
+
* otherwise the first to share. `hidden`: those whose screen was hidden (until they stop sharing).
|
|
160
|
+
*/
|
|
161
|
+
export function screenShown ({ peers, picked = null, hidden = new Set() }) {
|
|
162
|
+
const sharing = peers.filter((p) => p.sharing && !hidden.has(p.id))
|
|
163
|
+
return (sharing.find((p) => p.id === picked) || sharing[0])?.id ?? null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The sound for a change in who is in the call: 'callIn', 'callOut' or null. */
|
|
167
|
+
export function callSound (before, now) {
|
|
168
|
+
if ([...now].some((n) => !before.has(n))) return 'callIn'
|
|
169
|
+
if ([...before].some((n) => !now.has(n))) return 'callOut'
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What to say when sharing a screen didn't start: nothing when the person backed out of the picker
|
|
175
|
+
* (AbortError from the desktop app, "by user" from a browser), otherwise why, so the button never
|
|
176
|
+
* does nothing in silence.
|
|
177
|
+
*/
|
|
178
|
+
export function shareRefused (err) {
|
|
179
|
+
if (!err || err.name === 'AbortError' || /by user/i.test(err.message || '')) return ''
|
|
180
|
+
if (err.name === 'NotAllowedError') return "Quilt can't share your screen. On a Mac, allow Quilt in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen Quilt."
|
|
181
|
+
return `Couldn't share your screen: ${err.message || err.name}`
|
|
182
|
+
}
|
package/src/ui/voice.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// going on is pinned to the bottom of the chat, above the message box: who is in it, Join, or
|
|
3
3
|
// once you're in it mute, leave, a ring around whoever speaks and the latest lines as each is
|
|
4
4
|
// written down. A transcribed call says so before your microphone opens. Finished calls stay in
|
|
5
|
-
// the chat as cards that open their transcript in place.
|
|
6
|
-
// the
|
|
5
|
+
// the chat as cards that open their transcript in place. A screen someone shares fills the middle
|
|
6
|
+
// of the session, the chat staying on the right; Hide puts back what was there. The audio and the
|
|
7
|
+
// screens are voice-call.js; the call as the app's server sees it comes as `voice` events.
|
|
7
8
|
import { $, esc, api, toast, avatar, I, TOKEN, clock } from './common.js'
|
|
8
9
|
import { VoiceCall } from './voice-call.js'
|
|
10
|
+
import { screenShown, shareRefused } from './voice-model.js'
|
|
9
11
|
import { playSound } from './sounds.js'
|
|
10
12
|
|
|
11
13
|
const views = new Map() // session id -> the call as that session's server sees it
|
|
@@ -19,15 +21,44 @@ let options = false // the transcript choices, open in the pinned call
|
|
|
19
21
|
const opened = new Set() // finished calls whose transcript is open in the chat
|
|
20
22
|
const full = new Map() // call id -> its lines, once opened
|
|
21
23
|
const checked = new Set() // sessions whose leftover "in the call" (from before a reload) was cleared
|
|
24
|
+
let picked = null // whose shared screen was picked to show, when several share
|
|
25
|
+
const hiddenScreens = new Set() // presence ids whose screen was hidden, until they stop sharing
|
|
26
|
+
let actualSize = false // a shared screen at its own size (scrolling) instead of fitted
|
|
27
|
+
let picking = null // { sources, blocked, resolve }: the desktop app asks which screen or window to share
|
|
28
|
+
let stageDrawn = ''
|
|
29
|
+
let screenVideo = null // the shared screen's picture: one element kept across Hide and Show, so it keeps its last frame
|
|
30
|
+
let selfClosed = false // our own screen's preview closed, until we stop sharing
|
|
31
|
+
let selfVideo = null // our own screen's preview: one element, like screenVideo
|
|
22
32
|
let drawn = '' // what the pinned call was last drawn from: redrawn only when that changes, so a click is never lost mid-press
|
|
23
33
|
let callsDrawn = ''
|
|
24
34
|
|
|
35
|
+
const NO_SCREEN = "Quilt can't see your screen. Allow it for Quilt in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen Quilt."
|
|
25
36
|
const NO_MIC = "Quilt can't use your microphone. Allow it for Quilt in System Settings → Privacy & Security → Microphone, then try again."
|
|
26
37
|
|
|
27
38
|
/** "Call" in the chat's header, shown while no call is going. */
|
|
28
39
|
export const callButtonMarkup = () => `<button type="button" class="chat-call" id="chat-call" data-call-join="ask" title="Start a voice call in this session" hidden>${I.headset}<span>Call</span></button>`
|
|
29
40
|
/** The call going on, pinned above the message box. */
|
|
30
41
|
export const callPinMarkup = () => '<div class="call-pin" id="call-pin" role="region" aria-label="Voice call" hidden></div>'
|
|
42
|
+
/** A shared screen (or what to share), over the middle of the session. */
|
|
43
|
+
export const screenStageMarkup = () => '<div class="screen-stage" id="screen-stage" role="region" aria-label="Shared screen" hidden></div><div class="self-share" id="self-share" role="region" aria-label="What you are sharing" hidden></div>'
|
|
44
|
+
|
|
45
|
+
// The desktop app with no system picker (Windows, macOS before 15) asks here which screen or
|
|
46
|
+
// window to share (desktop/screen-share.js): the choices fill the middle, the answer goes back.
|
|
47
|
+
window.quiltDesktop?.onPickScreen?.((ask) => pickScreen(ask))
|
|
48
|
+
|
|
49
|
+
/** Shows what can be shared (`sources`, or `blocked` when the system doesn't let Quilt see the screen); resolves to the id picked, or null. */
|
|
50
|
+
export function pickScreen ({ sources = [], blocked = false } = {}) {
|
|
51
|
+
endPicking(null)
|
|
52
|
+
if (!shown || !live || live.sid !== shown.id) return Promise.resolve(null)
|
|
53
|
+
return new Promise((resolve) => { picking = { sources, blocked, resolve }; renderCall() })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function endPicking (id) {
|
|
57
|
+
if (!picking) return
|
|
58
|
+
const { resolve } = picking
|
|
59
|
+
picking = null
|
|
60
|
+
resolve(id)
|
|
61
|
+
}
|
|
31
62
|
|
|
32
63
|
/**
|
|
33
64
|
* A session is on screen: wire the call into its chat. `who()`: { owner, me, colorOf(name) } now;
|
|
@@ -39,32 +70,28 @@ export function mountCall (id, signal, who, chatChanged = () => {}) {
|
|
|
39
70
|
options = false
|
|
40
71
|
drawn = ''
|
|
41
72
|
callsDrawn = ''
|
|
73
|
+
stageDrawn = ''
|
|
42
74
|
$('#chat-pane')?.addEventListener('click', onClick, { signal })
|
|
75
|
+
$('#screen-stage')?.addEventListener('click', onClick, { signal })
|
|
76
|
+
$('#self-share')?.addEventListener('click', onClick, { signal })
|
|
43
77
|
const tick = setInterval(renderCall, 20000) // the call's minutes
|
|
44
78
|
signal.addEventListener('abort', () => clearInterval(tick))
|
|
45
79
|
api('GET', `/api/sessions/${id}/voice`).then((v) => voiceEvent(id, v), () => {})
|
|
46
80
|
renderCall()
|
|
47
81
|
}
|
|
48
82
|
|
|
49
|
-
export function unmountCall () { shown = null; asking = false; options = false }
|
|
83
|
+
export function unmountCall () { endPicking(null); shown = null; asking = false; options = false }
|
|
50
84
|
|
|
51
85
|
/** The call as the app's server sees it now (a `voice` event, or an answer). */
|
|
52
86
|
export function voiceEvent (id, view) {
|
|
53
|
-
const was = views.get(id)
|
|
54
87
|
views.set(id, view)
|
|
55
88
|
// Left in the call by a window that closed or reloaded without leaving: out now.
|
|
56
89
|
if (!checked.has(id)) {
|
|
57
90
|
checked.add(id)
|
|
58
91
|
if (view.in && !(live && live.sid === id)) { api('POST', `/api/sessions/${id}/voice`, { in: false }).then((v) => voiceEvent(id, v), () => {}); return }
|
|
59
92
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const before = new Set((was?.peers || []).map((p) => p.id))
|
|
63
|
-
const now = new Set(view.peers.map((p) => p.id))
|
|
64
|
-
if ([...now].some((p) => !before.has(p))) playSound('callIn', { force: true })
|
|
65
|
-
else if ([...before].some((p) => !now.has(p))) playSound('callOut', { force: true })
|
|
66
|
-
live.call.update(view)
|
|
67
|
-
}
|
|
93
|
+
// The call we're in: it plays the join and leave sounds itself (onPeople), so a relay blip plays neither.
|
|
94
|
+
if (live && live.sid === id) live.call.update(view)
|
|
68
95
|
if (shown && shown.id === id) {
|
|
69
96
|
renderCall()
|
|
70
97
|
// A call ended: its card joins the chat. The call going on now changes every few seconds and
|
|
@@ -97,7 +124,8 @@ async function join (id, transcribing) {
|
|
|
97
124
|
}
|
|
98
125
|
throw err
|
|
99
126
|
}),
|
|
100
|
-
onChange: () => { if (shown && shown.id === id) renderCall() }
|
|
127
|
+
onChange: () => { if (shown && shown.id === id) renderCall() },
|
|
128
|
+
onPeople: (sound) => playSound(sound, { force: true })
|
|
101
129
|
})
|
|
102
130
|
live = { sid: id, call }
|
|
103
131
|
await call.join({ transcribing })
|
|
@@ -116,6 +144,12 @@ async function leave () {
|
|
|
116
144
|
const { sid, call } = live
|
|
117
145
|
live = null
|
|
118
146
|
options = false
|
|
147
|
+
endPicking(null)
|
|
148
|
+
picked = null
|
|
149
|
+
hiddenScreens.clear()
|
|
150
|
+
if (screenVideo) screenVideo.srcObject = null
|
|
151
|
+
selfClosed = false
|
|
152
|
+
if (selfVideo) selfVideo.srcObject = null
|
|
119
153
|
const v = await call.leave().catch(() => null)
|
|
120
154
|
if (v) voiceEvent(sid, v)
|
|
121
155
|
playSound('callOut', { force: true })
|
|
@@ -130,7 +164,7 @@ window.addEventListener('pagehide', () => {
|
|
|
130
164
|
|
|
131
165
|
async function onClick (e) {
|
|
132
166
|
const t = e.target.closest('button')
|
|
133
|
-
if (!t || !shown || !t.closest('#chat-call, #call-pin, .call-card')) return
|
|
167
|
+
if (!t || !shown || !t.closest('#chat-call, #call-pin, #screen-stage, #self-share, .call-card')) return
|
|
134
168
|
const id = shown.id
|
|
135
169
|
const view = views.get(id) || EMPTY
|
|
136
170
|
const d = t.dataset
|
|
@@ -145,6 +179,32 @@ async function onClick (e) {
|
|
|
145
179
|
} else if (d.callOptions !== undefined) {
|
|
146
180
|
options = !options
|
|
147
181
|
renderCall()
|
|
182
|
+
} else if (d.callShare !== undefined) {
|
|
183
|
+
if (!live) return
|
|
184
|
+
if (live.call.sharing) live.call.stopShare()
|
|
185
|
+
else live.call.share().catch((err) => { const why = shareRefused(err); if (why) toast(why) })
|
|
186
|
+
} else if (d.screenShow) {
|
|
187
|
+
picked = Number(d.screenShow)
|
|
188
|
+
hiddenScreens.clear()
|
|
189
|
+
renderCall()
|
|
190
|
+
} else if (d.screenHide !== undefined) {
|
|
191
|
+
// Hidden until they stop sharing: everyone sharing now, so another screen doesn't take its place.
|
|
192
|
+
for (const p of view.peers) if (p.sharing) hiddenScreens.add(p.id)
|
|
193
|
+
renderCall()
|
|
194
|
+
} else if (d.selfShare) {
|
|
195
|
+
selfClosed = d.selfShare === 'close'
|
|
196
|
+
renderCall()
|
|
197
|
+
} else if (d.screenSize) {
|
|
198
|
+
actualSize = d.screenSize === 'actual'
|
|
199
|
+
renderCall()
|
|
200
|
+
} else if (d.screenFull !== undefined) {
|
|
201
|
+
$('#screen-stage .ss-view')?.requestFullscreen?.().catch(() => {})
|
|
202
|
+
} else if (d.screenSrc) {
|
|
203
|
+
endPicking(d.screenSrc)
|
|
204
|
+
renderCall()
|
|
205
|
+
} else if (d.screenCancel !== undefined) {
|
|
206
|
+
endPicking(null)
|
|
207
|
+
renderCall()
|
|
148
208
|
} else if (d.callMute !== undefined) {
|
|
149
209
|
live?.call.setMuted(!live.call.muted)
|
|
150
210
|
} else if (d.callLeave !== undefined) {
|
|
@@ -207,7 +267,14 @@ export function renderCall () {
|
|
|
207
267
|
// The latest lines change every few seconds: they're drawn on their own, so the controls above
|
|
208
268
|
// them (Join, mute, leave) are never redrawn under a click.
|
|
209
269
|
const lines = here && view.recent.length ? view.recent.map((l) => lineHtml(l, colorOf)).join('') : ''
|
|
210
|
-
|
|
270
|
+
// A screen hidden stays hidden until its person stops sharing; the next time they share, it shows.
|
|
271
|
+
for (const pid of [...hiddenScreens]) if (!others.some((p) => p.id === pid && p.sharing)) hiddenScreens.delete(pid)
|
|
272
|
+
const showing = here ? screenShown({ peers: others, picked, hidden: hiddenScreens }) : null
|
|
273
|
+
renderStage({ here, showing, others, colorOf })
|
|
274
|
+
// Closing the preview lasts until we stop sharing; the next share shows it again.
|
|
275
|
+
if (!c?.sharing) selfClosed = false
|
|
276
|
+
renderSelf(c)
|
|
277
|
+
const key = JSON.stringify([id, here, joining, asking, options, owner, elsewhere, view.transcribe, others.map((p) => [p.id, p.name, p.color, p.muted, p.transcribing, p.sharing]), me.name, me.color, c && [c.muted, c.transcribing, c.sharing], selfClosed, showing, minutes(view.since || Date.now())])
|
|
211
278
|
if (key === drawn && (pin.hidden || pin.childElementCount)) { paintLines(pin, lines); paintTalk(); return }
|
|
212
279
|
drawn = key
|
|
213
280
|
linesDrawn = null
|
|
@@ -221,7 +288,7 @@ export function renderCall () {
|
|
|
221
288
|
<div class="cp-two"><button type="button" class="btn sm primary" data-call-join="transcribed">${others.length ? 'Join' : 'Start'}, transcribed</button><button type="button" class="btn sm" data-call-join="plain">${others.length ? 'Join' : 'Start'}, not transcribed</button></div>`
|
|
222
289
|
} else if (!here) {
|
|
223
290
|
pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><span class="call-faces">${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>
|
|
224
|
-
<span class="cp-what"><b>${esc(names(others.map((p) => p.name)))} ${others.length === 1 ? 'is' : 'are'} in a call</b><span class="call-sub">${[dur, view.transcribe ? 'transcribed' : ''].filter(Boolean).join(' · ')}</span></span>
|
|
291
|
+
<span class="cp-what"><b>${esc(names(others.map((p) => p.name)))} ${others.length === 1 ? 'is' : 'are'} in a call</b><span class="call-sub">${[dur, view.transcribe ? 'transcribed' : '', ...others.filter((p) => p.sharing).map((p) => `${esc(p.name)} sharing a screen`)].filter(Boolean).join(' · ')}</span></span>
|
|
225
292
|
<button type="button" class="btn sm primary cp-end" data-call-join="ask" ${elsewhere ? 'disabled title="Leave your other call first"' : others.length >= FULL ? 'disabled title="This call is full: calls hold six people for now."' : ''}>Join</button></div>`
|
|
226
293
|
} else {
|
|
227
294
|
const mine = c.transcribing && view.transcribe
|
|
@@ -235,16 +302,99 @@ export function renderCall () {
|
|
|
235
302
|
? `<div class="cp-opt"><span><b>Transcripts</b> · ${view.transcribe ? 'On: people who agree are written down for the session and its agents.' : 'Off: nothing said in calls is written down.'}</span><button type="button" class="btn sm" data-call-owner="${view.transcribe ? 'off' : 'on'}">${view.transcribe ? 'Turn off' : 'Turn on'}</button></div>`
|
|
236
303
|
: ''
|
|
237
304
|
pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><b>Call</b><span class="call-sub">${esc(dur)}</span>
|
|
238
|
-
<button type="button" class="call-ic cp-end" data-call-
|
|
305
|
+
<button type="button" class="call-ic cp-end" data-call-share aria-pressed="${c.sharing}" title="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}" aria-label="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}">${c.sharing ? I.screenOff : I.screenShare}</button>
|
|
306
|
+
<button type="button" class="call-ic" data-call-mute aria-pressed="${c.muted}" title="${c.muted ? 'Unmute' : 'Mute'}" aria-label="${c.muted ? 'Unmute' : 'Mute'}">${c.muted ? I.micOff : I.mic}</button>
|
|
239
307
|
<button type="button" class="call-ic bad" data-call-leave title="Leave the call" aria-label="Leave the call">${I.hangUp}</button></div>
|
|
240
308
|
<div class="cp-row"><span class="call-faces">${face('me', me.name, me.color, c.muted)}${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>${tag}</div>
|
|
241
309
|
${options ? `<div class="cp-opts">${myChoice}${ownerChoice}</div>` : ''}
|
|
310
|
+
${c.sharing ? `<div class="cp-share">${I.screenShare}<span class="cp-says">Sharing your screen</span>${selfClosed ? '<button type="button" class="btn sm ghost" data-self-share="open" title="Show what you share, in the corner">Show</button>' : ''}<button type="button" class="btn sm" data-call-share>Stop sharing</button></div>` : ''}
|
|
311
|
+
${others.filter((p) => p.sharing).map((p) => `<div class="cp-share">${I.screenShare}<span class="cp-says">${esc(p.name)} is sharing a screen</span>${p.id === showing ? '<span class="call-sub">in the middle</span>' : `<button type="button" class="btn sm" data-screen-show="${p.id}">Show</button>`}</div>`).join('')}
|
|
242
312
|
<div class="cp-lines" aria-live="polite" hidden></div>`
|
|
243
313
|
}
|
|
244
314
|
paintLines(pin, lines)
|
|
245
315
|
paintTalk()
|
|
246
316
|
}
|
|
247
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The middle of the session while we're in the call: the screen someone shares (`showing`, a
|
|
320
|
+
* presence id), or what to share when the desktop app asks. Hidden otherwise, which puts back
|
|
321
|
+
* whatever was there. Redrawn only when what it shows changes; the picture itself never redraws.
|
|
322
|
+
*/
|
|
323
|
+
function renderStage ({ here, showing, others, colorOf }) {
|
|
324
|
+
const stage = $('#screen-stage')
|
|
325
|
+
if (!stage) return
|
|
326
|
+
const p = showing != null ? others.find((x) => x.id === showing) : null
|
|
327
|
+
const sharers = others.filter((x) => x.sharing)
|
|
328
|
+
const open = !!(here && (picking || p))
|
|
329
|
+
stage.hidden = !open
|
|
330
|
+
const key = JSON.stringify(open ? (picking ? ['pick', picking.blocked, picking.sources.map((s) => s.id)] : ['screen', p.id, p.name, p.color, sharers.map((x) => [x.id, x.name])]) : null)
|
|
331
|
+
if (key !== stageDrawn) {
|
|
332
|
+
stageDrawn = key
|
|
333
|
+
stage.innerHTML = !open ? '' : picking ? pickerHtml(picking) : screenHtml(p, sharers, colorOf)
|
|
334
|
+
}
|
|
335
|
+
if (!open || picking) return
|
|
336
|
+
// Fit or its own size: the same picture, never reloaded.
|
|
337
|
+
stage.querySelector('.ss-view').classList.toggle('actual', actualSize)
|
|
338
|
+
for (const b of stage.querySelectorAll('[data-screen-size]')) {
|
|
339
|
+
const on = (b.dataset.screenSize === 'actual') === actualSize
|
|
340
|
+
b.classList.toggle('on', on)
|
|
341
|
+
b.setAttribute('aria-pressed', on)
|
|
342
|
+
}
|
|
343
|
+
const stream = live.call.screenOf(p.id)
|
|
344
|
+
if (!screenVideo) screenVideo = Object.assign(document.createElement('video'), { autoplay: true, muted: true, playsInline: true })
|
|
345
|
+
const video = screenVideo
|
|
346
|
+
const box = stage.querySelector('.ss-view')
|
|
347
|
+
const wait = stage.querySelector('.ss-wait')
|
|
348
|
+
if (video.parentNode !== box) box.prepend(video)
|
|
349
|
+
if (video.srcObject !== stream) video.srcObject = stream
|
|
350
|
+
// Taken out of the page (Hide) pauses it.
|
|
351
|
+
if (stream && video.paused) video.play().catch(() => {})
|
|
352
|
+
// Until its first picture arrives (a moment after they start sharing), say whose it will be.
|
|
353
|
+
wait.hidden = !!(stream && video.videoWidth)
|
|
354
|
+
video.onresize = () => { if (video.parentNode) video.parentNode.querySelector('.ss-wait').hidden = true }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** What we share, small in the corner of the middle, so we see what everyone sees. `c`: our call, when we're in one. */
|
|
358
|
+
function renderSelf (c) {
|
|
359
|
+
const box = $('#self-share')
|
|
360
|
+
if (!box) return
|
|
361
|
+
const open = !!(c && c.sharing && c.screen && !selfClosed)
|
|
362
|
+
box.hidden = !open
|
|
363
|
+
if (!open) { if (selfVideo) selfVideo.srcObject = null; return }
|
|
364
|
+
if (!box.childElementCount) {
|
|
365
|
+
box.innerHTML = `<div class="sf-bar">${I.screenShare}<span>You're sharing this</span><button type="button" class="sf-x" data-self-share="close" title="Close the preview (you keep sharing)" aria-label="Close the preview">${I.x}</button></div><div class="sf-view"></div>`
|
|
366
|
+
}
|
|
367
|
+
if (!selfVideo) selfVideo = Object.assign(document.createElement('video'), { autoplay: true, muted: true, playsInline: true })
|
|
368
|
+
const view = box.querySelector('.sf-view')
|
|
369
|
+
if (selfVideo.parentNode !== view) view.append(selfVideo)
|
|
370
|
+
if (selfVideo.srcObject !== c.screen) selfVideo.srcObject = c.screen
|
|
371
|
+
if (selfVideo.paused) selfVideo.play().catch(() => {})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function screenHtml (p, sharers, colorOf) {
|
|
375
|
+
const chips = sharers.length > 1
|
|
376
|
+
? `<div class="loom-seg" role="group" aria-label="Whose screen">${sharers.map((x) => `<button type="button" class="loom-chip${x.id === p.id ? ' on' : ''}" data-screen-show="${x.id}" aria-pressed="${x.id === p.id}">${esc(x.name)}</button>`).join('')}</div>`
|
|
377
|
+
: ''
|
|
378
|
+
return `<div class="loom-bar ss-bar">
|
|
379
|
+
<div class="loom-title ss-who">${avatar(p.name, p.color || colorOf(p.name))}<b>${esc(p.name)}'s screen</b><span class="hint">live in the call</span></div>
|
|
380
|
+
${chips}<span class="spacer"></span>
|
|
381
|
+
<div class="loom-seg" role="group" aria-label="Size"><button type="button" class="loom-chip${actualSize ? '' : ' on'}" data-screen-size="fit" aria-pressed="${!actualSize}">Fit</button><button type="button" class="loom-chip${actualSize ? ' on' : ''}" data-screen-size="actual" aria-pressed="${actualSize}">Actual size</button></div>
|
|
382
|
+
<button type="button" class="loom-chip" data-screen-full>${I.fullScreen}<span>Full screen</span></button>
|
|
383
|
+
<button type="button" class="loom-chip" data-screen-hide title="Back to what was here">Hide</button>
|
|
384
|
+
</div>
|
|
385
|
+
<div class="ss-view${actualSize ? ' actual' : ''}"><p class="ss-wait">Waiting for ${esc(p.name)}'s screen…</p></div>`
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function pickerHtml ({ sources, blocked }) {
|
|
389
|
+
const card = (s) => `<button type="button" class="ss-src" data-screen-src="${esc(s.id)}" title="${esc(s.name)}"><span class="ss-thumb">${s.thumb ? `<img src="${esc(s.thumb)}" alt="">` : I.screenShare}</span><span class="ss-name">${esc(s.name || (s.screen ? 'Screen' : 'Window'))}</span></button>`
|
|
390
|
+
const group = (title, list) => list.length ? `<h4>${title}</h4><div class="ss-grid">${list.map(card).join('')}</div>` : ''
|
|
391
|
+
const body = blocked
|
|
392
|
+
? `<p class="ss-note">${esc(NO_SCREEN)}</p>`
|
|
393
|
+
: sources.length ? group('Screens', sources.filter((s) => s.screen)) + group('Windows', sources.filter((s) => !s.screen)) : '<p class="ss-note">There is nothing to share.</p>'
|
|
394
|
+
return `<div class="loom-bar ss-bar"><div class="loom-title"><b>Share your screen</b><span class="hint">Everyone in the call sees what you pick</span></div><span class="spacer"></span><button type="button" class="loom-chip" data-screen-cancel>Cancel</button></div>
|
|
395
|
+
<div class="ss-pick">${body}</div>`
|
|
396
|
+
}
|
|
397
|
+
|
|
248
398
|
let linesDrawn = null
|
|
249
399
|
/** The latest lines of the call, under its controls. */
|
|
250
400
|
function paintLines (pin, html) {
|