Size the call tiles to the window
They were 240 by 180 whatever they were being watched in: three people in a narrow window ran off the edge, and dragging the window wider bought nothing. Now the wall divides the space. Wider makes everyone bigger, which is the point of dragging a window during a call; someone joining shrinks the rest rather than pushing them off. Columns are chosen to keep tiles near the size they want rather than to fit as many across as physically go — four faces at a hundred points each is worse than two rows of two at twice that, and a call is people looking at each other, not a contact sheet. Height is the other half of it. A wall that wrapped freely would stack five rows deep in a narrow window and push the conversation off the screen, so it is held to about a third of the height: past that a chat client with a call in it has become a video app with a chat box attached. When the comfortable width would overflow that share, it takes more columns and smaller tiles instead. One width for every tile, not one per row — a last row holding one person would otherwise draw them twice the size of everybody above, which reads as a bug rather than a layout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4917912 parent: a66d1c1 modified
src/frq/app.jolt +24 -17 | @@ -521,35 +521,42 @@ | ||
| 521 | 521 | [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) |
| 522 | 522 | |
| 523 | 523 | (defn- call-tile |
| 524 | - "One participant's picture. | |
| 524 | + "One participant's picture, at the width the row worked out for it. | |
| 525 | 525 | |
| 526 | 526 | `:feed` rather than `:src`: these pixels never touch the disk and never |
| 527 | 527 | become a value here — the media plane hands the decoder's own buffer to |
| 528 | - Vidya as a pointer, and the tag paints whatever arrived last under that name." | |
| 529 | - [key] | |
| 528 | + Vidya as a pointer, and the tag paints whatever arrived last under that name. | |
| 529 | + | |
| 530 | + The height is three quarters of the width, which is the shape a camera hands | |
| 531 | + over. Naming both keeps a portrait phone from making its tile tall enough to | |
| 532 | + push the row off the screen — the picture is fitted inside, never stretched." | |
| 533 | + [width key] | |
| 530 | 534 | (let [mine? (= av/local-feed key)] |
| 531 | 535 | [:vbox {:key key :spacing 2} |
| 532 | - [:image {:feed key :max-width 240 :max-height 180}] | |
| 536 | + [:image {:feed key | |
| 537 | + :max-width width | |
| 538 | + :max-height (long (* width 0.75))}] | |
| 533 | 539 | [:dim-label {:label (if mine? "You" key)}]])) |
| 534 | 540 | |
| 535 | 541 | (defn- call-wall |
| 536 | - "Everyone with a camera on, or nothing at all. | |
| 542 | + "Everyone with a camera on, sized to the window they are being watched in. | |
| 537 | 543 | |
| 538 | 544 | A call with no video is the normal case and should look like one — a row of |
| 539 | - empty frames would suggest something had failed to load. The row itself is | |
| 540 | - always here even when empty, so a tile appearing adds a child rather than | |
| 541 | - changing the shape of the tree around it. | |
| 545 | + empty frames would suggest something had failed to load. | |
| 542 | 546 | |
| 543 | - `av/tiles` reads a cell the pump writes, which is what subscribes this | |
| 544 | - component: without that this would render once, with nobody on screen, and | |
| 545 | - never hear that someone had turned a camera on." | |
| 547 | + Both cells this reads are what subscribe it: `av/tiles` for who is on | |
| 548 | + screen, and the window width so the tiles follow a window being dragged. | |
| 549 | + Without either it would lay itself out once, on the first frame, and keep | |
| 550 | + that shape for the rest of the call." | |
| 546 | 551 | [] |
| 547 | - [:vbox {:key :call-wall :spacing 6} | |
| 548 | - [:hbox {:key :tiles :spacing 8} | |
| 549 | - ;; A seq, not a vector: children splice, and a vector would be read as one | |
| 550 | - ;; more hiccup element — which an empty one is not. | |
| 551 | - (for [key (av/tiles)] | |
| 552 | - [call-tile key])]]) | |
| 552 | + (let [[width rows] (av/tile-rows)] | |
| 553 | + [:vbox {:key :call-wall :spacing 6} | |
| 554 | + ;; A seq, not a vector: children splice, and a vector would be read as one | |
| 555 | + ;; more hiccup element — which an empty one is not. | |
| 556 | + (for [[i keys] (map-indexed vector rows)] | |
| 557 | + [:hbox {:key i :spacing 8} | |
| 558 | + (for [key keys] | |
| 559 | + [call-tile width key])])])) | |
| 553 | 560 | |
| 554 | 561 | (defn- call-controls |
| 555 | 562 | "What the person in a call can do about it. |
| @@ -521,35 +521,42 @@ | |||
| 521 | [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) | 521 | [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) |
| 522 | 522 | ||
| 523 | (defn- call-tile | 523 | (defn- call-tile |
| 524 | - "One participant's picture. | 524 | + "One participant's picture, at the width the row worked out for it. |
| 525 | 525 | ||
| 526 | `:feed` rather than `:src`: these pixels never touch the disk and never | 526 | `:feed` rather than `:src`: these pixels never touch the disk and never |
| 527 | become a value here — the media plane hands the decoder's own buffer to | 527 | become a value here — the media plane hands the decoder's own buffer to |
| 528 | - Vidya as a pointer, and the tag paints whatever arrived last under that name." | 528 | + Vidya as a pointer, and the tag paints whatever arrived last under that name. |
| 529 | - [key] | 529 | + |
| 530 | + The height is three quarters of the width, which is the shape a camera hands | ||
| 531 | + over. Naming both keeps a portrait phone from making its tile tall enough to | ||
| 532 | + push the row off the screen — the picture is fitted inside, never stretched." | ||
| 533 | + [width key] | ||
| 530 | (let [mine? (= av/local-feed key)] | 534 | (let [mine? (= av/local-feed key)] |
| 531 | [:vbox {:key key :spacing 2} | 535 | [:vbox {:key key :spacing 2} |
| 532 | - [:image {:feed key :max-width 240 :max-height 180}] | 536 | + [:image {:feed key |
| 537 | + :max-width width | ||
| 538 | + :max-height (long (* width 0.75))}] | ||
| 533 | [:dim-label {:label (if mine? "You" key)}]])) | 539 | [:dim-label {:label (if mine? "You" key)}]])) |
| 534 | 540 | ||
| 535 | (defn- call-wall | 541 | (defn- call-wall |
| 536 | - "Everyone with a camera on, or nothing at all. | 542 | + "Everyone with a camera on, sized to the window they are being watched in. |
| 537 | 543 | ||
| 538 | A call with no video is the normal case and should look like one — a row of | 544 | A call with no video is the normal case and should look like one — a row of |
| 539 | - empty frames would suggest something had failed to load. The row itself is | 545 | + empty frames would suggest something had failed to load. |
| 540 | - always here even when empty, so a tile appearing adds a child rather than | ||
| 541 | - changing the shape of the tree around it. | ||
| 542 | 546 | ||
| 543 | - `av/tiles` reads a cell the pump writes, which is what subscribes this | 547 | + Both cells this reads are what subscribe it: `av/tiles` for who is on |
| 544 | - component: without that this would render once, with nobody on screen, and | 548 | + screen, and the window width so the tiles follow a window being dragged. |
| 545 | - never hear that someone had turned a camera on." | 549 | + Without either it would lay itself out once, on the first frame, and keep |
| 550 | + that shape for the rest of the call." | ||
| 546 | [] | 551 | [] |
| 547 | - [:vbox {:key :call-wall :spacing 6} | 552 | + (let [[width rows] (av/tile-rows)] |
| 548 | - [:hbox {:key :tiles :spacing 8} | 553 | + [:vbox {:key :call-wall :spacing 6} |
| 549 | - ;; A seq, not a vector: children splice, and a vector would be read as one | 554 | + ;; A seq, not a vector: children splice, and a vector would be read as one |
| 550 | - ;; more hiccup element — which an empty one is not. | 555 | + ;; more hiccup element — which an empty one is not. |
| 551 | - (for [key (av/tiles)] | 556 | + (for [[i keys] (map-indexed vector rows)] |
| 552 | - [call-tile key])]]) | 557 | + [:hbox {:key i :spacing 8} |
| 558 | + (for [key keys] | ||
| 559 | + [call-tile width key])])])) | ||
| 553 | 560 | ||
| 554 | (defn- call-controls | 561 | (defn- call-controls |
| 555 | "What the person in a call can do about it. | 562 | "What the person in a call can do about it. |
modified
src/frq/av.jolt +105 -0 | @@ -192,6 +192,13 @@ | ||
| 192 | 192 | ;; be handed to Vidya, and paint into no node at all. |
| 193 | 193 | (defonce feeds (atom [])) |
| 194 | 194 | |
| 195 | +;; The window's width in points, so a tile can be a share of it rather than a | |
| 196 | +;; number somebody picked. A cell for the same reason `feeds` is one: a | |
| 197 | +;; component that asked the backend at render time would lay itself out once, | |
| 198 | +;; on the first frame, and keep that shape however the window was dragged. | |
| 199 | +(defonce window-width (atom 0)) | |
| 200 | +(defonce window-height (atom 0)) | |
| 201 | + | |
| 195 | 202 | ;; The self-view is keyed this way by the media plane; the UI wants to know |
| 196 | 203 | ;; which tile is its own, to label it and to mirror nothing else. |
| 197 | 204 | (def local-feed "__local__") |
| @@ -421,12 +428,30 @@ | ||
| 421 | 428 | (when-not (= ordered @feeds) |
| 422 | 429 | (reset! feeds ordered))))) |
| 423 | 430 | |
| 431 | +(defn- pump-window! | |
| 432 | + "Follow the window's width, so a call wall can divide it. | |
| 433 | + | |
| 434 | + Outside the `live?` guard: the width is wanted the frame a call *starts*, and | |
| 435 | + a cell first written at that moment would lay the wall out against a zero. | |
| 436 | + | |
| 437 | + Written only when it changes, and only in whole points. A window being | |
| 438 | + dragged produces a fractional width every frame, and a cell that took each | |
| 439 | + one would re-render the wall for a third of a point of difference nobody can | |
| 440 | + see." | |
| 441 | + [] | |
| 442 | + (let [[w h] (vidya/screen-size) | |
| 443 | + w (long w) | |
| 444 | + h (long h)] | |
| 445 | + (when-not (= w @window-width) (reset! window-width w)) | |
| 446 | + (when-not (= h @window-height) (reset! window-height h)))) | |
| 447 | + | |
| 424 | 448 | (defn pump! |
| 425 | 449 | "One frame's worth of the media plane. Cheap when no call is up. |
| 426 | 450 | |
| 427 | 451 | Runs on the loop thread — `vidya/frame-rgba!` may not be called from anywhere |
| 428 | 452 | else, and neither may anything that touches a node." |
| 429 | 453 | [] |
| 454 | + (pump-window!) | |
| 430 | 455 | (when (live?) |
| 431 | 456 | (pump-status!) |
| 432 | 457 | (pump-frames!) |
| @@ -457,3 +482,83 @@ | ||
| 457 | 482 | someone turns their camera on." |
| 458 | 483 | [] |
| 459 | 484 | @feeds) |
| 485 | + | |
| 486 | +;; What the wall cannot use: the window's own edges, the card the tiles sit in, | |
| 487 | +;; and the gaps between them. Measured against the chat screen's margins rather | |
| 488 | +;; than guessed — 12 a side outside the card, 8 a side within it. | |
| 489 | +(def ^:private wall-chrome 44) | |
| 490 | +(def ^:private tile-gap 8) | |
| 491 | + | |
| 492 | +;; The size a tile wants to be. Columns are chosen to keep tiles near this | |
| 493 | +;; rather than to fit as many across as will physically go: four faces at a | |
| 494 | +;; hundred points each is worse than two rows of two at twice that, and a call | |
| 495 | +;; is people looking at each other, not a contact sheet. | |
| 496 | +(def ^:private want-tile 160) | |
| 497 | + | |
| 498 | +;; A tile narrower than this is not a face, it is a thumbnail of one. The | |
| 499 | +;; ceiling is where a tile stops being a tile and starts being the screen — at | |
| 500 | +;; which point the person wants the picture, not the call. | |
| 501 | +(def ^:private min-tile 96) | |
| 502 | +(def ^:private max-tile 240) | |
| 503 | + | |
| 504 | +;; What a tile costs in height beyond its picture: the name under it, and the | |
| 505 | +;; gap to the row below. | |
| 506 | +(def ^:private tile-label 22) | |
| 507 | + | |
| 508 | +;; The wall's share of the window. A call in a chat client is something you | |
| 509 | +;; have *while* reading the room — past about a third of the height it stops | |
| 510 | +;; being that and starts being a video app with a chat box attached. | |
| 511 | +(def ^:private wall-share 0.34) | |
| 512 | + | |
| 513 | +(defn- tile-height [width] (+ (long (* width 0.75)) tile-label)) | |
| 514 | + | |
| 515 | +(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols))) | |
| 516 | + | |
| 517 | +(defn tile-width | |
| 518 | + "How wide each of `n` tiles across should be in a window of `width` points. | |
| 519 | + | |
| 520 | + A wider window makes everyone bigger, which is the point of dragging one | |
| 521 | + wider during a call; a second person appearing shrinks the first rather than | |
| 522 | + pushing them off the edge." | |
| 523 | + [width n] | |
| 524 | + (let [n (max 1 n) | |
| 525 | + usable (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))] | |
| 526 | + (-> (quot usable n) | |
| 527 | + (max min-tile) | |
| 528 | + (min max-tile)))) | |
| 529 | + | |
| 530 | +(defn columns | |
| 531 | + "How many tiles to put across a window of `width` by `height` points. | |
| 532 | + | |
| 533 | + Two pressures, and the second is why this is not simply a division. Across: | |
| 534 | + tiles are kept near the size they want rather than crammed to the minimum, | |
| 535 | + because four faces at a hundred points each is worse than two rows of two at | |
| 536 | + twice that. Down: the whole wall has to fit its share of the window, so when | |
| 537 | + the rows that follow from a comfortable width would be taller than that, it | |
| 538 | + takes more columns and smaller tiles instead — which is what stops five | |
| 539 | + people in a narrow window from stacking five rows deep and pushing the | |
| 540 | + conversation off the screen." | |
| 541 | + [width height n] | |
| 542 | + (let [n (max 1 n) | |
| 543 | + usable (- (or width 0) wall-chrome) | |
| 544 | + want (-> (quot (+ usable tile-gap) (+ want-tile tile-gap)) (max 1) (min n)) | |
| 545 | + budget (* (or height 0) wall-share) | |
| 546 | + fits? (fn [cols] | |
| 547 | + (<= (* (rows-for n cols) (+ (tile-height (tile-width width cols)) tile-gap)) | |
| 548 | + budget))] | |
| 549 | + ;; From the width we would like, widen until the wall fits its share. The | |
| 550 | + ;; last candidate is every tile on one row, which is as short as the wall | |
| 551 | + ;; can be made — if that still does not fit, it is the best on offer. | |
| 552 | + (or (first (filter fits? (range want (inc n)))) n))) | |
| 553 | + | |
| 554 | +(defn tile-rows | |
| 555 | + "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw. | |
| 556 | + | |
| 557 | + One width for every tile, not one per row: a last row holding a single person | |
| 558 | + would otherwise draw them at twice the size of everybody above, which reads | |
| 559 | + as though something had gone wrong rather than as a layout." | |
| 560 | + ([] (tile-rows @window-width @window-height (tiles))) | |
| 561 | + ([width height keys] | |
| 562 | + (let [cols (columns width height (count keys))] | |
| 563 | + [(tile-width width cols) | |
| 564 | + (mapv vec (partition-all cols keys))]))) | |
| @@ -192,6 +192,13 @@ | |||
| 192 | ;; be handed to Vidya, and paint into no node at all. | 192 | ;; be handed to Vidya, and paint into no node at all. |
| 193 | (defonce feeds (atom [])) | 193 | (defonce feeds (atom [])) |
| 194 | 194 | ||
| 195 | +;; The window's width in points, so a tile can be a share of it rather than a | ||
| 196 | +;; number somebody picked. A cell for the same reason `feeds` is one: a | ||
| 197 | +;; component that asked the backend at render time would lay itself out once, | ||
| 198 | +;; on the first frame, and keep that shape however the window was dragged. | ||
| 199 | +(defonce window-width (atom 0)) | ||
| 200 | +(defonce window-height (atom 0)) | ||
| 201 | + | ||
| 195 | ;; The self-view is keyed this way by the media plane; the UI wants to know | 202 | ;; The self-view is keyed this way by the media plane; the UI wants to know |
| 196 | ;; which tile is its own, to label it and to mirror nothing else. | 203 | ;; which tile is its own, to label it and to mirror nothing else. |
| 197 | (def local-feed "__local__") | 204 | (def local-feed "__local__") |
| @@ -421,12 +428,30 @@ | |||
| 421 | (when-not (= ordered @feeds) | 428 | (when-not (= ordered @feeds) |
| 422 | (reset! feeds ordered))))) | 429 | (reset! feeds ordered))))) |
| 423 | 430 | ||
| 431 | +(defn- pump-window! | ||
| 432 | + "Follow the window's width, so a call wall can divide it. | ||
| 433 | + | ||
| 434 | + Outside the `live?` guard: the width is wanted the frame a call *starts*, and | ||
| 435 | + a cell first written at that moment would lay the wall out against a zero. | ||
| 436 | + | ||
| 437 | + Written only when it changes, and only in whole points. A window being | ||
| 438 | + dragged produces a fractional width every frame, and a cell that took each | ||
| 439 | + one would re-render the wall for a third of a point of difference nobody can | ||
| 440 | + see." | ||
| 441 | + [] | ||
| 442 | + (let [[w h] (vidya/screen-size) | ||
| 443 | + w (long w) | ||
| 444 | + h (long h)] | ||
| 445 | + (when-not (= w @window-width) (reset! window-width w)) | ||
| 446 | + (when-not (= h @window-height) (reset! window-height h)))) | ||
| 447 | + | ||
| 424 | (defn pump! | 448 | (defn pump! |
| 425 | "One frame's worth of the media plane. Cheap when no call is up. | 449 | "One frame's worth of the media plane. Cheap when no call is up. |
| 426 | 450 | ||
| 427 | Runs on the loop thread — `vidya/frame-rgba!` may not be called from anywhere | 451 | Runs on the loop thread — `vidya/frame-rgba!` may not be called from anywhere |
| 428 | else, and neither may anything that touches a node." | 452 | else, and neither may anything that touches a node." |
| 429 | [] | 453 | [] |
| 454 | + (pump-window!) | ||
| 430 | (when (live?) | 455 | (when (live?) |
| 431 | (pump-status!) | 456 | (pump-status!) |
| 432 | (pump-frames!) | 457 | (pump-frames!) |
| @@ -457,3 +482,83 @@ | |||
| 457 | someone turns their camera on." | 482 | someone turns their camera on." |
| 458 | [] | 483 | [] |
| 459 | @feeds) | 484 | @feeds) |
| 485 | + | ||
| 486 | +;; What the wall cannot use: the window's own edges, the card the tiles sit in, | ||
| 487 | +;; and the gaps between them. Measured against the chat screen's margins rather | ||
| 488 | +;; than guessed — 12 a side outside the card, 8 a side within it. | ||
| 489 | +(def ^:private wall-chrome 44) | ||
| 490 | +(def ^:private tile-gap 8) | ||
| 491 | + | ||
| 492 | +;; The size a tile wants to be. Columns are chosen to keep tiles near this | ||
| 493 | +;; rather than to fit as many across as will physically go: four faces at a | ||
| 494 | +;; hundred points each is worse than two rows of two at twice that, and a call | ||
| 495 | +;; is people looking at each other, not a contact sheet. | ||
| 496 | +(def ^:private want-tile 160) | ||
| 497 | + | ||
| 498 | +;; A tile narrower than this is not a face, it is a thumbnail of one. The | ||
| 499 | +;; ceiling is where a tile stops being a tile and starts being the screen — at | ||
| 500 | +;; which point the person wants the picture, not the call. | ||
| 501 | +(def ^:private min-tile 96) | ||
| 502 | +(def ^:private max-tile 240) | ||
| 503 | + | ||
| 504 | +;; What a tile costs in height beyond its picture: the name under it, and the | ||
| 505 | +;; gap to the row below. | ||
| 506 | +(def ^:private tile-label 22) | ||
| 507 | + | ||
| 508 | +;; The wall's share of the window. A call in a chat client is something you | ||
| 509 | +;; have *while* reading the room — past about a third of the height it stops | ||
| 510 | +;; being that and starts being a video app with a chat box attached. | ||
| 511 | +(def ^:private wall-share 0.34) | ||
| 512 | + | ||
| 513 | +(defn- tile-height [width] (+ (long (* width 0.75)) tile-label)) | ||
| 514 | + | ||
| 515 | +(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols))) | ||
| 516 | + | ||
| 517 | +(defn tile-width | ||
| 518 | + "How wide each of `n` tiles across should be in a window of `width` points. | ||
| 519 | + | ||
| 520 | + A wider window makes everyone bigger, which is the point of dragging one | ||
| 521 | + wider during a call; a second person appearing shrinks the first rather than | ||
| 522 | + pushing them off the edge." | ||
| 523 | + [width n] | ||
| 524 | + (let [n (max 1 n) | ||
| 525 | + usable (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))] | ||
| 526 | + (-> (quot usable n) | ||
| 527 | + (max min-tile) | ||
| 528 | + (min max-tile)))) | ||
| 529 | + | ||
| 530 | +(defn columns | ||
| 531 | + "How many tiles to put across a window of `width` by `height` points. | ||
| 532 | + | ||
| 533 | + Two pressures, and the second is why this is not simply a division. Across: | ||
| 534 | + tiles are kept near the size they want rather than crammed to the minimum, | ||
| 535 | + because four faces at a hundred points each is worse than two rows of two at | ||
| 536 | + twice that. Down: the whole wall has to fit its share of the window, so when | ||
| 537 | + the rows that follow from a comfortable width would be taller than that, it | ||
| 538 | + takes more columns and smaller tiles instead — which is what stops five | ||
| 539 | + people in a narrow window from stacking five rows deep and pushing the | ||
| 540 | + conversation off the screen." | ||
| 541 | + [width height n] | ||
| 542 | + (let [n (max 1 n) | ||
| 543 | + usable (- (or width 0) wall-chrome) | ||
| 544 | + want (-> (quot (+ usable tile-gap) (+ want-tile tile-gap)) (max 1) (min n)) | ||
| 545 | + budget (* (or height 0) wall-share) | ||
| 546 | + fits? (fn [cols] | ||
| 547 | + (<= (* (rows-for n cols) (+ (tile-height (tile-width width cols)) tile-gap)) | ||
| 548 | + budget))] | ||
| 549 | + ;; From the width we would like, widen until the wall fits its share. The | ||
| 550 | + ;; last candidate is every tile on one row, which is as short as the wall | ||
| 551 | + ;; can be made — if that still does not fit, it is the best on offer. | ||
| 552 | + (or (first (filter fits? (range want (inc n)))) n))) | ||
| 553 | + | ||
| 554 | +(defn tile-rows | ||
| 555 | + "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw. | ||
| 556 | + | ||
| 557 | + One width for every tile, not one per row: a last row holding a single person | ||
| 558 | + would otherwise draw them at twice the size of everybody above, which reads | ||
| 559 | + as though something had gone wrong rather than as a layout." | ||
| 560 | + ([] (tile-rows @window-width @window-height (tiles))) | ||
| 561 | + ([width height keys] | ||
| 562 | + (let [cols (columns width height (count keys))] | ||
| 563 | + [(tile-width width cols) | ||
| 564 | + (mapv vec (partition-all cols keys))]))) | ||