Merge remote-tracking branch 'origin/main' into claude/apk-device-deployment-921e81
modified
README.md +45 -2 | @@ -18,6 +18,7 @@ src/frq/store.jolt the saved sign-in, mode 600 in the config directory | ||
| 18 | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | 20 | 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 | 22 | src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings |
| 22 | 23 | src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name |
| 23 | 24 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| @@ -32,13 +33,19 @@ Android is logcat. | ||
| 32 | 33 | |
| 33 | 34 | ## Running |
| 34 | 35 | |
| 35 | -`libvidya` from Vidya's Rust/egui backend, then the app: | |
| 36 | +Both native libraries, then the app: | |
| 36 | 37 | |
| 37 | 38 | ```bash |
| 38 | 39 | just lib |
| 39 | 40 | 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 | 49 | `just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built |
| 43 | 50 | library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick |
| 44 | 51 | 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 | 139 | * Conversations listed most recently opened first |
| 133 | 140 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 134 | 141 | 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 | 174 | ## Limits |
| 137 | 175 | |
| @@ -145,12 +183,17 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 145 | 183 | format, and a fetch needs TLS, so the phone shows links. The link is left in |
| 146 | 184 | place either way. |
| 147 | 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 | 191 | * **Pasting a picture needs a sign-in and a desktop.** The upload is filed |
| 149 | 192 | under the DID of a live session, so a guest cannot make one; and it is read |
| 150 | 193 | off the clipboard through the ABI's `vidya_clipboard_image_png`, which |
| 151 | 194 | arboard backs on desktop and nothing backs on Android. It also shares |
| 152 | 195 | nothing to your PDS and posts nothing to Bluesky — those fields are opt-in |
| 153 | 196 | and this client does not send them. |
| 154 | -* **No scrollback trimming, reactions, threads, or calls.** | |
| 197 | +* **No scrollback trimming or threads.** | |
| 155 | 198 | * A sent line waits up to 200ms for the reader thread to flush it. |
| 156 | 199 | * 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 handle | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk | 19 | 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 multipart | 20 | 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 headings | 22 | 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 name | 23 | 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, PRIVMSG | 24 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| @@ -32,13 +33,19 @@ Android is logcat. | |||
| 32 | 33 | ||
| 33 | ## Running | 34 | ## Running |
| 34 | 35 | ||
| 35 | -`libvidya` from Vidya's Rust/egui backend, then the app: | 36 | +Both native libraries, then the app: |
| 36 | 37 | ||
| 37 | ```bash | 38 | ```bash |
| 38 | just lib | 39 | just lib |
| 39 | just run | 40 | 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 built | 49 | `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`. Untick | 50 | 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's | 51 | 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 first | 139 | * Conversations listed most recently opened first |
| 133 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each | 140 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 134 | message with | 141 | 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 | ## Limits | 174 | ## 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 in | 183 | 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 filed | 191 | * **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 read | 192 | 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`, which | 193 | off the clipboard through the ABI's `vidya_clipboard_image_png`, which |
| 151 | arboard backs on desktop and nothing backs on Android. It also shares | 194 | 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-in | 195 | 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 @@ | ||
| 2 | 2 | |
| 3 | 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); |
| 4 | 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: a | |
| 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A | |
| 6 | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | 7 | ;; used to render anyway, into widgets the backend had already freed and |
| 8 | 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 | 13 | :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"}} | |
| 12 | 16 | |
| 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 | |
| 17 | 28 | :jolt/native [{:name "vidya" |
| 18 | 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 | 35 | :aliases {:frq {:main-opts ["-m" "frq.app"]}} |
| 22 | 36 | |
| @@ -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: a | 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A |
| 6 | ;; component unmounted between a cell firing and the queued render running | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | ;; used to render anyway, into widgets the backend had already freed and | 7 | ;; 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 tree | 17 | + ;; 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 ffi | 19 | + ;; |
| 16 | - ;; LD_LIBRARY_PATH=../vidya/build jolt -M:frq | 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 | ||
| 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 @@ | ||
| 1 | 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 | 12 | default: |
| 6 | 13 | @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 | 17 | lib: |
| 10 | - cd {{vidya}} && just ffi | |
| 18 | + cd {{jolt_native}} && just build | |
| 11 | 19 | |
| 12 | -# The app. Point LD_LIBRARY_PATH at whichever libvidya build you have. | |
| 20 | +# The app. | |
| 13 | 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}} | |
| @@ -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 --list | 13 | @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 ffi | 18 | + 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 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [glimmer.core :as ui] |
| 10 | 10 | [glimmer-vidya.core :as vidya] |
| 11 | + [frq.av :as av] | |
| 11 | 12 | [frq.avatars :as avatars] |
| 12 | 13 | [frq.clock :as clock] |
| 13 | 14 | [frq.media :as media] |
| @@ -164,9 +165,16 @@ | ||
| 164 | 165 | [:button {:label "Join" |
| 165 | 166 | :kind :primary |
| 166 | 167 | :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 "")}])] | |
| 170 | 178 | (if (seq buffers) |
| 171 | 179 | (for [b buffers] [conversation-row b]) |
| 172 | 180 | [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]) |
| @@ -519,6 +527,118 @@ | ||
| 519 | 527 | [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] |
| 520 | 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 | 642 | (defn image-picker-screen |
| 523 | 643 | "The pictures on this device, to send one of. |
| 524 | 644 | |
| @@ -584,9 +704,25 @@ | ||
| 584 | 704 | ;; bottom of a screen wants is above it, not beneath it. |
| 585 | 705 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} |
| 586 | 706 | [: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)}])]] | |
| 589 | 724 | [error-note] |
| 725 | + [call-bar name] | |
| 590 | 726 | ;; :reserve leaves room for everything below: the jump button's row, the |
| 591 | 727 | ;; separator and the compose bar. It does not vary with whether the button |
| 592 | 728 | ;; is showing, and neither does that row — a reserve that changed would |
| @@ -660,6 +796,46 @@ | ||
| 660 | 796 | :on-activate s/send-draft!}] |
| 661 | 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 | 839 | ;; ---------------------------------------------------------------- discover |
| 664 | 840 | |
| 665 | 841 | (defn discover-screen [] |
| @@ -719,13 +895,17 @@ | ||
| 719 | 895 | (cond |
| 720 | 896 | @s/lightbox [lightbox-screen] |
| 721 | 897 | @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]))) | |
| 729 | 909 | |
| 730 | 910 | (defn -main [& _] |
| 731 | 911 | ;; Before the window: the rooms this client has been in, and a saved sign-in |
| @@ -739,4 +919,22 @@ | ||
| 739 | 919 | ;; user should be looking at while this happens, and if it fails, the |
| 740 | 920 | ;; error lands somewhere visible. |
| 741 | 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 | 940 | (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 :primary | 166 | :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/search | 168 | + ;; 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-screen | 642 | (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, the | 726 | ;; :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 button | 727 | ;; 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 would | 728 | ;; 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 | ;; ---------------------------------------------------------------- discover | 839 | ;; ---------------------------------------------------------------- discover |
| 664 | 840 | ||
| 665 | (defn discover-screen [] | 841 | (defn discover-screen [] |
| @@ -719,13 +895,17 @@ | |||
| 719 | (cond | 895 | (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 | - :else | 898 | + ;; Wide enough for both, and on one of the two screens that are halves of |
| 723 | - (case @s/screen | 899 | + ;; 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-in | 911 | ;; 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, the | 919 | ;; 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 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [jolt.host :as host] |
| 10 | 10 | [frq.atproto :as atproto] |
| 11 | + [frq.av :as av] | |
| 11 | 12 | [frq.clock :as clock] |
| 12 | 13 | [frq.emoji :as emoji] |
| 13 | 14 | [frq.irc :as irc] |
| @@ -88,6 +89,32 @@ | ||
| 88 | 89 | (defonce lightbox (atom nil)) ; {:path :url} |
| 89 | 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 | 118 | ;; Whether the chat view is showing the newest line, and a counter the view |
| 92 | 119 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 93 | 120 | ;; would need clearing, and there is no frame in which to clear it. |
| @@ -150,7 +177,7 @@ | ||
| 150 | 177 | (swap! channels |
| 151 | 178 | (fn [m] |
| 152 | 179 | (let [m (ensure-channel m channel) |
| 153 | - viewing? (and (= :chat @screen) (= channel @current))] | |
| 180 | + viewing? (and (chat-visible?) (= channel @current))] | |
| 154 | 181 | (-> m |
| 155 | 182 | (update-in [channel :messages] conj |
| 156 | 183 | {:from from :text text :system? (= "*" from) |
| @@ -170,7 +197,10 @@ | ||
| 170 | 197 | buffer stays), and opening one is a request to be in it." |
| 171 | 198 | [name] |
| 172 | 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 | 204 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 175 | 205 | ;; buffer would offer to react to something that is no longer on screen. |
| 176 | 206 | (reset! reacting nil) |
| @@ -231,7 +261,60 @@ | ||
| 231 | 261 | msgs)) |
| 232 | 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 | 319 | (defn apply-msg! |
| 237 | 320 | "Fold one parsed IRC message into the state." |
| @@ -280,10 +363,22 @@ | ||
| 280 | 363 | (irc/tag-value tags "+draft/reply")) |
| 281 | 364 | add (or (irc/tag-value tags "+react") |
| 282 | 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 | 374 | (cond |
| 285 | 375 | add (update-reaction! buffer msgid add from true) |
| 286 | 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 | 382 | :else nil)) |
| 288 | 383 | "JOIN" (let [ch (first params)] |
| 289 | 384 | (if (= from @form-nick) |
| @@ -856,6 +951,61 @@ | ||
| 856 | 951 | (irc/unreact! c channel msgid emoji))) |
| 857 | 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 | 1009 | (defn channel-list |
| 860 | 1010 | "Buffers most recently opened first, filtered by the search box. |
| 861 | 1011 | |
| @@ -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 view | 118 | ;; 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 flag | 119 | ;; 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! channels | 177 | (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 | (-> m | 181 | (-> m |
| 155 | (update-in [channel :messages] conj | 182 | (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 another | 204 | ;; 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 | (cond | 374 | (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-list | 1009 | (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 | ||