nandi/frqpublic Fork 0
556ab58
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Merge remote-tracking branch 'origin/main' into claude/apk-device-deployment-921e81

nandi committed 2026-08-30T18:21:11-07:00 Browse files
556ab58 parents: 7ef496a 3343b8e
modified README.md +45 -2
@@ -18,6 +18,7 @@ src/frq/store.jolt the saved sign-in, mode 600 in the config directory
1818 src/frq/avatars.jolt profile pictures, by DID or handle
1919 src/frq/media.jolt image links: spot them, fetch them once, cache on disk
2020 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart
21+src/frq/av.jolt calls: the signaling, and a handle on the media plane
2122 src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings
2223 src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name
2324 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
@@ -32,13 +33,19 @@ Android is logcat.
3233
3334 ## Running
3435
35-`libvidya` from Vidya's Rust/egui backend, then the app:
36+Both native libraries, then the app:
3637
3738 ```bash
3839 just lib
3940 just run
4041 ```
4142
43+`just lib` builds [jolt-native](https://gitlab.com/nandithebull/jolt-native),
44+which is where every shared object this client loads comes from: `libvidya`,
45+the retained-tree ABI glimmer paints through, and `libjoltmoq`, the AV media
46+plane. They come out of one directory, and `just run` puts that one directory
47+on the loader path.
48+
4249 `just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built
4350 library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick
4451 TLS on the connect screen (or point it at `127.0.0.1`) for a local server's
@@ -132,6 +139,37 @@ surface — that surface does not work on Android either, while the syscalls do.
132139 * Conversations listed most recently opened first
133140 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
134141 message with
142+* Calls: a Call button opens one in a channel, a banner offers Join where
143+ somebody already has, and in one there is mute, deafen, video and leave. Mute
144+ and deafen are separate — a deafened microphone still carries your voice.
145+ Whoever turns a camera on appears as a tile; the self-view is labelled You
146+ and sits last, where it cannot push a face you are talking to off the row
147+
148+## Calls
149+
150+Signaling is IRC and lives here: `+freeq.at/av-start`, `av-join` and `av-leave`
151+go out as TAGMSGs and the server broadcasts `+freeq.at/av-state` back, which is
152+what actually moves this client's state — a press is optimistic, and the server
153+settles it. Losing a race to open a call (`start-collision`) is answered by
154+joining the call that won rather than by reporting an error, since the person
155+asked to be in a call in that room and there is one.
156+
157+Media is not IRC and is not here. Audio and video ride MoQ — Media over QUIC —
158+through freeq's SFU, and that is `libjoltmoq`: Opus, H.264, capture and
159+transport, lifted out of sleek rather than written a second time in jolt.
160+`src/frq/av.jolt` is the whole of what frq says to it, and two of its rules
161+shape this side:
162+
163+* **Nothing calls back.** Status and video are polled, drained by a timer that
164+ glimmer runs on the loop thread — the only thread allowed to touch a node.
165+* **A frame is borrowed.** The decoder's own buffer is handed to Vidya as a
166+ pointer and painted by an `:image` with a `:feed`. The pixels never become a
167+ jolt value and are never copied on this side, which is the only way thirty
168+ frames a second is affordable here.
169+
170+The SFU is dialled once the server has minted a token, not when we ask to join:
171+a remote SFU refuses a connection without one, and the MoQ client then retries
172+in a loop that looks exactly like a hang.
135173
136174 ## Limits
137175
@@ -145,12 +183,17 @@ surface — that surface does not work on Android either, while the syscalls do.
145183 format, and a fetch needs TLS, so the phone shows links. The link is left in
146184 place either way.
147185 * **Nothing evicts the media cache.**
186+* **Calls are desktop-only.** `libjoltmoq` is not built for Android here, and
187+ the camera and microphone paths that are would still need the runtime
188+ permissions the APK does not ask for.
189+* **One call at a time**, which is the media plane's rule and the microphone's.
190+* **No call is offered in a DM** — freeq's AV signaling is a channel's.
148191 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed
149192 under the DID of a live session, so a guest cannot make one; and it is read
150193 off the clipboard through the ABI's `vidya_clipboard_image_png`, which
151194 arboard backs on desktop and nothing backs on Android. It also shares
152195 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
153196 and this client does not send them.
154-* **No scrollback trimming, reactions, threads, or calls.**
197+* **No scrollback trimming or threads.**
155198 * A sent line waits up to 200ms for the reader thread to flush it.
156199 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
@@ -18,6 +18,7 @@ src/frq/store.jolt the saved sign-in, mode 600 in the config directory
18 src/frq/avatars.jolt profile pictures, by DID or handle18 src/frq/avatars.jolt profile pictures, by DID or handle
19 src/frq/media.jolt image links: spot them, fetch them once, cache on disk19 src/frq/media.jolt image links: spot them, fetch them once, cache on disk
20 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart20 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart
21+src/frq/av.jolt calls: the signaling, and a handle on the media plane
21 src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings22 src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings
22 src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name23 src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name
23 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG24 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
@@ -32,13 +33,19 @@ Android is logcat.
32 33
33 ## Running34 ## Running
34 35
35-`libvidya` from Vidya's Rust/egui backend, then the app:36+Both native libraries, then the app:
36 37
37 ```bash38 ```bash
38 just lib39 just lib
39 just run40 just run
40 ```41 ```
41 42
43+`just lib` builds [jolt-native](https://gitlab.com/nandithebull/jolt-native),
44+which is where every shared object this client loads comes from: `libvidya`,
45+the retained-tree ABI glimmer paints through, and `libjoltmoq`, the AV media
46+plane. They come out of one directory, and `just run` puts that one directory
47+on the loader path.
48+
42 `just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built49 `just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built
43 library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick50 library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick
44 TLS on the connect screen (or point it at `127.0.0.1`) for a local server's51 TLS on the connect screen (or point it at `127.0.0.1`) for a local server's
@@ -132,6 +139,37 @@ surface — that surface does not work on Android either, while the syscalls do.
132 * Conversations listed most recently opened first139 * Conversations listed most recently opened first
133 * Bluesky avatars beside the sender, resolved from the DID freeq tags each140 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
134 message with141 message with
142+* Calls: a Call button opens one in a channel, a banner offers Join where
143+ somebody already has, and in one there is mute, deafen, video and leave. Mute
144+ and deafen are separate — a deafened microphone still carries your voice.
145+ Whoever turns a camera on appears as a tile; the self-view is labelled You
146+ and sits last, where it cannot push a face you are talking to off the row
147+
148+## Calls
149+
150+Signaling is IRC and lives here: `+freeq.at/av-start`, `av-join` and `av-leave`
151+go out as TAGMSGs and the server broadcasts `+freeq.at/av-state` back, which is
152+what actually moves this client's state — a press is optimistic, and the server
153+settles it. Losing a race to open a call (`start-collision`) is answered by
154+joining the call that won rather than by reporting an error, since the person
155+asked to be in a call in that room and there is one.
156+
157+Media is not IRC and is not here. Audio and video ride MoQ — Media over QUIC —
158+through freeq's SFU, and that is `libjoltmoq`: Opus, H.264, capture and
159+transport, lifted out of sleek rather than written a second time in jolt.
160+`src/frq/av.jolt` is the whole of what frq says to it, and two of its rules
161+shape this side:
162+
163+* **Nothing calls back.** Status and video are polled, drained by a timer that
164+ glimmer runs on the loop thread — the only thread allowed to touch a node.
165+* **A frame is borrowed.** The decoder's own buffer is handed to Vidya as a
166+ pointer and painted by an `:image` with a `:feed`. The pixels never become a
167+ jolt value and are never copied on this side, which is the only way thirty
168+ frames a second is affordable here.
169+
170+The SFU is dialled once the server has minted a token, not when we ask to join:
171+a remote SFU refuses a connection without one, and the MoQ client then retries
172+in a loop that looks exactly like a hang.
135 173
136 ## Limits174 ## Limits
137 175
@@ -145,12 +183,17 @@ surface — that surface does not work on Android either, while the syscalls do.
145 format, and a fetch needs TLS, so the phone shows links. The link is left in183 format, and a fetch needs TLS, so the phone shows links. The link is left in
146 place either way.184 place either way.
147 * **Nothing evicts the media cache.**185 * **Nothing evicts the media cache.**
186+* **Calls are desktop-only.** `libjoltmoq` is not built for Android here, and
187+ the camera and microphone paths that are would still need the runtime
188+ permissions the APK does not ask for.
189+* **One call at a time**, which is the media plane's rule and the microphone's.
190+* **No call is offered in a DM** — freeq's AV signaling is a channel's.
148 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed191 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed
149 under the DID of a live session, so a guest cannot make one; and it is read192 under the DID of a live session, so a guest cannot make one; and it is read
150 off the clipboard through the ABI's `vidya_clipboard_image_png`, which193 off the clipboard through the ABI's `vidya_clipboard_image_png`, which
151 arboard backs on desktop and nothing backs on Android. It also shares194 arboard backs on desktop and nothing backs on Android. It also shares
152 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in195 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
153 and this client does not send them.196 and this client does not send them.
154-* **No scrollback trimming, reactions, threads, or calls.**197+* **No scrollback trimming or threads.**
155 * A sent line waits up to 200ms for the reader thread to flush it.198 * A sent line waits up to 200ms for the reader thread to flush it.
156 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.199 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
modified deps.edn +22 -8
@@ -2,21 +2,35 @@
22
33 ;; glimmer owns the reactive half (ratom, components, reconciler);
44 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.
5- ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a
5+ ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
66 ;; component unmounted between a cell firing and the queued render running
77 ;; used to render anyway, into widgets the backend had already freed and
88 ;; handed out again — which took the message list apart in a busy channel.
9+ ;; And replacing a native node with one of another tag left every watcher
10+ ;; under it subscribed, so swapping the phone layout for the split one and
11+ ;; back shredded the chat: one component's props painted onto another's
12+ ;; widget.
913 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
10- :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"}
11- nandi/glimmer-vidya {:local/root "../vidya/glimmer"}}
14+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
15+ nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}}
1216
13- ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree
14- ;; ABI the backend binds):
15- ;; cd ../vidya && just ffi
16- ;; LD_LIBRARY_PATH=../vidya/build jolt -M:frq
17+ ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each,
18+ ;; out of one target directory:
19+ ;;
20+ ;; libvidya the retained-tree ABI glimmer-vidya binds, on egui
21+ ;; libjoltmoq the AV media plane — MoQ over QUIC, Opus, H.264, capture
22+ ;;
23+ ;; Calls are the one thing this client cannot do in jolt. Signaling is IRC and
24+ ;; lives in src/frq/av.jolt; the media half is three thousand lines of codec
25+ ;; and transport that would only be written badly a second time here.
26+ ;;
27+ ;; cd ../jolt-native && just build
1728 :jolt/native [{:name "vidya"
1829 :darwin ["libvidya.dylib"]
19- :linux ["libvidya.so"]}]
30+ :linux ["libvidya.so"]}
31+ {:name "joltmoq"
32+ :darwin ["libjoltmoq.dylib"]
33+ :linux ["libjoltmoq.so"]}]
2034
2135 :aliases {:frq {:main-opts ["-m" "frq.app"]}}
2236
@@ -2,21 +2,35 @@
2 2
3 ;; glimmer owns the reactive half (ratom, components, reconciler);3 ;; glimmer owns the reactive half (ratom, components, reconciler);
4 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.4 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.
5- ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a5+ ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
6 ;; component unmounted between a cell firing and the queued render running6 ;; component unmounted between a cell firing and the queued render running
7 ;; used to render anyway, into widgets the backend had already freed and7 ;; used to render anyway, into widgets the backend had already freed and
8 ;; handed out again — which took the message list apart in a busy channel.8 ;; handed out again — which took the message list apart in a busy channel.
9+ ;; And replacing a native node with one of another tag left every watcher
10+ ;; under it subscribed, so swapping the phone layout for the split one and
11+ ;; back shredded the chat: one component's props painted onto another's
12+ ;; widget.
9 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"13 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
10- :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"}14+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
11- nandi/glimmer-vidya {:local/root "../vidya/glimmer"}}15+ nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}}
12 16
13- ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree17+ ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each,
14- ;; ABI the backend binds):18+ ;; out of one target directory:
15- ;; cd ../vidya && just ffi19+ ;;
16- ;; LD_LIBRARY_PATH=../vidya/build jolt -M:frq20+ ;; libvidya the retained-tree ABI glimmer-vidya binds, on egui
21+ ;; libjoltmoq the AV media plane — MoQ over QUIC, Opus, H.264, capture
22+ ;;
23+ ;; Calls are the one thing this client cannot do in jolt. Signaling is IRC and
24+ ;; lives in src/frq/av.jolt; the media half is three thousand lines of codec
25+ ;; and transport that would only be written badly a second time here.
26+ ;;
27+ ;; cd ../jolt-native && just build
17 :jolt/native [{:name "vidya"28 :jolt/native [{:name "vidya"
18 :darwin ["libvidya.dylib"]29 :darwin ["libvidya.dylib"]
19- :linux ["libvidya.so"]}]30+ :linux ["libvidya.so"]}
31+ {:name "joltmoq"
32+ :darwin ["libjoltmoq.dylib"]
33+ :linux ["libjoltmoq.so"]}]
20 34
21 :aliases {:frq {:main-opts ["-m" "frq.app"]}}35 :aliases {:frq {:main-opts ["-m" "frq.app"]}}
22 36
modified justfile +13 -5
@@ -1,14 +1,22 @@
11 set shell := ["bash", "-euo", "pipefail", "-c"]
22
3-vidya := justfile_directory() + "/../vidya"
3+# Sibling checkouts, found relative to the *main* checkout rather than to this
4+# directory. In a git worktree they are not the same place: the justfile sits
5+# at .claude/worktrees/<name>, so "../jolt-native" from here would be
6+# .claude/worktrees/jolt-native, which is nothing. `--git-common-dir` is the one
7+# thing that answers the same in a worktree as it does in the checkout it came
8+# from.
9+checkout := parent_directory(`git rev-parse --path-format=absolute --git-common-dir`)
10+jolt_native := checkout / "../jolt-native"
411
512 default:
613 @just --list
714
8-# libvidya (Rust/egui build) — the tree ABI glimmer-vidya binds.
15+# Both native libraries: libvidya (the tree ABI glimmer-vidya binds) and
16+# libjoltmoq (the AV media plane). One workspace, one target directory.
917 lib:
10- cd {{vidya}} && just ffi
18+ cd {{jolt_native}} && just build
1119
12-# The app. Point LD_LIBRARY_PATH at whichever libvidya build you have.
20+# The app.
1321 run *args:
14- LD_LIBRARY_PATH="{{vidya}}/build:{{vidya}}/ffi/target/release" jolt -M:frq {{args}}
22+ LD_LIBRARY_PATH="{{jolt_native}}/target/release" jolt -M:frq {{args}}
@@ -1,14 +1,22 @@
1 set shell := ["bash", "-euo", "pipefail", "-c"]1 set shell := ["bash", "-euo", "pipefail", "-c"]
2 2
3-vidya := justfile_directory() + "/../vidya"3+# Sibling checkouts, found relative to the *main* checkout rather than to this
4+# directory. In a git worktree they are not the same place: the justfile sits
5+# at .claude/worktrees/<name>, so "../jolt-native" from here would be
6+# .claude/worktrees/jolt-native, which is nothing. `--git-common-dir` is the one
7+# thing that answers the same in a worktree as it does in the checkout it came
8+# from.
9+checkout := parent_directory(`git rev-parse --path-format=absolute --git-common-dir`)
10+jolt_native := checkout / "../jolt-native"
4 11
5 default:12 default:
6 @just --list13 @just --list
7 14
8-# libvidya (Rust/egui build) — the tree ABI glimmer-vidya binds.15+# Both native libraries: libvidya (the tree ABI glimmer-vidya binds) and
16+# libjoltmoq (the AV media plane). One workspace, one target directory.
9 lib:17 lib:
10- cd {{vidya}} && just ffi18+ cd {{jolt_native}} && just build
11 19
12-# The app. Point LD_LIBRARY_PATH at whichever libvidya build you have.20+# The app.
13 run *args:21 run *args:
14- LD_LIBRARY_PATH="{{vidya}}/build:{{vidya}}/ffi/target/release" jolt -M:frq {{args}}22+ LD_LIBRARY_PATH="{{jolt_native}}/target/release" jolt -M:frq {{args}}
modified src/frq/app.jolt +210 -12
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [glimmer.core :as ui]
1010 [glimmer-vidya.core :as vidya]
11+ [frq.av :as av]
1112 [frq.avatars :as avatars]
1213 [frq.clock :as clock]
1314 [frq.media :as media]
@@ -164,9 +165,16 @@
164165 [:button {:label "Join"
165166 :kind :primary
166167 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]]
167- [:entry {:text @s/search
168- :placeholder "Search channels"
169- :on-change #(reset! s/search %)}]
168+ ;; The clear button only shows while there is something to clear: an
169+ ;; empty box has nothing to undo, and a dead button beside it reads as
170+ ;; one that stopped working.
171+ [:hbox {:spacing 6}
172+ [:entry {:text @s/search
173+ :width-request 380
174+ :placeholder "Search channels"
175+ :on-change #(reset! s/search %)}]
176+ (when (seq @s/search)
177+ [:button {:label "" :on-click #(reset! s/search "")}])]
170178 (if (seq buffers)
171179 (for [b buffers] [conversation-row b])
172180 [:card {} [:dim-label {:label "No conversations yet — join a channel."}]])
@@ -519,6 +527,118 @@
519527 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
520528 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
521529
530+(defn- call-tile
531+ "One participant's picture, at the width the row worked out for it.
532+
533+ `:feed` rather than `:src`: these pixels never touch the disk and never
534+ become a value here the media plane hands the decoder's own buffer to
535+ Vidya as a pointer, and the tag paints whatever arrived last under that name.
536+
537+ The height is three quarters of the width, which is the shape a camera hands
538+ over. Naming both keeps a portrait phone from making its tile tall enough to
539+ push the row off the screen the picture is fitted inside, never stretched."
540+ [width key]
541+ (let [mine? (= av/local-feed key)]
542+ [:vbox {:key key :spacing 2}
543+ ;; `:upscale` because a tile is a slot the layout sized, not a picture
544+ ;; sitting at whatever the camera happened to send. Without it a 480-wide
545+ ;; stream draws 480 wide in a 900-point slot and the wall looks broken
546+ ;; which is exactly what it did.
547+ [:image {:feed key
548+ :upscale true
549+ :max-width width
550+ :max-height (long (* width 0.75))}]
551+ [:dim-label {:label (if mine? "You" key)}]]))
552+
553+(defn- call-wall
554+ "Everyone with a camera on, sized to the window they are being watched in.
555+
556+ A call with no video is the normal case and should look like one a row of
557+ empty frames would suggest something had failed to load.
558+
559+ Both cells this reads are what subscribe it: `av/tiles` for who is on
560+ screen, and the window width so the tiles follow a window being dragged.
561+ Without either it would lay itself out once, on the first frame, and keep
562+ that shape for the rest of the call."
563+ []
564+ (let [[width rows] (av/tile-rows)]
565+ [:vbox {:key :call-wall :spacing 6}
566+ ;; A seq, not a vector: children splice, and a vector would be read as one
567+ ;; more hiccup element which an empty one is not.
568+ (for [[i keys] (map-indexed vector rows)]
569+ [:hbox {:key i :spacing 8}
570+ (for [key keys]
571+ [call-tile width key])])]))
572+
573+(defn- call-controls
574+ "What the person in a call can do about it.
575+
576+ Mute and deafen are separate buttons because they are separate things: a
577+ deafened microphone still carries your voice, and one control for both would
578+ make the quieter of the two a surprise."
579+ []
580+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
581+ [:vbox {:key :call-controls :spacing 6}
582+ [:hbox {:spacing 8}
583+ [:label {:label (case media
584+ :waiting "Asking to join…"
585+ :dialling "Connecting…"
586+ :live "In call"
587+ :failed "Call failed"
588+ "In call")}]
589+ ;; A microphone that is not there is worth saying so: the call works,
590+ ;; and the person is listening rather than silent by choice.
591+ (when (and (= :live media) (not has-mic?))
592+ [:dim-label {:label "· listening only"}])]
593+ [:hbox {:spacing 8}
594+ [:button {:label (if muted? "Unmute" "Mute")
595+ :on-click #(av/set-muted! (not muted?))}]
596+ [:button {:label (if speaker-muted? "Undeafen" "Deafen")
597+ :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
598+ ;; Only offered when there is a camera to turn on. Nothing is more
599+ ;; annoying than a control that does nothing and does not say why.
600+ (when has-camera?
601+ [:button {:label (if camera? "Stop video" "Start video")
602+ :on-click #(av/set-camera! (not camera?))}])
603+ [:button {:label "Leave" :on-click #(s/leave-call!)}]]
604+ (when-let [e @av/media-error]
605+ [:dim-label {:label (str "" e)}])]))
606+
607+(defn call-bar
608+ "The call in this channel, whatever state it is in. Always a node.
609+
610+ Three cases, and the empty one matters as much as the others: a channel with
611+ no call must render *something* here, because the reconciler matches children
612+ by position and a banner that came and went would patch the message list into
613+ a button."
614+ [channel]
615+ [:vbox {:key :call-bar :spacing 6}
616+ (cond
617+ (av/in-call? channel)
618+ [:card {}
619+ [:vbox {:spacing 6}
620+ [call-controls]
621+ [call-wall]]]
622+
623+ ;; A call is open in this room and we are not in it.
624+ (av/call-in channel)
625+ (let [{:keys [session-id participants title]} (av/call-in channel)]
626+ [:card {}
627+ [:hbox {:spacing 8}
628+ [:label {:label (str "📞 " (or title "Call in progress")
629+ (if (and participants (pos? participants))
630+ (str " · " participants)
631+ ""))}]
632+ [:button {:label "Join"
633+ :on-click #(s/join-call! channel session-id)}]]])
634+
635+ ;; We are in a call, but in a different room. Say which, since the
636+ ;; controls are not on this screen to be found by looking.
637+ (av/in-call?)
638+ [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
639+
640+ :else nil)])
641+
522642 (defn image-picker-screen
523643 "The pictures on this device, to send one of.
524644
@@ -584,9 +704,25 @@
584704 ;; bottom of a screen wants is above it, not beneath it.
585705 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}
586706 [:hbox {:spacing 8}
587- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}]
588- [:title {:label (or name "Chat")}]]
707+ ;; The way back to the list, on a window with room for one thing at a
708+ ;; time. Beside the list there is nothing to go back to, so the button
709+ ;; goes in a wrapper of its own, since a child that comes and goes
710+ ;; would otherwise renumber the row for the reconciler.
711+ [:vbox {:key :back}
712+ (when-not (s/wide?)
713+ [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
714+ [:title {:label (or name "Chat")}]
715+ ;; Same wrapper trick: only in a channel, and only when there is no call
716+ ;; to join already the bar below offers Join in that case, and two ways
717+ ;; into the same call is one more than anybody needs.
718+ [:vbox {:key :call}
719+ (when (and name
720+ (str/starts-with? name "#")
721+ (not (av/call-in name))
722+ (not (av/in-call?)))
723+ [:button {:label "Call" :on-click #(s/start-call! name)}])]]
589724 [error-note]
725+ [call-bar name]
590726 ;; :reserve leaves room for everything below: the jump button's row, the
591727 ;; separator and the compose bar. It does not vary with whether the button
592728 ;; is showing, and neither does that row a reserve that changed would
@@ -660,6 +796,46 @@
660796 :on-activate s/send-draft!}]
661797 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
662798
799+;; ---------------------------------------------------------------- split
800+
801+(def ^:private sidebar-width 320)
802+
803+(defn- no-chat-pane
804+ "What fills the second pane before a conversation has been picked. The pane
805+ is there either way a list that widened into two columns and back again as
806+ channels were opened would be a worse answer than an empty half."
807+ []
808+ [:vbox {:spacing 8 :margin 12}
809+ [:title {:label "frq"}]
810+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]]])
811+
812+(defn split-screen
813+ "The chats list and the conversation side by side, for a window wide enough
814+ to hold both.
815+
816+ Same components as the narrow layout, in a row instead of one at a time: the
817+ list keeps its own scroll and its own tab bar, and the conversation keeps the
818+ compose bar pinned under a backlog that scrolls on its own.
819+
820+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
821+ row, which at the moment it is placed is one button and the list and the
822+ message backlog both size themselves against the height they are handed.
823+
824+ Only the list is given a width. The conversation takes what is left, rather
825+ than the window's width minus the list's: the list costs a little more than
826+ its 320 (its page has padding of its own), and a second column asking for
827+ more than remains is wrapped onto a row below painting the whole
828+ conversation off the bottom of the window, which reads exactly like a
829+ conversation that has gone missing."
830+ []
831+ [:hbox {:spacing 0 :wrap false}
832+ [:vbox {:key :list :width-request sidebar-width :fill-height true}
833+ [chats-screen]]
834+ [:vbox {:key :chat :fill-height true}
835+ (if @s/current
836+ [chat-screen]
837+ [no-chat-pane])]])
838+
663839 ;; ---------------------------------------------------------------- discover
664840
665841 (defn discover-screen []
@@ -719,13 +895,17 @@
719895 (cond
720896 @s/lightbox [lightbox-screen]
721897 @s/image-picker [image-picker-screen]
722- :else
723- (case @s/screen
724- :connect [connect-screen]
725- :chat [chat-screen]
726- :discover [discover-screen]
727- :settings [settings-screen]
728- [chats-screen])))
898+ ;; Wide enough for both, and on one of the two screens that are halves of
899+ ;; the same thing: the list and the conversation it opens. Discover and
900+ ;; settings stay whole screens they are somewhere else, not the other
901+ ;; half of here.
902+ (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen]
903+ :else (case @s/screen
904+ :connect [connect-screen]
905+ :chat [chat-screen]
906+ :discover [discover-screen]
907+ :settings [settings-screen]
908+ [chats-screen])))
729909
730910 (defn -main [& _]
731911 ;; Before the window: the rooms this client has been in, and a saved sign-in
@@ -739,4 +919,22 @@
739919 ;; user should be looking at while this happens, and if it fails, the
740920 ;; error lands somewhere visible.
741921 (vidya/after! 150 s/connect!))
922+ ;; Calls arrive rather than being asked for, so the media plane is drained
923+ ;; every frame whether or not one is up the drain costs a single integer
924+ ;; read when it is not. It has to be a timer: `frame-rgba!` and everything
925+ ;; else that touches a node belongs to the loop thread, and this is glimmer's
926+ ;; way of getting onto it.
927+ (av/init-logging!)
928+ ;; Who to tell when a call ends under us rather than at our asking. Set here
929+ ;; rather than in frq.av because sending a TAGMSG needs the connection, and
930+ ;; that belongs to the state layer.
931+ (reset! av/on-dropped s/announce-leave!)
932+ (vidya/after! 0 av/install-pump!)
933+ ;; The window's width, into a ratom, a few times a second. Polled rather than
934+ ;; delivered: the backend reports a size by writing it onto the window node,
935+ ;; and only what a component derefs re-renders so the layout follows a drag
936+ ;; of the window's edge without every frame touching the tree.
937+ (vidya/every! 200 #(let [w (vidya/window-width)]
938+ (when (not= w @s/window-width)
939+ (reset! s/window-width w))))
742940 (ui/run app :title "frq" :width 520 :height 860))
@@ -8,6 +8,7 @@
8 [glimmer.ratom :as r :refer [atom]]8 [glimmer.ratom :as r :refer [atom]]
9 [glimmer.core :as ui]9 [glimmer.core :as ui]
10 [glimmer-vidya.core :as vidya]10 [glimmer-vidya.core :as vidya]
11+ [frq.av :as av]
11 [frq.avatars :as avatars]12 [frq.avatars :as avatars]
12 [frq.clock :as clock]13 [frq.clock :as clock]
13 [frq.media :as media]14 [frq.media :as media]
@@ -164,9 +165,16 @@
164 [:button {:label "Join"165 [:button {:label "Join"
165 :kind :primary166 :kind :primary
166 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]]167 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]]
167- [:entry {:text @s/search168+ ;; The clear button only shows while there is something to clear: an
168- :placeholder "Search channels"169+ ;; empty box has nothing to undo, and a dead button beside it reads as
169- :on-change #(reset! s/search %)}]170+ ;; one that stopped working.
171+ [:hbox {:spacing 6}
172+ [:entry {:text @s/search
173+ :width-request 380
174+ :placeholder "Search channels"
175+ :on-change #(reset! s/search %)}]
176+ (when (seq @s/search)
177+ [:button {:label "" :on-click #(reset! s/search "")}])]
170 (if (seq buffers)178 (if (seq buffers)
171 (for [b buffers] [conversation-row b])179 (for [b buffers] [conversation-row b])
172 [:card {} [:dim-label {:label "No conversations yet — join a channel."}]])180 [:card {} [:dim-label {:label "No conversations yet — join a channel."}]])
@@ -519,6 +527,118 @@
519 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]527 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
520 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))528 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
521 529
530+(defn- call-tile
531+ "One participant's picture, at the width the row worked out for it.
532+
533+ `:feed` rather than `:src`: these pixels never touch the disk and never
534+ become a value here the media plane hands the decoder's own buffer to
535+ Vidya as a pointer, and the tag paints whatever arrived last under that name.
536+
537+ The height is three quarters of the width, which is the shape a camera hands
538+ over. Naming both keeps a portrait phone from making its tile tall enough to
539+ push the row off the screen the picture is fitted inside, never stretched."
540+ [width key]
541+ (let [mine? (= av/local-feed key)]
542+ [:vbox {:key key :spacing 2}
543+ ;; `:upscale` because a tile is a slot the layout sized, not a picture
544+ ;; sitting at whatever the camera happened to send. Without it a 480-wide
545+ ;; stream draws 480 wide in a 900-point slot and the wall looks broken
546+ ;; which is exactly what it did.
547+ [:image {:feed key
548+ :upscale true
549+ :max-width width
550+ :max-height (long (* width 0.75))}]
551+ [:dim-label {:label (if mine? "You" key)}]]))
552+
553+(defn- call-wall
554+ "Everyone with a camera on, sized to the window they are being watched in.
555+
556+ A call with no video is the normal case and should look like one a row of
557+ empty frames would suggest something had failed to load.
558+
559+ Both cells this reads are what subscribe it: `av/tiles` for who is on
560+ screen, and the window width so the tiles follow a window being dragged.
561+ Without either it would lay itself out once, on the first frame, and keep
562+ that shape for the rest of the call."
563+ []
564+ (let [[width rows] (av/tile-rows)]
565+ [:vbox {:key :call-wall :spacing 6}
566+ ;; A seq, not a vector: children splice, and a vector would be read as one
567+ ;; more hiccup element which an empty one is not.
568+ (for [[i keys] (map-indexed vector rows)]
569+ [:hbox {:key i :spacing 8}
570+ (for [key keys]
571+ [call-tile width key])])]))
572+
573+(defn- call-controls
574+ "What the person in a call can do about it.
575+
576+ Mute and deafen are separate buttons because they are separate things: a
577+ deafened microphone still carries your voice, and one control for both would
578+ make the quieter of the two a surprise."
579+ []
580+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
581+ [:vbox {:key :call-controls :spacing 6}
582+ [:hbox {:spacing 8}
583+ [:label {:label (case media
584+ :waiting "Asking to join…"
585+ :dialling "Connecting…"
586+ :live "In call"
587+ :failed "Call failed"
588+ "In call")}]
589+ ;; A microphone that is not there is worth saying so: the call works,
590+ ;; and the person is listening rather than silent by choice.
591+ (when (and (= :live media) (not has-mic?))
592+ [:dim-label {:label "· listening only"}])]
593+ [:hbox {:spacing 8}
594+ [:button {:label (if muted? "Unmute" "Mute")
595+ :on-click #(av/set-muted! (not muted?))}]
596+ [:button {:label (if speaker-muted? "Undeafen" "Deafen")
597+ :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
598+ ;; Only offered when there is a camera to turn on. Nothing is more
599+ ;; annoying than a control that does nothing and does not say why.
600+ (when has-camera?
601+ [:button {:label (if camera? "Stop video" "Start video")
602+ :on-click #(av/set-camera! (not camera?))}])
603+ [:button {:label "Leave" :on-click #(s/leave-call!)}]]
604+ (when-let [e @av/media-error]
605+ [:dim-label {:label (str "" e)}])]))
606+
607+(defn call-bar
608+ "The call in this channel, whatever state it is in. Always a node.
609+
610+ Three cases, and the empty one matters as much as the others: a channel with
611+ no call must render *something* here, because the reconciler matches children
612+ by position and a banner that came and went would patch the message list into
613+ a button."
614+ [channel]
615+ [:vbox {:key :call-bar :spacing 6}
616+ (cond
617+ (av/in-call? channel)
618+ [:card {}
619+ [:vbox {:spacing 6}
620+ [call-controls]
621+ [call-wall]]]
622+
623+ ;; A call is open in this room and we are not in it.
624+ (av/call-in channel)
625+ (let [{:keys [session-id participants title]} (av/call-in channel)]
626+ [:card {}
627+ [:hbox {:spacing 8}
628+ [:label {:label (str "📞 " (or title "Call in progress")
629+ (if (and participants (pos? participants))
630+ (str " · " participants)
631+ ""))}]
632+ [:button {:label "Join"
633+ :on-click #(s/join-call! channel session-id)}]]])
634+
635+ ;; We are in a call, but in a different room. Say which, since the
636+ ;; controls are not on this screen to be found by looking.
637+ (av/in-call?)
638+ [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
639+
640+ :else nil)])
641+
522 (defn image-picker-screen642 (defn image-picker-screen
523 "The pictures on this device, to send one of.643 "The pictures on this device, to send one of.
524 644
@@ -584,9 +704,25 @@
584 ;; bottom of a screen wants is above it, not beneath it.704 ;; bottom of a screen wants is above it, not beneath it.
585 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}705 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}
586 [:hbox {:spacing 8}706 [:hbox {:spacing 8}
587- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}]707+ ;; The way back to the list, on a window with room for one thing at a
588- [:title {:label (or name "Chat")}]]708+ ;; time. Beside the list there is nothing to go back to, so the button
709+ ;; goes in a wrapper of its own, since a child that comes and goes
710+ ;; would otherwise renumber the row for the reconciler.
711+ [:vbox {:key :back}
712+ (when-not (s/wide?)
713+ [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
714+ [:title {:label (or name "Chat")}]
715+ ;; Same wrapper trick: only in a channel, and only when there is no call
716+ ;; to join already the bar below offers Join in that case, and two ways
717+ ;; into the same call is one more than anybody needs.
718+ [:vbox {:key :call}
719+ (when (and name
720+ (str/starts-with? name "#")
721+ (not (av/call-in name))
722+ (not (av/in-call?)))
723+ [:button {:label "Call" :on-click #(s/start-call! name)}])]]
589 [error-note]724 [error-note]
725+ [call-bar name]
590 ;; :reserve leaves room for everything below: the jump button's row, the726 ;; :reserve leaves room for everything below: the jump button's row, the
591 ;; separator and the compose bar. It does not vary with whether the button727 ;; separator and the compose bar. It does not vary with whether the button
592 ;; is showing, and neither does that row a reserve that changed would728 ;; is showing, and neither does that row a reserve that changed would
@@ -660,6 +796,46 @@
660 :on-activate s/send-draft!}]796 :on-activate s/send-draft!}]
661 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))797 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
662 798
799+;; ---------------------------------------------------------------- split
800+
801+(def ^:private sidebar-width 320)
802+
803+(defn- no-chat-pane
804+ "What fills the second pane before a conversation has been picked. The pane
805+ is there either way a list that widened into two columns and back again as
806+ channels were opened would be a worse answer than an empty half."
807+ []
808+ [:vbox {:spacing 8 :margin 12}
809+ [:title {:label "frq"}]
810+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]]])
811+
812+(defn split-screen
813+ "The chats list and the conversation side by side, for a window wide enough
814+ to hold both.
815+
816+ Same components as the narrow layout, in a row instead of one at a time: the
817+ list keeps its own scroll and its own tab bar, and the conversation keeps the
818+ compose bar pinned under a backlog that scrolls on its own.
819+
820+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
821+ row, which at the moment it is placed is one button and the list and the
822+ message backlog both size themselves against the height they are handed.
823+
824+ Only the list is given a width. The conversation takes what is left, rather
825+ than the window's width minus the list's: the list costs a little more than
826+ its 320 (its page has padding of its own), and a second column asking for
827+ more than remains is wrapped onto a row below painting the whole
828+ conversation off the bottom of the window, which reads exactly like a
829+ conversation that has gone missing."
830+ []
831+ [:hbox {:spacing 0 :wrap false}
832+ [:vbox {:key :list :width-request sidebar-width :fill-height true}
833+ [chats-screen]]
834+ [:vbox {:key :chat :fill-height true}
835+ (if @s/current
836+ [chat-screen]
837+ [no-chat-pane])]])
838+
663 ;; ---------------------------------------------------------------- discover839 ;; ---------------------------------------------------------------- discover
664 840
665 (defn discover-screen []841 (defn discover-screen []
@@ -719,13 +895,17 @@
719 (cond895 (cond
720 @s/lightbox [lightbox-screen]896 @s/lightbox [lightbox-screen]
721 @s/image-picker [image-picker-screen]897 @s/image-picker [image-picker-screen]
722- :else898+ ;; Wide enough for both, and on one of the two screens that are halves of
723- (case @s/screen899+ ;; the same thing: the list and the conversation it opens. Discover and
724- :connect [connect-screen]900+ ;; settings stay whole screens they are somewhere else, not the other
725- :chat [chat-screen]901+ ;; half of here.
726- :discover [discover-screen]902+ (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen]
727- :settings [settings-screen]903+ :else (case @s/screen
728- [chats-screen])))904+ :connect [connect-screen]
905+ :chat [chat-screen]
906+ :discover [discover-screen]
907+ :settings [settings-screen]
908+ [chats-screen])))
729 909
730 (defn -main [& _]910 (defn -main [& _]
731 ;; Before the window: the rooms this client has been in, and a saved sign-in911 ;; Before the window: the rooms this client has been in, and a saved sign-in
@@ -739,4 +919,22 @@
739 ;; user should be looking at while this happens, and if it fails, the919 ;; user should be looking at while this happens, and if it fails, the
740 ;; error lands somewhere visible.920 ;; error lands somewhere visible.
741 (vidya/after! 150 s/connect!))921 (vidya/after! 150 s/connect!))
922+ ;; Calls arrive rather than being asked for, so the media plane is drained
923+ ;; every frame whether or not one is up the drain costs a single integer
924+ ;; read when it is not. It has to be a timer: `frame-rgba!` and everything
925+ ;; else that touches a node belongs to the loop thread, and this is glimmer's
926+ ;; way of getting onto it.
927+ (av/init-logging!)
928+ ;; Who to tell when a call ends under us rather than at our asking. Set here
929+ ;; rather than in frq.av because sending a TAGMSG needs the connection, and
930+ ;; that belongs to the state layer.
931+ (reset! av/on-dropped s/announce-leave!)
932+ (vidya/after! 0 av/install-pump!)
933+ ;; The window's width, into a ratom, a few times a second. Polled rather than
934+ ;; delivered: the backend reports a size by writing it onto the window node,
935+ ;; and only what a component derefs re-renders so the layout follows a drag
936+ ;; of the window's edge without every frame touching the tree.
937+ (vidya/every! 200 #(let [w (vidya/window-width)]
938+ (when (not= w @s/window-width)
939+ (reset! s/window-width w))))
742 (ui/run app :title "frq" :width 520 :height 860))940 (ui/run app :title "frq" :width 520 :height 860))
added src/frq/av.jolt +649 -0
new file mode 100644
@@ -0,0 +1,649 @@
1+(ns frq.av
2+ "Calls: the signaling, in jolt, and a handle on the media plane, which is not.
3+
4+ A freeq call has two halves. The one that is written here is *signaling*
5+ `+freeq.at/av-start`, `av-join` and `av-leave` go out as TAGMSGs and the
6+ server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
7+ so it is written in the language the rest of the client is written in.
8+
9+ The other half is audio and video over MoQ, and it is three thousand lines of
10+ Opus, H.264, QUIC and capture that would be written badly a second time in
11+ any other language. It lives in `libjoltmoq`, extracted from sleek, and this
12+ namespace is the whole of what frq says to it.
13+
14+ Two rules come from that side and shape everything here:
15+
16+ * **Nothing calls back.** Status and video are *polled* `pump!` drains both
17+ and is called from a timer, which glimmer runs on the loop thread.
18+ * **A video frame is borrowed**, valid only until the next poll. `pump!`
19+ hands each one to Vidya as it arrives and never keeps one, which is also
20+ why a frame never becomes a jolt value: it goes from the decoder's buffer
21+ to the texture as a pointer, and is never copied on this side at all."
22+ (:require [clojure.string :as str]
23+ [glimmer.ratom :as r :refer [atom]]
24+ [glimmer-vidya.core :as vidya]
25+ [frq.irc :as irc]
26+ [jolt.ffi :as ffi]))
27+
28+;; --- the media plane ---------------------------------------------------------
29+;; `libjoltmoq` is declared in deps.edn's `:jolt/native` beside libvidya, so the
30+;; loader has already found it by the time these resolve.
31+
32+(ffi/defcfn raw-init-logging "joltmoq_init_logging" [] :void)
33+(ffi/defcfn raw-start "joltmoq_start"
34+ [:string :string :string :string :int :int :int :string :string :string] :int)
35+(ffi/defcfn raw-stop "joltmoq_stop" [] :void)
36+(ffi/defcfn raw-is-live "joltmoq_is_live" [] :int)
37+
38+(ffi/defcfn raw-set-muted "joltmoq_set_muted" [:int] :void)
39+(ffi/defcfn raw-set-speaker-muted "joltmoq_set_speaker_muted" [:int] :void)
40+(ffi/defcfn raw-set-camera "joltmoq_set_camera" [:int] :void)
41+(ffi/defcfn raw-set-camera-device "joltmoq_set_camera_device" [:string] :void)
42+(ffi/defcfn raw-set-mic-device "joltmoq_set_mic_device" [:string] :void)
43+(ffi/defcfn raw-set-speaker-device "joltmoq_set_speaker_device" [:string] :void)
44+(ffi/defcfn mic-level "joltmoq_mic_level" [] :double)
45+
46+(ffi/defcfn raw-poll-status "joltmoq_poll_status" [] :int)
47+(ffi/defcfn status-text "joltmoq_status_text" [] :string)
48+(ffi/defcfn raw-status-has-camera "joltmoq_status_has_camera" [] :int)
49+(ffi/defcfn raw-status-has-mic "joltmoq_status_has_mic" [] :int)
50+
51+(ffi/defcfn raw-frame-poll "joltmoq_frame_poll" [] :int)
52+(ffi/defcfn frame-key "joltmoq_frame_key" [] :string)
53+(ffi/defcfn frame-width "joltmoq_frame_width" [] :int)
54+(ffi/defcfn frame-height "joltmoq_frame_height" [] :int)
55+;; The one pointer that crosses. Read as `:pointer` rather than bytes on
56+;; purpose: it goes straight back out to `vidya/frame-rgba!`, so the pixels
57+;; never become a jolt value and are never copied on this side.
58+(ffi/defcfn frame-rgba "joltmoq_frame_rgba" [] :pointer)
59+(ffi/defcfn video-keys "joltmoq_video_keys" [] :string)
60+
61+(ffi/defcfn raw-cameras "joltmoq_cameras" [] :string)
62+(ffi/defcfn raw-microphones "joltmoq_microphones" [] :string)
63+(ffi/defcfn raw-speakers "joltmoq_speakers" [] :string)
64+
65+(ffi/defcfn raw-sfu-url "joltmoq_sfu_url" [:string :string :string] :string)
66+(ffi/defcfn raw-can-dial "joltmoq_can_dial" [:string :string] :int)
67+(ffi/defcfn new-instance "joltmoq_new_instance" [] :string)
68+
69+;; C has no booleans and no absence; both are converted here rather than at
70+;; every call site, so the rest of this file is written in jolt's own terms.
71+(def ^:private status-none 0)
72+(def ^:private status-live 1)
73+(def ^:private status-ended 2)
74+(def ^:private status-failed 3)
75+
76+(defn- flag [b] (if b 1 0))
77+(defn- pref [s] (or s ""))
78+
79+(defn live? [] (not (zero? (raw-is-live))))
80+(defn can-dial?
81+ "Whether dialling this server is worth attempting. A remote SFU with no token
82+ accepts the connection and closes it, and the MoQ client then retries in a
83+ tight loop that looks exactly like a hang."
84+ [server jwt]
85+ (not (zero? (raw-can-dial (pref server) (pref jwt)))))
86+
87+(defn sfu-url
88+ "The SFU to dial for this server, or nil when the server is not one a URL can
89+ be made of."
90+ [server jwt instance]
91+ (let [u (raw-sfu-url (pref server) (pref jwt) (pref instance))]
92+ (when (seq u) u)))
93+
94+(defn- parse-devices
95+ "`id\\tname\\tdefault` a line into `{:id :name :default?}`.
96+
97+ Tab and newline delimit because a device name may hold anything else a
98+ webcam called \"EMEET SmartCam C960, Mono\" has spaces and a comma in it, and
99+ splitting on those gives nonsense."
100+ [text]
101+ (->> (str/split-lines (or text ""))
102+ (remove str/blank?)
103+ (mapv (fn [line]
104+ (let [[id name default] (str/split line #"\t")]
105+ {:id id
106+ :name (or name id)
107+ :default? (= "1" default)})))))
108+
109+(defn cameras [] (parse-devices (raw-cameras)))
110+(defn microphones [] (parse-devices (raw-microphones)))
111+(defn speakers [] (parse-devices (raw-speakers)))
112+
113+;; --- the signaling tags ------------------------------------------------------
114+;; Every one of these is a TAGMSG to the channel. The server answers with an
115+;; `+freeq.at/av-state` broadcast, which is what actually moves this client's
116+;; state nothing below assumes a request succeeded.
117+
118+(defn start-tags
119+ "Open a call on this channel."
120+ [instance title]
121+ (cond-> {"+freeq.at/av-start" ""
122+ "+freeq.at/av-instance" instance}
123+ (seq title) (assoc "+freeq.at/av-title" title)))
124+
125+(defn join-tags
126+ "Join the call already open on this channel."
127+ [session-id instance]
128+ {"+freeq.at/av-join" ""
129+ "+freeq.at/av-id" session-id
130+ "+freeq.at/av-instance" instance})
131+
132+(defn leave-tags
133+ [session-id instance]
134+ {"+freeq.at/av-leave" ""
135+ "+freeq.at/av-id" session-id
136+ "+freeq.at/av-instance" instance})
137+
138+(defn parse-state
139+ "An `+freeq.at/av-state` broadcast, or nil for any other TAGMSG.
140+
141+ Safe to apply to every TAGMSG that arrives: a reaction answers nil."
142+ [tags]
143+ (let [action (irc/tag-value tags "+freeq.at/av-state")]
144+ (when (contains? #{"started" "joined" "left" "ended"} action)
145+ {:action (keyword action)
146+ :session-id (or (irc/tag-value tags "+freeq.at/av-id") "")
147+ :actor (irc/tag-value tags "+freeq.at/av-actor")
148+ :participants (when-let [p (irc/tag-value tags "+freeq.at/av-participants")]
149+ (try (Integer/parseInt p) (catch Exception _ nil)))
150+ :title (let [t (irc/tag-value tags "+freeq.at/av-title")]
151+ (when (seq t) t))})))
152+
153+(defn state-message
154+ "The system line a state change is worth showing as."
155+ [{:keys [action actor participants title]}]
156+ (let [who (or actor "someone")
157+ n (if participants (str " · " participants " in call") "")]
158+ (case action
159+ :started (str "Call started by " who
160+ (if (seq title) (str "" title "") "")
161+ n)
162+ :joined (str who " joined the call" n)
163+ :left (str who " left the call" n)
164+ :ended (str "Call ended" n)
165+ "")))
166+
167+;; --- what the UI reads -------------------------------------------------------
168+
169+;; channel -> {:session-id :title :participants :last-actor}. What the server
170+;; says is happening in a room, whether or not we are in it: this is what puts
171+;; a "join the call" banner above a channel nobody here has joined.
172+(defonce channel-calls (atom {}))
173+
174+;; nil, or the call this device is in. `:media` is how far the other half has
175+;; got: :dialling until the plane says otherwise, then :live or :failed.
176+(defonce local-call (atom nil))
177+
178+;; The last thing the media plane failed with, for the line under the controls.
179+(defonce media-error (atom nil))
180+
181+;; What to do when this device stops being in a call without having asked to.
182+;;
183+;; The media plane failing is not the server hearing about it: freeq counts a
184+;; participant until an `av-leave` says otherwise. Dropping out quietly leaves
185+;; a ghost in the room, and pressing Join again adds a second one which is
186+;; how a channel ends up reporting seven people in a call with two.
187+;;
188+;; `frq.state` puts the TAGMSG here. This namespace cannot send one itself
189+;; without depending on the client that owns the connection.
190+(defonce on-dropped (atom nil))
191+
192+;; SFU tokens, by session id.
193+;;
194+;; The server mints one when you join, and does not necessarily mint another
195+;; when you join the same call again so a client that forgets it on the way
196+;; out has nothing to dial with on the way back in, and rejoining signals
197+;; correctly and then sits there with no video. Kept past the call for exactly
198+;; that, and overwritten whenever a fresh one arrives.
199+(defonce ^:private session-tokens (atom {}))
200+
201+;; Feeds pushed to Vidya, so the ones that stop can be dropped again. Without
202+;; this the last frame of someone who left hangs on the wall for the rest of
203+;; the call.
204+(defonce ^:private painted-feeds (atom #{}))
205+
206+;; Who has a picture, in the order they should be drawn.
207+;;
208+;; A cell rather than a question asked at render time, and that is the whole
209+;; point: glimmer re-renders a component when a ratom it read has changed, so a
210+;; view that asked the media plane directly would render once with nobody on
211+;; screen and never be told that someone had appeared. The frames would arrive,
212+;; be handed to Vidya, and paint into no node at all.
213+(defonce feeds (atom []))
214+
215+;; The window's width in points, so a tile can be a share of it rather than a
216+;; number somebody picked. A cell for the same reason `feeds` is one: a
217+;; component that asked the backend at render time would lay itself out once,
218+;; on the first frame, and keep that shape however the window was dragged.
219+(defonce window-width (atom 0))
220+(defonce window-height (atom 0))
221+
222+;; The self-view is keyed this way by the media plane; the UI wants to know
223+;; which tile is its own, to label it and to mirror nothing else.
224+(def local-feed "__local__")
225+
226+(declare stop-media!)
227+
228+(defn call-in [channel] (get @channel-calls channel))
229+
230+(defn in-call?
231+ ([] (some? @local-call))
232+ ([channel] (= channel (:channel @local-call))))
233+
234+(defn apply-state!
235+ "Fold an `+freeq.at/av-state` broadcast into what we know about `channel`.
236+
237+ The server is the authority on who is in a call, so this only writes what it
238+ was told. Ending clears the room; anything else updates the tally in place,
239+ because a `left` that omits the count should not reset it to nothing."
240+ [channel st]
241+ (let [{:keys [action session-id actor participants title]} st]
242+ (if (= :ended action)
243+ (swap! channel-calls dissoc channel)
244+ (swap! channel-calls update channel
245+ (fn [c]
246+ (cond-> (or c {:participants 0})
247+ true (assoc :session-id session-id)
248+ participants (assoc :participants participants)
249+ ;; A `started` with no count is one person: whoever started it.
250+ (and (nil? participants)
251+ (= :started action)
252+ (zero? (:participants (or c {:participants 0}))))
253+ (assoc :participants 1)
254+ actor (assoc :last-actor actor)
255+ title (assoc :title title)))))
256+ ;; A call we are in that has ended is one we are no longer in, whoever
257+ ;; ended it. Leaving the local call set would leave the controls up over
258+ ;; a session the SFU has already forgotten.
259+ (when (and (= :ended action) (in-call? channel))
260+ (stop-media!))
261+ ;; The server agreeing we are in the call is the other moment worth
262+ ;; dialling on. A join we opened optimistically has no session id until
263+ ;; this arrives and a *re*join often brings no token with it, because the
264+ ;; server already minted one for this session and does not mint a second.
265+ (when (and (not= :ended action) (in-call? channel))
266+ (when (and (seq session-id) (str/blank? (:session-id @local-call)))
267+ (swap! local-call assoc :session-id session-id))
268+ true)))
269+
270+;; --- the media plane, as this client uses it ---------------------------------
271+
272+(defn- drop-feeds!
273+ "Stop painting every feed we have been pushing."
274+ []
275+ (doseq [k @painted-feeds] (vidya/frame-drop! k))
276+ (reset! painted-feeds #{})
277+ (reset! feeds []))
278+
279+(defn stop-media!
280+ "Leave the media plane and forget the call, telling nobody.
281+
282+ For the two cases where the server already knows: it ended the call itself,
283+ or the caller is about to send an `av-leave` of its own. Anything else wants
284+ `dropped!`, or freeq goes on counting a participant who is not there."
285+ []
286+ (raw-stop)
287+ (drop-feeds!)
288+ (reset! feeds [])
289+ (reset! local-call nil)
290+ (reset! media-error nil))
291+
292+(defn dropped!
293+ "We are out of the call and did not ask to be — the media plane failed, or
294+ the transport went away under it.
295+
296+ Tells whoever registered `on-dropped` first, while the session id and
297+ instance it needs are still here to be read, and only then forgets them."
298+ []
299+ (when-let [announce @on-dropped]
300+ (when-let [call @local-call]
301+ (try (announce call) (catch Exception _ nil))))
302+ (stop-media!))
303+
304+(declare try-start-media!)
305+
306+(defn- start-media!
307+ "Dial the SFU for the call we have already joined over IRC.
308+
309+ Called once the server has minted a token for us, not when we asked to join:
310+ a remote SFU refuses a connection without one and the MoQ client then retries
311+ in a loop that looks exactly like a hang."
312+ [server]
313+ (when-let [{:keys [session-id instance token muted? speaker-muted? camera?
314+ camera-id mic-id speaker-id nick]} @local-call]
315+ (if-let [url (sfu-url server token instance)]
316+ (do
317+ (swap! local-call assoc :media :dialling)
318+ (reset! media-error nil)
319+ (when (zero? (raw-start url session-id (pref nick) instance
320+ (flag muted?) (flag speaker-muted?) (flag camera?)
321+ (pref camera-id) (pref mic-id) (pref speaker-id)))
322+ (swap! local-call assoc :media :failed)
323+ (reset! media-error "could not start the media plane")))
324+ (do (swap! local-call assoc :media :failed)
325+ (reset! media-error (str "no SFU for " server))))))
326+
327+(defn try-start-media!
328+ "Dial the SFU if there is a call to dial for, and we are not already on it.
329+
330+ Called from every signal that might mean the call is ready the server
331+ agreeing we joined, a token arriving, a rejoin because none of them is
332+ reliably the one that comes last. What makes that safe is the two guards:
333+ `can-dial?` refuses a remote SFU with no token rather than retrying in a
334+ loop that looks like a hang, and a call already up or on its way is left
335+ alone rather than re-dialled."
336+ [server]
337+ (when-let [{:keys [session-id token media]} @local-call]
338+ (when (and (seq session-id)
339+ (not (contains? #{:dialling :live} media))
340+ (can-dial? server token))
341+ (start-media! server))))
342+
343+(defn apply-token!
344+ "The server minted us an SFU token — remember it, and dial.
345+
346+ This arrives as a TAGMSG directed at our own nick rather than at the channel,
347+ so the buffer it came in on says nothing about which call it is for; the
348+ session id in the tag does. One naming a different session than ours is not
349+ ours.
350+
351+ Remembered past the end of the call, because the server does not always mint
352+ a second one when you rejoin the same session."
353+ [server session-id token]
354+ (when-let [lc @local-call]
355+ (when (or (str/blank? session-id)
356+ (str/blank? (:session-id lc))
357+ (= session-id (:session-id lc)))
358+ (let [sid (if (seq session-id) session-id (:session-id lc))]
359+ (when (seq sid)
360+ (swap! session-tokens assoc sid token))
361+ (swap! local-call
362+ #(-> %
363+ (assoc :token token :awaiting-start? false)
364+ (cond-> (seq sid) (assoc :session-id sid)))))
365+ (try-start-media! server))))
366+
367+(defn begin!
368+ "Record that this device is joining `channel`, before the server has agreed.
369+
370+ Optimistic on purpose: the controls appear on the press rather than a round
371+ trip later. `:awaiting-start?` is what a start that has not been answered yet
372+ looks like, and is how a collision is recognised as ours."
373+ [{:keys [channel session-id nick muted? speaker-muted? camera?
374+ camera-id mic-id speaker-id]}]
375+ (let [instance (new-instance)]
376+ (reset! media-error nil)
377+ (reset! local-call
378+ {:channel channel
379+ :session-id (or session-id "")
380+ :instance instance
381+ :nick nick
382+ ;; What we were given last time we were in this call, if
383+ ;; anything. A rejoin the server answers with no new token
384+ ;; still has something to dial with.
385+ :token (get @session-tokens (or session-id ""))
386+ :awaiting-start? (str/blank? (or session-id ""))
387+ :muted? (boolean muted?)
388+ :speaker-muted? (boolean speaker-muted?)
389+ :camera? (boolean camera?)
390+ :camera-id camera-id
391+ :mic-id mic-id
392+ :speaker-id speaker-id
393+ :media :waiting})
394+ instance))
395+
396+;; --- controls ----------------------------------------------------------------
397+;; Each writes the cell the UI reads *and* tells the media plane, so a control
398+;; answers on the press rather than a frame later. Muting the microphone and
399+;; muting the speaker are deliberately separate: deafening yourself still lets
400+;; peers hear you.
401+
402+(defn set-muted! [muted?]
403+ (swap! local-call assoc :muted? muted?)
404+ (raw-set-muted (flag muted?)))
405+
406+(defn set-speaker-muted! [muted?]
407+ (swap! local-call assoc :speaker-muted? muted?)
408+ (raw-set-speaker-muted (flag muted?)))
409+
410+(defn set-camera! [on?]
411+ (swap! local-call assoc :camera? on?)
412+ (raw-set-camera (flag on?))
413+ ;; The tile goes when the camera does: the plane stops publishing, so no
414+ ;; frame arrives to replace the last one.
415+ (when-not on?
416+ (vidya/frame-drop! local-feed)
417+ (swap! painted-feeds disj local-feed)))
418+
419+(defn set-camera-device! [id]
420+ (swap! local-call assoc :camera-id id)
421+ (raw-set-camera-device (pref id)))
422+
423+(defn set-mic-device! [id]
424+ (swap! local-call assoc :mic-id id)
425+ (raw-set-mic-device (pref id)))
426+
427+(defn set-speaker-device! [id]
428+ (swap! local-call assoc :speaker-id id)
429+ (raw-set-speaker-device (pref id)))
430+
431+;; --- the pump ----------------------------------------------------------------
432+
433+(defn- pump-status!
434+ "Drain what the media plane has learned since the last frame."
435+ []
436+ (loop []
437+ (let [code (raw-poll-status)]
438+ (when-not (= code status-none)
439+ (cond
440+ (= code status-live)
441+ (swap! local-call #(when %
442+ (assoc % :media :live
443+ :has-camera? (not (zero? (raw-status-has-camera)))
444+ :has-mic? (not (zero? (raw-status-has-mic))))))
445+
446+ ;; Both of these are the call ending underneath us rather than at
447+ ;; our request, so both have to be announced. A failure keeps the
448+ ;; local call up afterwards so the reason stays on screen but the
449+ ;; server is told either way, because we are no longer in the call
450+ ;; whether or not the person has read why yet.
451+ (= code status-ended)
452+ (dropped!)
453+
454+ (= code status-failed)
455+ (let [why (status-text)
456+ call @local-call]
457+ (when-let [announce @on-dropped]
458+ (when call (try (announce call) (catch Exception _ nil))))
459+ (reset! media-error why)
460+ (swap! local-call #(when % (assoc % :media :failed)))))
461+ (recur)))))
462+
463+(defn- pump-frames!
464+ "Hand every new frame straight to Vidya.
465+
466+ The pointer is borrowed until the next poll, so it is used and dropped inside
467+ this loop and never held. Nothing is copied on this side: the pixels go from
468+ the decoder's own buffer to a texture without becoming a jolt value at all,
469+ which is the only way a call at thirty frames a second is affordable here."
470+ []
471+ (loop []
472+ (when-not (zero? (raw-frame-poll))
473+ (let [key (frame-key)
474+ w (frame-width)
475+ h (frame-height)
476+ px (frame-rgba)]
477+ (when (and (seq key) (pos? w) (pos? h) (not (ffi/null? px)))
478+ (vidya/frame-rgba! key w h px)
479+ (swap! painted-feeds conj key)))
480+ (recur))))
481+
482+(defn- order-feeds
483+ "Everyone with a picture, the self-view last.
484+
485+ Last because it is the one whose subject the person can already see, so it
486+ belongs where it will not push a face they are talking to off the row."
487+ [keys]
488+ (let [mine (filter #(= local-feed %) keys)
489+ others (sort (remove #(= local-feed %) keys))]
490+ (vec (concat others mine))))
491+
492+(defn- pump-feeds!
493+ "Reconcile who has a picture: drop the tiles of anyone who has stopped, and
494+ publish the list for the view to render from.
495+
496+ The cell is only written when the set has actually changed. Writing it every
497+ frame would re-render the call wall sixty times a second to say the same
498+ thing, and every `:image` node under it would be rebuilt around a texture
499+ that was fine where it was."
500+ []
501+ (let [live (set (remove str/blank? (str/split-lines (or (video-keys) ""))))]
502+ (doseq [k (remove live @painted-feeds)]
503+ (vidya/frame-drop! k)
504+ (swap! painted-feeds disj k))
505+ (let [ordered (order-feeds live)]
506+ (when-not (= ordered @feeds)
507+ (reset! feeds ordered)))))
508+
509+(defn- pump-window!
510+ "Follow the window's width, so a call wall can divide it.
511+
512+ Outside the `live?` guard: the width is wanted the frame a call *starts*, and
513+ a cell first written at that moment would lay the wall out against a zero.
514+
515+ Written only when it changes, and only in whole points. A window being
516+ dragged produces a fractional width every frame, and a cell that took each
517+ one would re-render the wall for a third of a point of difference nobody can
518+ see."
519+ []
520+ (let [[w h] (vidya/screen-size)
521+ w (long w)
522+ h (long h)]
523+ (when-not (= w @window-width) (reset! window-width w))
524+ (when-not (= h @window-height) (reset! window-height h))))
525+
526+(defn pump!
527+ "One frame's worth of the media plane. Cheap when no call is up.
528+
529+ Runs on the loop thread `vidya/frame-rgba!` may not be called from anywhere
530+ else, and neither may anything that touches a node."
531+ []
532+ (pump-window!)
533+ (when (live?)
534+ (pump-status!)
535+ (pump-frames!)
536+ (pump-feeds!)))
537+
538+(defn init-logging!
539+ "Let the media plane talk to stderr, honouring `RUST_LOG`.
540+
541+ Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,
542+ and when a call misbehaves it is the only thing that knows why MoQ
543+ subscription, codec negotiation and device open all happen on the far side of
544+ the boundary, where no jolt-level trace can see them."
545+ []
546+ (raw-init-logging))
547+
548+(defn install-pump!
549+ "Start pumping the media plane every frame. Returns a timer id.
550+
551+ Sixteen milliseconds rather than a longer gap because this is where video
552+ arrives: polling slower than the window paints would show every other frame."
553+ []
554+ (vidya/every! 16 pump!))
555+
556+(defn tiles
557+ "Everyone with a picture in the current call, the self-view last.
558+
559+ Reads the cell the pump writes, so a component calling this re-renders when
560+ someone turns their camera on."
561+ []
562+ @feeds)
563+
564+;; What the wall cannot use: the window's own edges, the card the tiles sit in,
565+;; and the gaps between them. Measured against the chat screen's margins rather
566+;; than guessed 12 a side outside the card, 8 a side within it.
567+(def ^:private wall-chrome 44)
568+(def ^:private tile-gap 8)
569+
570+;; The size a tile wants to be. Columns are chosen to keep tiles near this
571+;; rather than to fit as many across as will physically go: four faces at a
572+;; hundred points each is worse than two rows of two at twice that, and a call
573+;; is people looking at each other, not a contact sheet.
574+(def ^:private want-tile 160)
575+
576+;; A tile narrower than this is not a face, it is a thumbnail of one.
577+(def ^:private min-tile 96)
578+
579+;; And an upper bound, so one person alone does not become a wall-sized
580+;; portrait. Generous rather than tight what actually stops tiles growing is
581+;; the height budget below, and this is only here so there is an answer on a
582+;; screen tall enough that it never binds.
583+(def ^:private max-tile 720)
584+
585+;; What a tile costs in height beyond its picture: the name under it, and the
586+;; gap to the row below.
587+(def ^:private tile-label 22)
588+
589+;; The wall's share of the window height.
590+;;
591+;; A third was the first guess and it was wrong: on a 1920x1060 screen it held
592+;; one person to a 440-point tile with fourteen hundred points of empty width
593+;; beside them, which reads as video that will not scale because in every way
594+;; the eye can tell, it does not. Half leaves the conversation legible under it
595+;; while letting a maximised window actually be worth maximising.
596+(def ^:private wall-share 0.5)
597+
598+(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols)))
599+
600+(defn tile-width
601+ "How wide a tile is with `n` across and `rows` down, in a window of `width`
602+ by `height` points.
603+
604+ Bounded by both axes, and the height is usually the one that binds. That is
605+ deliberate: a fixed ceiling meant a window dragged from half the screen to
606+ all of it changed the tiles by six points, because they were already against
607+ it. Deriving the ceiling from the wall's share of the height means a taller
608+ window really does make the faces bigger."
609+ [width height n rows]
610+ (let [n (max 1 n)
611+ rows (max 1 rows)
612+ across (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))
613+ by-width (quot across n)
614+ ;; What the row height allows, once the name and the gap are paid for.
615+ down (- (quot (long (* (or height 0) wall-share)) rows) tile-gap tile-label)
616+ by-height (long (/ (max 0 down) 0.75))]
617+ (-> (min by-width by-height)
618+ (max min-tile)
619+ (min max-tile))))
620+
621+(defn columns
622+ "How many tiles to put across, for the biggest tiles the window allows.
623+
624+ Every arrangement from one row to one column is tried and the roomiest wins,
625+ because neither axis alone decides it: more columns buy height by spending
626+ width, and which is worth more depends on the shape of the window. Ties go to
627+ fewer columns, which is the arrangement with fewer rows."
628+ [width height n]
629+ (let [n (max 1 n)]
630+ (reduce (fn [best cols]
631+ (if (> (tile-width width height cols (rows-for n cols))
632+ (tile-width width height best (rows-for n best)))
633+ cols
634+ best))
635+ 1
636+ (range 2 (inc n)))))
637+
638+(defn tile-rows
639+ "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw.
640+
641+ One width for every tile, not one per row: a last row holding a single person
642+ would otherwise draw them at twice the size of everybody above, which reads
643+ as though something had gone wrong rather than as a layout."
644+ ([] (tile-rows @window-width @window-height (tiles)))
645+ ([width height keys]
646+ (let [n (count keys)
647+ cols (columns width height n)]
648+ [(tile-width width height cols (rows-for n cols))
649+ (mapv vec (partition-all cols keys))])))
new file mode 100644
@@ -0,0 +1,649 @@
1+(ns frq.av
2+ "Calls: the signaling, in jolt, and a handle on the media plane, which is not.
3+
4+ A freeq call has two halves. The one that is written here is *signaling*
5+ `+freeq.at/av-start`, `av-join` and `av-leave` go out as TAGMSGs and the
6+ server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
7+ so it is written in the language the rest of the client is written in.
8+
9+ The other half is audio and video over MoQ, and it is three thousand lines of
10+ Opus, H.264, QUIC and capture that would be written badly a second time in
11+ any other language. It lives in `libjoltmoq`, extracted from sleek, and this
12+ namespace is the whole of what frq says to it.
13+
14+ Two rules come from that side and shape everything here:
15+
16+ * **Nothing calls back.** Status and video are *polled* `pump!` drains both
17+ and is called from a timer, which glimmer runs on the loop thread.
18+ * **A video frame is borrowed**, valid only until the next poll. `pump!`
19+ hands each one to Vidya as it arrives and never keeps one, which is also
20+ why a frame never becomes a jolt value: it goes from the decoder's buffer
21+ to the texture as a pointer, and is never copied on this side at all."
22+ (:require [clojure.string :as str]
23+ [glimmer.ratom :as r :refer [atom]]
24+ [glimmer-vidya.core :as vidya]
25+ [frq.irc :as irc]
26+ [jolt.ffi :as ffi]))
27+
28+;; --- the media plane ---------------------------------------------------------
29+;; `libjoltmoq` is declared in deps.edn's `:jolt/native` beside libvidya, so the
30+;; loader has already found it by the time these resolve.
31+
32+(ffi/defcfn raw-init-logging "joltmoq_init_logging" [] :void)
33+(ffi/defcfn raw-start "joltmoq_start"
34+ [:string :string :string :string :int :int :int :string :string :string] :int)
35+(ffi/defcfn raw-stop "joltmoq_stop" [] :void)
36+(ffi/defcfn raw-is-live "joltmoq_is_live" [] :int)
37+
38+(ffi/defcfn raw-set-muted "joltmoq_set_muted" [:int] :void)
39+(ffi/defcfn raw-set-speaker-muted "joltmoq_set_speaker_muted" [:int] :void)
40+(ffi/defcfn raw-set-camera "joltmoq_set_camera" [:int] :void)
41+(ffi/defcfn raw-set-camera-device "joltmoq_set_camera_device" [:string] :void)
42+(ffi/defcfn raw-set-mic-device "joltmoq_set_mic_device" [:string] :void)
43+(ffi/defcfn raw-set-speaker-device "joltmoq_set_speaker_device" [:string] :void)
44+(ffi/defcfn mic-level "joltmoq_mic_level" [] :double)
45+
46+(ffi/defcfn raw-poll-status "joltmoq_poll_status" [] :int)
47+(ffi/defcfn status-text "joltmoq_status_text" [] :string)
48+(ffi/defcfn raw-status-has-camera "joltmoq_status_has_camera" [] :int)
49+(ffi/defcfn raw-status-has-mic "joltmoq_status_has_mic" [] :int)
50+
51+(ffi/defcfn raw-frame-poll "joltmoq_frame_poll" [] :int)
52+(ffi/defcfn frame-key "joltmoq_frame_key" [] :string)
53+(ffi/defcfn frame-width "joltmoq_frame_width" [] :int)
54+(ffi/defcfn frame-height "joltmoq_frame_height" [] :int)
55+;; The one pointer that crosses. Read as `:pointer` rather than bytes on
56+;; purpose: it goes straight back out to `vidya/frame-rgba!`, so the pixels
57+;; never become a jolt value and are never copied on this side.
58+(ffi/defcfn frame-rgba "joltmoq_frame_rgba" [] :pointer)
59+(ffi/defcfn video-keys "joltmoq_video_keys" [] :string)
60+
61+(ffi/defcfn raw-cameras "joltmoq_cameras" [] :string)
62+(ffi/defcfn raw-microphones "joltmoq_microphones" [] :string)
63+(ffi/defcfn raw-speakers "joltmoq_speakers" [] :string)
64+
65+(ffi/defcfn raw-sfu-url "joltmoq_sfu_url" [:string :string :string] :string)
66+(ffi/defcfn raw-can-dial "joltmoq_can_dial" [:string :string] :int)
67+(ffi/defcfn new-instance "joltmoq_new_instance" [] :string)
68+
69+;; C has no booleans and no absence; both are converted here rather than at
70+;; every call site, so the rest of this file is written in jolt's own terms.
71+(def ^:private status-none 0)
72+(def ^:private status-live 1)
73+(def ^:private status-ended 2)
74+(def ^:private status-failed 3)
75+
76+(defn- flag [b] (if b 1 0))
77+(defn- pref [s] (or s ""))
78+
79+(defn live? [] (not (zero? (raw-is-live))))
80+(defn can-dial?
81+ "Whether dialling this server is worth attempting. A remote SFU with no token
82+ accepts the connection and closes it, and the MoQ client then retries in a
83+ tight loop that looks exactly like a hang."
84+ [server jwt]
85+ (not (zero? (raw-can-dial (pref server) (pref jwt)))))
86+
87+(defn sfu-url
88+ "The SFU to dial for this server, or nil when the server is not one a URL can
89+ be made of."
90+ [server jwt instance]
91+ (let [u (raw-sfu-url (pref server) (pref jwt) (pref instance))]
92+ (when (seq u) u)))
93+
94+(defn- parse-devices
95+ "`id\\tname\\tdefault` a line into `{:id :name :default?}`.
96+
97+ Tab and newline delimit because a device name may hold anything else a
98+ webcam called \"EMEET SmartCam C960, Mono\" has spaces and a comma in it, and
99+ splitting on those gives nonsense."
100+ [text]
101+ (->> (str/split-lines (or text ""))
102+ (remove str/blank?)
103+ (mapv (fn [line]
104+ (let [[id name default] (str/split line #"\t")]
105+ {:id id
106+ :name (or name id)
107+ :default? (= "1" default)})))))
108+
109+(defn cameras [] (parse-devices (raw-cameras)))
110+(defn microphones [] (parse-devices (raw-microphones)))
111+(defn speakers [] (parse-devices (raw-speakers)))
112+
113+;; --- the signaling tags ------------------------------------------------------
114+;; Every one of these is a TAGMSG to the channel. The server answers with an
115+;; `+freeq.at/av-state` broadcast, which is what actually moves this client's
116+;; state nothing below assumes a request succeeded.
117+
118+(defn start-tags
119+ "Open a call on this channel."
120+ [instance title]
121+ (cond-> {"+freeq.at/av-start" ""
122+ "+freeq.at/av-instance" instance}
123+ (seq title) (assoc "+freeq.at/av-title" title)))
124+
125+(defn join-tags
126+ "Join the call already open on this channel."
127+ [session-id instance]
128+ {"+freeq.at/av-join" ""
129+ "+freeq.at/av-id" session-id
130+ "+freeq.at/av-instance" instance})
131+
132+(defn leave-tags
133+ [session-id instance]
134+ {"+freeq.at/av-leave" ""
135+ "+freeq.at/av-id" session-id
136+ "+freeq.at/av-instance" instance})
137+
138+(defn parse-state
139+ "An `+freeq.at/av-state` broadcast, or nil for any other TAGMSG.
140+
141+ Safe to apply to every TAGMSG that arrives: a reaction answers nil."
142+ [tags]
143+ (let [action (irc/tag-value tags "+freeq.at/av-state")]
144+ (when (contains? #{"started" "joined" "left" "ended"} action)
145+ {:action (keyword action)
146+ :session-id (or (irc/tag-value tags "+freeq.at/av-id") "")
147+ :actor (irc/tag-value tags "+freeq.at/av-actor")
148+ :participants (when-let [p (irc/tag-value tags "+freeq.at/av-participants")]
149+ (try (Integer/parseInt p) (catch Exception _ nil)))
150+ :title (let [t (irc/tag-value tags "+freeq.at/av-title")]
151+ (when (seq t) t))})))
152+
153+(defn state-message
154+ "The system line a state change is worth showing as."
155+ [{:keys [action actor participants title]}]
156+ (let [who (or actor "someone")
157+ n (if participants (str " · " participants " in call") "")]
158+ (case action
159+ :started (str "Call started by " who
160+ (if (seq title) (str "" title "") "")
161+ n)
162+ :joined (str who " joined the call" n)
163+ :left (str who " left the call" n)
164+ :ended (str "Call ended" n)
165+ "")))
166+
167+;; --- what the UI reads -------------------------------------------------------
168+
169+;; channel -> {:session-id :title :participants :last-actor}. What the server
170+;; says is happening in a room, whether or not we are in it: this is what puts
171+;; a "join the call" banner above a channel nobody here has joined.
172+(defonce channel-calls (atom {}))
173+
174+;; nil, or the call this device is in. `:media` is how far the other half has
175+;; got: :dialling until the plane says otherwise, then :live or :failed.
176+(defonce local-call (atom nil))
177+
178+;; The last thing the media plane failed with, for the line under the controls.
179+(defonce media-error (atom nil))
180+
181+;; What to do when this device stops being in a call without having asked to.
182+;;
183+;; The media plane failing is not the server hearing about it: freeq counts a
184+;; participant until an `av-leave` says otherwise. Dropping out quietly leaves
185+;; a ghost in the room, and pressing Join again adds a second one which is
186+;; how a channel ends up reporting seven people in a call with two.
187+;;
188+;; `frq.state` puts the TAGMSG here. This namespace cannot send one itself
189+;; without depending on the client that owns the connection.
190+(defonce on-dropped (atom nil))
191+
192+;; SFU tokens, by session id.
193+;;
194+;; The server mints one when you join, and does not necessarily mint another
195+;; when you join the same call again so a client that forgets it on the way
196+;; out has nothing to dial with on the way back in, and rejoining signals
197+;; correctly and then sits there with no video. Kept past the call for exactly
198+;; that, and overwritten whenever a fresh one arrives.
199+(defonce ^:private session-tokens (atom {}))
200+
201+;; Feeds pushed to Vidya, so the ones that stop can be dropped again. Without
202+;; this the last frame of someone who left hangs on the wall for the rest of
203+;; the call.
204+(defonce ^:private painted-feeds (atom #{}))
205+
206+;; Who has a picture, in the order they should be drawn.
207+;;
208+;; A cell rather than a question asked at render time, and that is the whole
209+;; point: glimmer re-renders a component when a ratom it read has changed, so a
210+;; view that asked the media plane directly would render once with nobody on
211+;; screen and never be told that someone had appeared. The frames would arrive,
212+;; be handed to Vidya, and paint into no node at all.
213+(defonce feeds (atom []))
214+
215+;; The window's width in points, so a tile can be a share of it rather than a
216+;; number somebody picked. A cell for the same reason `feeds` is one: a
217+;; component that asked the backend at render time would lay itself out once,
218+;; on the first frame, and keep that shape however the window was dragged.
219+(defonce window-width (atom 0))
220+(defonce window-height (atom 0))
221+
222+;; The self-view is keyed this way by the media plane; the UI wants to know
223+;; which tile is its own, to label it and to mirror nothing else.
224+(def local-feed "__local__")
225+
226+(declare stop-media!)
227+
228+(defn call-in [channel] (get @channel-calls channel))
229+
230+(defn in-call?
231+ ([] (some? @local-call))
232+ ([channel] (= channel (:channel @local-call))))
233+
234+(defn apply-state!
235+ "Fold an `+freeq.at/av-state` broadcast into what we know about `channel`.
236+
237+ The server is the authority on who is in a call, so this only writes what it
238+ was told. Ending clears the room; anything else updates the tally in place,
239+ because a `left` that omits the count should not reset it to nothing."
240+ [channel st]
241+ (let [{:keys [action session-id actor participants title]} st]
242+ (if (= :ended action)
243+ (swap! channel-calls dissoc channel)
244+ (swap! channel-calls update channel
245+ (fn [c]
246+ (cond-> (or c {:participants 0})
247+ true (assoc :session-id session-id)
248+ participants (assoc :participants participants)
249+ ;; A `started` with no count is one person: whoever started it.
250+ (and (nil? participants)
251+ (= :started action)
252+ (zero? (:participants (or c {:participants 0}))))
253+ (assoc :participants 1)
254+ actor (assoc :last-actor actor)
255+ title (assoc :title title)))))
256+ ;; A call we are in that has ended is one we are no longer in, whoever
257+ ;; ended it. Leaving the local call set would leave the controls up over
258+ ;; a session the SFU has already forgotten.
259+ (when (and (= :ended action) (in-call? channel))
260+ (stop-media!))
261+ ;; The server agreeing we are in the call is the other moment worth
262+ ;; dialling on. A join we opened optimistically has no session id until
263+ ;; this arrives and a *re*join often brings no token with it, because the
264+ ;; server already minted one for this session and does not mint a second.
265+ (when (and (not= :ended action) (in-call? channel))
266+ (when (and (seq session-id) (str/blank? (:session-id @local-call)))
267+ (swap! local-call assoc :session-id session-id))
268+ true)))
269+
270+;; --- the media plane, as this client uses it ---------------------------------
271+
272+(defn- drop-feeds!
273+ "Stop painting every feed we have been pushing."
274+ []
275+ (doseq [k @painted-feeds] (vidya/frame-drop! k))
276+ (reset! painted-feeds #{})
277+ (reset! feeds []))
278+
279+(defn stop-media!
280+ "Leave the media plane and forget the call, telling nobody.
281+
282+ For the two cases where the server already knows: it ended the call itself,
283+ or the caller is about to send an `av-leave` of its own. Anything else wants
284+ `dropped!`, or freeq goes on counting a participant who is not there."
285+ []
286+ (raw-stop)
287+ (drop-feeds!)
288+ (reset! feeds [])
289+ (reset! local-call nil)
290+ (reset! media-error nil))
291+
292+(defn dropped!
293+ "We are out of the call and did not ask to be — the media plane failed, or
294+ the transport went away under it.
295+
296+ Tells whoever registered `on-dropped` first, while the session id and
297+ instance it needs are still here to be read, and only then forgets them."
298+ []
299+ (when-let [announce @on-dropped]
300+ (when-let [call @local-call]
301+ (try (announce call) (catch Exception _ nil))))
302+ (stop-media!))
303+
304+(declare try-start-media!)
305+
306+(defn- start-media!
307+ "Dial the SFU for the call we have already joined over IRC.
308+
309+ Called once the server has minted a token for us, not when we asked to join:
310+ a remote SFU refuses a connection without one and the MoQ client then retries
311+ in a loop that looks exactly like a hang."
312+ [server]
313+ (when-let [{:keys [session-id instance token muted? speaker-muted? camera?
314+ camera-id mic-id speaker-id nick]} @local-call]
315+ (if-let [url (sfu-url server token instance)]
316+ (do
317+ (swap! local-call assoc :media :dialling)
318+ (reset! media-error nil)
319+ (when (zero? (raw-start url session-id (pref nick) instance
320+ (flag muted?) (flag speaker-muted?) (flag camera?)
321+ (pref camera-id) (pref mic-id) (pref speaker-id)))
322+ (swap! local-call assoc :media :failed)
323+ (reset! media-error "could not start the media plane")))
324+ (do (swap! local-call assoc :media :failed)
325+ (reset! media-error (str "no SFU for " server))))))
326+
327+(defn try-start-media!
328+ "Dial the SFU if there is a call to dial for, and we are not already on it.
329+
330+ Called from every signal that might mean the call is ready the server
331+ agreeing we joined, a token arriving, a rejoin because none of them is
332+ reliably the one that comes last. What makes that safe is the two guards:
333+ `can-dial?` refuses a remote SFU with no token rather than retrying in a
334+ loop that looks like a hang, and a call already up or on its way is left
335+ alone rather than re-dialled."
336+ [server]
337+ (when-let [{:keys [session-id token media]} @local-call]
338+ (when (and (seq session-id)
339+ (not (contains? #{:dialling :live} media))
340+ (can-dial? server token))
341+ (start-media! server))))
342+
343+(defn apply-token!
344+ "The server minted us an SFU token — remember it, and dial.
345+
346+ This arrives as a TAGMSG directed at our own nick rather than at the channel,
347+ so the buffer it came in on says nothing about which call it is for; the
348+ session id in the tag does. One naming a different session than ours is not
349+ ours.
350+
351+ Remembered past the end of the call, because the server does not always mint
352+ a second one when you rejoin the same session."
353+ [server session-id token]
354+ (when-let [lc @local-call]
355+ (when (or (str/blank? session-id)
356+ (str/blank? (:session-id lc))
357+ (= session-id (:session-id lc)))
358+ (let [sid (if (seq session-id) session-id (:session-id lc))]
359+ (when (seq sid)
360+ (swap! session-tokens assoc sid token))
361+ (swap! local-call
362+ #(-> %
363+ (assoc :token token :awaiting-start? false)
364+ (cond-> (seq sid) (assoc :session-id sid)))))
365+ (try-start-media! server))))
366+
367+(defn begin!
368+ "Record that this device is joining `channel`, before the server has agreed.
369+
370+ Optimistic on purpose: the controls appear on the press rather than a round
371+ trip later. `:awaiting-start?` is what a start that has not been answered yet
372+ looks like, and is how a collision is recognised as ours."
373+ [{:keys [channel session-id nick muted? speaker-muted? camera?
374+ camera-id mic-id speaker-id]}]
375+ (let [instance (new-instance)]
376+ (reset! media-error nil)
377+ (reset! local-call
378+ {:channel channel
379+ :session-id (or session-id "")
380+ :instance instance
381+ :nick nick
382+ ;; What we were given last time we were in this call, if
383+ ;; anything. A rejoin the server answers with no new token
384+ ;; still has something to dial with.
385+ :token (get @session-tokens (or session-id ""))
386+ :awaiting-start? (str/blank? (or session-id ""))
387+ :muted? (boolean muted?)
388+ :speaker-muted? (boolean speaker-muted?)
389+ :camera? (boolean camera?)
390+ :camera-id camera-id
391+ :mic-id mic-id
392+ :speaker-id speaker-id
393+ :media :waiting})
394+ instance))
395+
396+;; --- controls ----------------------------------------------------------------
397+;; Each writes the cell the UI reads *and* tells the media plane, so a control
398+;; answers on the press rather than a frame later. Muting the microphone and
399+;; muting the speaker are deliberately separate: deafening yourself still lets
400+;; peers hear you.
401+
402+(defn set-muted! [muted?]
403+ (swap! local-call assoc :muted? muted?)
404+ (raw-set-muted (flag muted?)))
405+
406+(defn set-speaker-muted! [muted?]
407+ (swap! local-call assoc :speaker-muted? muted?)
408+ (raw-set-speaker-muted (flag muted?)))
409+
410+(defn set-camera! [on?]
411+ (swap! local-call assoc :camera? on?)
412+ (raw-set-camera (flag on?))
413+ ;; The tile goes when the camera does: the plane stops publishing, so no
414+ ;; frame arrives to replace the last one.
415+ (when-not on?
416+ (vidya/frame-drop! local-feed)
417+ (swap! painted-feeds disj local-feed)))
418+
419+(defn set-camera-device! [id]
420+ (swap! local-call assoc :camera-id id)
421+ (raw-set-camera-device (pref id)))
422+
423+(defn set-mic-device! [id]
424+ (swap! local-call assoc :mic-id id)
425+ (raw-set-mic-device (pref id)))
426+
427+(defn set-speaker-device! [id]
428+ (swap! local-call assoc :speaker-id id)
429+ (raw-set-speaker-device (pref id)))
430+
431+;; --- the pump ----------------------------------------------------------------
432+
433+(defn- pump-status!
434+ "Drain what the media plane has learned since the last frame."
435+ []
436+ (loop []
437+ (let [code (raw-poll-status)]
438+ (when-not (= code status-none)
439+ (cond
440+ (= code status-live)
441+ (swap! local-call #(when %
442+ (assoc % :media :live
443+ :has-camera? (not (zero? (raw-status-has-camera)))
444+ :has-mic? (not (zero? (raw-status-has-mic))))))
445+
446+ ;; Both of these are the call ending underneath us rather than at
447+ ;; our request, so both have to be announced. A failure keeps the
448+ ;; local call up afterwards so the reason stays on screen but the
449+ ;; server is told either way, because we are no longer in the call
450+ ;; whether or not the person has read why yet.
451+ (= code status-ended)
452+ (dropped!)
453+
454+ (= code status-failed)
455+ (let [why (status-text)
456+ call @local-call]
457+ (when-let [announce @on-dropped]
458+ (when call (try (announce call) (catch Exception _ nil))))
459+ (reset! media-error why)
460+ (swap! local-call #(when % (assoc % :media :failed)))))
461+ (recur)))))
462+
463+(defn- pump-frames!
464+ "Hand every new frame straight to Vidya.
465+
466+ The pointer is borrowed until the next poll, so it is used and dropped inside
467+ this loop and never held. Nothing is copied on this side: the pixels go from
468+ the decoder's own buffer to a texture without becoming a jolt value at all,
469+ which is the only way a call at thirty frames a second is affordable here."
470+ []
471+ (loop []
472+ (when-not (zero? (raw-frame-poll))
473+ (let [key (frame-key)
474+ w (frame-width)
475+ h (frame-height)
476+ px (frame-rgba)]
477+ (when (and (seq key) (pos? w) (pos? h) (not (ffi/null? px)))
478+ (vidya/frame-rgba! key w h px)
479+ (swap! painted-feeds conj key)))
480+ (recur))))
481+
482+(defn- order-feeds
483+ "Everyone with a picture, the self-view last.
484+
485+ Last because it is the one whose subject the person can already see, so it
486+ belongs where it will not push a face they are talking to off the row."
487+ [keys]
488+ (let [mine (filter #(= local-feed %) keys)
489+ others (sort (remove #(= local-feed %) keys))]
490+ (vec (concat others mine))))
491+
492+(defn- pump-feeds!
493+ "Reconcile who has a picture: drop the tiles of anyone who has stopped, and
494+ publish the list for the view to render from.
495+
496+ The cell is only written when the set has actually changed. Writing it every
497+ frame would re-render the call wall sixty times a second to say the same
498+ thing, and every `:image` node under it would be rebuilt around a texture
499+ that was fine where it was."
500+ []
501+ (let [live (set (remove str/blank? (str/split-lines (or (video-keys) ""))))]
502+ (doseq [k (remove live @painted-feeds)]
503+ (vidya/frame-drop! k)
504+ (swap! painted-feeds disj k))
505+ (let [ordered (order-feeds live)]
506+ (when-not (= ordered @feeds)
507+ (reset! feeds ordered)))))
508+
509+(defn- pump-window!
510+ "Follow the window's width, so a call wall can divide it.
511+
512+ Outside the `live?` guard: the width is wanted the frame a call *starts*, and
513+ a cell first written at that moment would lay the wall out against a zero.
514+
515+ Written only when it changes, and only in whole points. A window being
516+ dragged produces a fractional width every frame, and a cell that took each
517+ one would re-render the wall for a third of a point of difference nobody can
518+ see."
519+ []
520+ (let [[w h] (vidya/screen-size)
521+ w (long w)
522+ h (long h)]
523+ (when-not (= w @window-width) (reset! window-width w))
524+ (when-not (= h @window-height) (reset! window-height h))))
525+
526+(defn pump!
527+ "One frame's worth of the media plane. Cheap when no call is up.
528+
529+ Runs on the loop thread `vidya/frame-rgba!` may not be called from anywhere
530+ else, and neither may anything that touches a node."
531+ []
532+ (pump-window!)
533+ (when (live?)
534+ (pump-status!)
535+ (pump-frames!)
536+ (pump-feeds!)))
537+
538+(defn init-logging!
539+ "Let the media plane talk to stderr, honouring `RUST_LOG`.
540+
541+ Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,
542+ and when a call misbehaves it is the only thing that knows why MoQ
543+ subscription, codec negotiation and device open all happen on the far side of
544+ the boundary, where no jolt-level trace can see them."
545+ []
546+ (raw-init-logging))
547+
548+(defn install-pump!
549+ "Start pumping the media plane every frame. Returns a timer id.
550+
551+ Sixteen milliseconds rather than a longer gap because this is where video
552+ arrives: polling slower than the window paints would show every other frame."
553+ []
554+ (vidya/every! 16 pump!))
555+
556+(defn tiles
557+ "Everyone with a picture in the current call, the self-view last.
558+
559+ Reads the cell the pump writes, so a component calling this re-renders when
560+ someone turns their camera on."
561+ []
562+ @feeds)
563+
564+;; What the wall cannot use: the window's own edges, the card the tiles sit in,
565+;; and the gaps between them. Measured against the chat screen's margins rather
566+;; than guessed 12 a side outside the card, 8 a side within it.
567+(def ^:private wall-chrome 44)
568+(def ^:private tile-gap 8)
569+
570+;; The size a tile wants to be. Columns are chosen to keep tiles near this
571+;; rather than to fit as many across as will physically go: four faces at a
572+;; hundred points each is worse than two rows of two at twice that, and a call
573+;; is people looking at each other, not a contact sheet.
574+(def ^:private want-tile 160)
575+
576+;; A tile narrower than this is not a face, it is a thumbnail of one.
577+(def ^:private min-tile 96)
578+
579+;; And an upper bound, so one person alone does not become a wall-sized
580+;; portrait. Generous rather than tight what actually stops tiles growing is
581+;; the height budget below, and this is only here so there is an answer on a
582+;; screen tall enough that it never binds.
583+(def ^:private max-tile 720)
584+
585+;; What a tile costs in height beyond its picture: the name under it, and the
586+;; gap to the row below.
587+(def ^:private tile-label 22)
588+
589+;; The wall's share of the window height.
590+;;
591+;; A third was the first guess and it was wrong: on a 1920x1060 screen it held
592+;; one person to a 440-point tile with fourteen hundred points of empty width
593+;; beside them, which reads as video that will not scale because in every way
594+;; the eye can tell, it does not. Half leaves the conversation legible under it
595+;; while letting a maximised window actually be worth maximising.
596+(def ^:private wall-share 0.5)
597+
598+(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols)))
599+
600+(defn tile-width
601+ "How wide a tile is with `n` across and `rows` down, in a window of `width`
602+ by `height` points.
603+
604+ Bounded by both axes, and the height is usually the one that binds. That is
605+ deliberate: a fixed ceiling meant a window dragged from half the screen to
606+ all of it changed the tiles by six points, because they were already against
607+ it. Deriving the ceiling from the wall's share of the height means a taller
608+ window really does make the faces bigger."
609+ [width height n rows]
610+ (let [n (max 1 n)
611+ rows (max 1 rows)
612+ across (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))
613+ by-width (quot across n)
614+ ;; What the row height allows, once the name and the gap are paid for.
615+ down (- (quot (long (* (or height 0) wall-share)) rows) tile-gap tile-label)
616+ by-height (long (/ (max 0 down) 0.75))]
617+ (-> (min by-width by-height)
618+ (max min-tile)
619+ (min max-tile))))
620+
621+(defn columns
622+ "How many tiles to put across, for the biggest tiles the window allows.
623+
624+ Every arrangement from one row to one column is tried and the roomiest wins,
625+ because neither axis alone decides it: more columns buy height by spending
626+ width, and which is worth more depends on the shape of the window. Ties go to
627+ fewer columns, which is the arrangement with fewer rows."
628+ [width height n]
629+ (let [n (max 1 n)]
630+ (reduce (fn [best cols]
631+ (if (> (tile-width width height cols (rows-for n cols))
632+ (tile-width width height best (rows-for n best)))
633+ cols
634+ best))
635+ 1
636+ (range 2 (inc n)))))
637+
638+(defn tile-rows
639+ "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw.
640+
641+ One width for every tile, not one per row: a last row holding a single person
642+ would otherwise draw them at twice the size of everybody above, which reads
643+ as though something had gone wrong rather than as a layout."
644+ ([] (tile-rows @window-width @window-height (tiles)))
645+ ([width height keys]
646+ (let [n (count keys)
647+ cols (columns width height n)]
648+ [(tile-width width height cols (rows-for n cols))
649+ (mapv vec (partition-all cols keys))])))
modified src/frq/state.jolt +154 -4
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [jolt.host :as host]
1010 [frq.atproto :as atproto]
11+ [frq.av :as av]
1112 [frq.clock :as clock]
1213 [frq.emoji :as emoji]
1314 [frq.irc :as irc]
@@ -88,6 +89,32 @@
8889 (defonce lightbox (atom nil)) ; {:path :url}
8990 (defonce join-input (atom ""))
9091
92+;; The window's content width in points, polled from the backend a few times a
93+;; second. The app is laid out for a phone-width window, and this is what lets
94+;; a wide one be more than a phone with margins: past `wide-width` the channel
95+;; list and the conversation are both on screen instead of taking turns.
96+(defonce window-width (atom 0))
97+
98+;; Where the second pane starts paying for itself. Below this a 300pt list
99+;; beside a conversation leaves the messages narrower than the phone layout
100+;; they were written for.
101+(def wide-width 900)
102+
103+(defn wide?
104+ "True while the window has room for the list and a conversation at once."
105+ []
106+ (>= @window-width wide-width))
107+
108+(defn chat-visible?
109+ "Whether the conversation in `current` is on screen.
110+
111+ On a narrow window that is the chat screen alone. On a wide one the chats
112+ screen shows it too, in the pane beside the list — so this, and not the
113+ screen, is what decides whether an arriving line counts as unread."
114+ []
115+ (or (= :chat @screen)
116+ (and (wide?) (= :chats @screen))))
117+
91118 ;; Whether the chat view is showing the newest line, and a counter the view
92119 ;; watches to be told to go back to it. A counter rather than a flag: a flag
93120 ;; would need clearing, and there is no frame in which to clear it.
@@ -150,7 +177,7 @@
150177 (swap! channels
151178 (fn [m]
152179 (let [m (ensure-channel m channel)
153- viewing? (and (= :chat @screen) (= channel @current))]
180+ viewing? (and (chat-visible?) (= channel @current))]
154181 (-> m
155182 (update-in [channel :messages] conj
156183 {:from from :text text :system? (= "*" from)
@@ -170,7 +197,10 @@
170197 buffer stays), and opening one is a request to be in it."
171198 [name]
172199 (reset! current name)
173- (reset! screen :chat)
200+ ;; On a wide window the conversation lives in the chats screen's second
201+ ;; pane, beside the list; :chat is the narrow window's way of showing it
202+ ;; instead of the list, and there is nothing there to trade it for.
203+ (reset! screen (if (wide?) :chats :chat))
174204 ;; A picker belongs to the message it was opened on; carrying it into another
175205 ;; buffer would offer to react to something that is no longer on screen.
176206 (reset! reacting nil)
@@ -231,7 +261,60 @@
231261 msgs))
232262 m)))))
233263
234-(declare join!)
264+(declare join! join-call!)
265+
266+;; --- calls -------------------------------------------------------------------
267+;; Signaling only. The audio and video themselves are `frq.av`'s, and behind it
268+;; libjoltmoq's; what happens here is that the server's broadcasts become state
269+;; the screens can read, and a press becomes a TAGMSG.
270+
271+(defn apply-call-state!
272+ "A `+freeq.at/av-state` broadcast: fold it in, and say so in the buffer.
273+
274+ The system line is worth the space — a call is the one thing that happens in
275+ a channel while nobody types, and without a line saying so the only trace of
276+ someone joining is a number quietly changing in a banner."
277+ [channel st]
278+ (av/apply-state! channel st)
279+ ;; And try to dial. The token may already be in hand — from this join, or
280+ ;; from the last time we were in this same session — in which case the
281+ ;; server's agreement that we are in the call is the last thing we were
282+ ;; waiting for. `try-start-media!` refuses if there is nothing to dial with
283+ ;; or a call is already up, so calling it on every state change is safe.
284+ (when (av/in-call? channel)
285+ (av/try-start-media! @form-host))
286+ (let [line (av/state-message st)]
287+ (when (seq line)
288+ (push-message! channel "*" line))))
289+
290+(defn apply-call-error!
291+ "A `+freeq.at/av-error`. Most say the call failed; one says we lost a race.
292+
293+ `start-collision` means our `av-start` and someone else's crossed and theirs
294+ won. The server names the winning session, so the answer is to join that one
295+ rather than to report an error for something the person asked for and can
296+ have — they wanted to be in a call in this room, and there is one."
297+ [tags code]
298+ (let [reason (or (irc/tag-value tags "+freeq.at/av-reason") code)
299+ session-id (irc/tag-value tags "+freeq.at/av-id")
300+ lc @av/local-call
301+ channel (:channel lc)]
302+ (if (and (= "start-collision" code) (seq session-id) channel
303+ (or (:awaiting-start? lc) (str/blank? (:session-id lc))))
304+ (do
305+ (push-message! channel "*" "Call already open — joining it instead")
306+ (av/stop-media!)
307+ (join-call! channel session-id))
308+ (do
309+ (when channel
310+ (push-message! channel "*" (str "Call error: " reason)))
311+ ;; Only tear down a call the error is actually about. A `join-failed`
312+ ;; naming someone else's session is not ours to act on.
313+ (when (and lc
314+ (or (str/blank? (or session-id ""))
315+ (str/blank? (:session-id lc))
316+ (= session-id (:session-id lc))))
317+ (av/stop-media!))))))
235318
236319 (defn apply-msg!
237320 "Fold one parsed IRC message into the state."
@@ -280,10 +363,22 @@
280363 (irc/tag-value tags "+draft/reply"))
281364 add (or (irc/tag-value tags "+react")
282365 (irc/tag-value tags "+draft/react"))
283- remove-it (irc/tag-value tags "+freeq.at/unreact")]
366+ remove-it (irc/tag-value tags "+freeq.at/unreact")
367+ call-state (av/parse-state tags)
368+ ;; The token is directed at our own nick rather than at
369+ ;; the channel, so `buffer` is a DM key here and says
370+ ;; nothing about which call it is for. The session id in
371+ ;; the tag is what does.
372+ token (irc/tag-value tags "+freeq.at/av-token")
373+ call-error (irc/tag-value tags "+freeq.at/av-error")]
284374 (cond
285375 add (update-reaction! buffer msgid add from true)
286376 remove-it (update-reaction! buffer msgid remove-it from false)
377+ call-state (apply-call-state! buffer call-state)
378+ token (av/apply-token! @form-host
379+ (irc/tag-value tags "+freeq.at/av-id")
380+ token)
381+ call-error (apply-call-error! tags call-error)
287382 :else nil))
288383 "JOIN" (let [ch (first params)]
289384 (if (= from @form-nick)
@@ -856,6 +951,61 @@
856951 (irc/unreact! c channel msgid emoji)))
857952 (update-reaction! channel msgid emoji @form-nick on?))))
858953
954+(defn start-call!
955+ "Open a call on this channel.
956+
957+ Optimistic: the controls appear on the press. What comes back settles it —
958+ an `av-state` says the room has a call, an `av-token` starts the media, and
959+ a `start-collision` means someone beat us to it and we join theirs instead."
960+ [channel]
961+ (when-let [c @conn]
962+ (let [nick (or (:nick @session) @form-nick)
963+ instance (av/begin! {:channel channel
964+ :nick nick
965+ :muted? false
966+ :speaker-muted? false
967+ ;; Audio first, always. A call that opened with
968+ ;; the camera on would be a call that showed
969+ ;; someone's room before they had agreed to.
970+ :camera? false})]
971+ (irc/tagmsg! c channel (av/start-tags instance nil)))))
972+
973+(defn join-call!
974+ "Join the call already open on this channel."
975+ [channel session-id]
976+ (when-let [c @conn]
977+ (let [nick (or (:nick @session) @form-nick)
978+ instance (av/begin! {:channel channel
979+ :session-id session-id
980+ :nick nick
981+ :muted? false
982+ :speaker-muted? false
983+ :camera? false})]
984+ (irc/tagmsg! c channel (av/join-tags session-id instance)))))
985+
986+(defn announce-leave!
987+ "Tell the room this device is out of a call it did not choose to leave.
988+
989+ freeq counts a participant until an `av-leave` says otherwise, so a media
990+ plane that fails silently leaves a ghost behind — and the next Join adds
991+ another beside it. Registered with `frq.av` at startup, because that
992+ namespace has no connection to send on."
993+ [{:keys [channel session-id instance]}]
994+ (when (and @conn (seq (or session-id "")))
995+ (irc/tagmsg! @conn channel (av/leave-tags session-id instance))))
996+
997+(defn leave-call!
998+ "Leave the call, telling the room and the SFU both.
999+
1000+ The media plane goes down first and on its own account: the person pressed
1001+ leave, so the microphone should be shut whether or not the TAGMSG gets out."
1002+ []
1003+ (when-let [{:keys [channel session-id instance]} @av/local-call]
1004+ (av/stop-media!)
1005+ (when-let [c @conn]
1006+ (when (seq session-id)
1007+ (irc/tagmsg! c channel (av/leave-tags session-id instance))))))
1008+
8591009 (defn channel-list
8601010 "Buffers most recently opened first, filtered by the search box.
8611011
@@ -8,6 +8,7 @@
8 [glimmer.ratom :as r :refer [atom]]8 [glimmer.ratom :as r :refer [atom]]
9 [jolt.host :as host]9 [jolt.host :as host]
10 [frq.atproto :as atproto]10 [frq.atproto :as atproto]
11+ [frq.av :as av]
11 [frq.clock :as clock]12 [frq.clock :as clock]
12 [frq.emoji :as emoji]13 [frq.emoji :as emoji]
13 [frq.irc :as irc]14 [frq.irc :as irc]
@@ -88,6 +89,32 @@
88 (defonce lightbox (atom nil)) ; {:path :url}89 (defonce lightbox (atom nil)) ; {:path :url}
89 (defonce join-input (atom ""))90 (defonce join-input (atom ""))
90 91
92+;; The window's content width in points, polled from the backend a few times a
93+;; second. The app is laid out for a phone-width window, and this is what lets
94+;; a wide one be more than a phone with margins: past `wide-width` the channel
95+;; list and the conversation are both on screen instead of taking turns.
96+(defonce window-width (atom 0))
97+
98+;; Where the second pane starts paying for itself. Below this a 300pt list
99+;; beside a conversation leaves the messages narrower than the phone layout
100+;; they were written for.
101+(def wide-width 900)
102+
103+(defn wide?
104+ "True while the window has room for the list and a conversation at once."
105+ []
106+ (>= @window-width wide-width))
107+
108+(defn chat-visible?
109+ "Whether the conversation in `current` is on screen.
110+
111+ On a narrow window that is the chat screen alone. On a wide one the chats
112+ screen shows it too, in the pane beside the list — so this, and not the
113+ screen, is what decides whether an arriving line counts as unread."
114+ []
115+ (or (= :chat @screen)
116+ (and (wide?) (= :chats @screen))))
117+
91 ;; Whether the chat view is showing the newest line, and a counter the view118 ;; Whether the chat view is showing the newest line, and a counter the view
92 ;; watches to be told to go back to it. A counter rather than a flag: a flag119 ;; watches to be told to go back to it. A counter rather than a flag: a flag
93 ;; would need clearing, and there is no frame in which to clear it.120 ;; would need clearing, and there is no frame in which to clear it.
@@ -150,7 +177,7 @@
150 (swap! channels177 (swap! channels
151 (fn [m]178 (fn [m]
152 (let [m (ensure-channel m channel)179 (let [m (ensure-channel m channel)
153- viewing? (and (= :chat @screen) (= channel @current))]180+ viewing? (and (chat-visible?) (= channel @current))]
154 (-> m181 (-> m
155 (update-in [channel :messages] conj182 (update-in [channel :messages] conj
156 {:from from :text text :system? (= "*" from)183 {:from from :text text :system? (= "*" from)
@@ -170,7 +197,10 @@
170 buffer stays), and opening one is a request to be in it."197 buffer stays), and opening one is a request to be in it."
171 [name]198 [name]
172 (reset! current name)199 (reset! current name)
173- (reset! screen :chat)200+ ;; On a wide window the conversation lives in the chats screen's second
201+ ;; pane, beside the list; :chat is the narrow window's way of showing it
202+ ;; instead of the list, and there is nothing there to trade it for.
203+ (reset! screen (if (wide?) :chats :chat))
174 ;; A picker belongs to the message it was opened on; carrying it into another204 ;; A picker belongs to the message it was opened on; carrying it into another
175 ;; buffer would offer to react to something that is no longer on screen.205 ;; buffer would offer to react to something that is no longer on screen.
176 (reset! reacting nil)206 (reset! reacting nil)
@@ -231,7 +261,60 @@
231 msgs))261 msgs))
232 m)))))262 m)))))
233 263
234-(declare join!)264+(declare join! join-call!)
265+
266+;; --- calls -------------------------------------------------------------------
267+;; Signaling only. The audio and video themselves are `frq.av`'s, and behind it
268+;; libjoltmoq's; what happens here is that the server's broadcasts become state
269+;; the screens can read, and a press becomes a TAGMSG.
270+
271+(defn apply-call-state!
272+ "A `+freeq.at/av-state` broadcast: fold it in, and say so in the buffer.
273+
274+ The system line is worth the space — a call is the one thing that happens in
275+ a channel while nobody types, and without a line saying so the only trace of
276+ someone joining is a number quietly changing in a banner."
277+ [channel st]
278+ (av/apply-state! channel st)
279+ ;; And try to dial. The token may already be in hand — from this join, or
280+ ;; from the last time we were in this same session — in which case the
281+ ;; server's agreement that we are in the call is the last thing we were
282+ ;; waiting for. `try-start-media!` refuses if there is nothing to dial with
283+ ;; or a call is already up, so calling it on every state change is safe.
284+ (when (av/in-call? channel)
285+ (av/try-start-media! @form-host))
286+ (let [line (av/state-message st)]
287+ (when (seq line)
288+ (push-message! channel "*" line))))
289+
290+(defn apply-call-error!
291+ "A `+freeq.at/av-error`. Most say the call failed; one says we lost a race.
292+
293+ `start-collision` means our `av-start` and someone else's crossed and theirs
294+ won. The server names the winning session, so the answer is to join that one
295+ rather than to report an error for something the person asked for and can
296+ have — they wanted to be in a call in this room, and there is one."
297+ [tags code]
298+ (let [reason (or (irc/tag-value tags "+freeq.at/av-reason") code)
299+ session-id (irc/tag-value tags "+freeq.at/av-id")
300+ lc @av/local-call
301+ channel (:channel lc)]
302+ (if (and (= "start-collision" code) (seq session-id) channel
303+ (or (:awaiting-start? lc) (str/blank? (:session-id lc))))
304+ (do
305+ (push-message! channel "*" "Call already open — joining it instead")
306+ (av/stop-media!)
307+ (join-call! channel session-id))
308+ (do
309+ (when channel
310+ (push-message! channel "*" (str "Call error: " reason)))
311+ ;; Only tear down a call the error is actually about. A `join-failed`
312+ ;; naming someone else's session is not ours to act on.
313+ (when (and lc
314+ (or (str/blank? (or session-id ""))
315+ (str/blank? (:session-id lc))
316+ (= session-id (:session-id lc))))
317+ (av/stop-media!))))))
235 318
236 (defn apply-msg!319 (defn apply-msg!
237 "Fold one parsed IRC message into the state."320 "Fold one parsed IRC message into the state."
@@ -280,10 +363,22 @@
280 (irc/tag-value tags "+draft/reply"))363 (irc/tag-value tags "+draft/reply"))
281 add (or (irc/tag-value tags "+react")364 add (or (irc/tag-value tags "+react")
282 (irc/tag-value tags "+draft/react"))365 (irc/tag-value tags "+draft/react"))
283- remove-it (irc/tag-value tags "+freeq.at/unreact")]366+ remove-it (irc/tag-value tags "+freeq.at/unreact")
367+ call-state (av/parse-state tags)
368+ ;; The token is directed at our own nick rather than at
369+ ;; the channel, so `buffer` is a DM key here and says
370+ ;; nothing about which call it is for. The session id in
371+ ;; the tag is what does.
372+ token (irc/tag-value tags "+freeq.at/av-token")
373+ call-error (irc/tag-value tags "+freeq.at/av-error")]
284 (cond374 (cond
285 add (update-reaction! buffer msgid add from true)375 add (update-reaction! buffer msgid add from true)
286 remove-it (update-reaction! buffer msgid remove-it from false)376 remove-it (update-reaction! buffer msgid remove-it from false)
377+ call-state (apply-call-state! buffer call-state)
378+ token (av/apply-token! @form-host
379+ (irc/tag-value tags "+freeq.at/av-id")
380+ token)
381+ call-error (apply-call-error! tags call-error)
287 :else nil))382 :else nil))
288 "JOIN" (let [ch (first params)]383 "JOIN" (let [ch (first params)]
289 (if (= from @form-nick)384 (if (= from @form-nick)
@@ -856,6 +951,61 @@
856 (irc/unreact! c channel msgid emoji)))951 (irc/unreact! c channel msgid emoji)))
857 (update-reaction! channel msgid emoji @form-nick on?))))952 (update-reaction! channel msgid emoji @form-nick on?))))
858 953
954+(defn start-call!
955+ "Open a call on this channel.
956+
957+ Optimistic: the controls appear on the press. What comes back settles it —
958+ an `av-state` says the room has a call, an `av-token` starts the media, and
959+ a `start-collision` means someone beat us to it and we join theirs instead."
960+ [channel]
961+ (when-let [c @conn]
962+ (let [nick (or (:nick @session) @form-nick)
963+ instance (av/begin! {:channel channel
964+ :nick nick
965+ :muted? false
966+ :speaker-muted? false
967+ ;; Audio first, always. A call that opened with
968+ ;; the camera on would be a call that showed
969+ ;; someone's room before they had agreed to.
970+ :camera? false})]
971+ (irc/tagmsg! c channel (av/start-tags instance nil)))))
972+
973+(defn join-call!
974+ "Join the call already open on this channel."
975+ [channel session-id]
976+ (when-let [c @conn]
977+ (let [nick (or (:nick @session) @form-nick)
978+ instance (av/begin! {:channel channel
979+ :session-id session-id
980+ :nick nick
981+ :muted? false
982+ :speaker-muted? false
983+ :camera? false})]
984+ (irc/tagmsg! c channel (av/join-tags session-id instance)))))
985+
986+(defn announce-leave!
987+ "Tell the room this device is out of a call it did not choose to leave.
988+
989+ freeq counts a participant until an `av-leave` says otherwise, so a media
990+ plane that fails silently leaves a ghost behind — and the next Join adds
991+ another beside it. Registered with `frq.av` at startup, because that
992+ namespace has no connection to send on."
993+ [{:keys [channel session-id instance]}]
994+ (when (and @conn (seq (or session-id "")))
995+ (irc/tagmsg! @conn channel (av/leave-tags session-id instance))))
996+
997+(defn leave-call!
998+ "Leave the call, telling the room and the SFU both.
999+
1000+ The media plane goes down first and on its own account: the person pressed
1001+ leave, so the microphone should be shut whether or not the TAGMSG gets out."
1002+ []
1003+ (when-let [{:keys [channel session-id instance]} @av/local-call]
1004+ (av/stop-media!)
1005+ (when-let [c @conn]
1006+ (when (seq session-id)
1007+ (irc/tagmsg! c channel (av/leave-tags session-id instance))))))
1008+
859 (defn channel-list1009 (defn channel-list
860 "Buffers most recently opened first, filtered by the search box.1010 "Buffers most recently opened first, filtered by the search box.
861 1011