diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4ec1a8d..39afb00 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,10 +37,17 @@ export default function App(): JSX.Element { const accounts = useChat((s) => s.accounts) const activePanel = useChat((s) => s.activePanel) const activeBufferId = useChat((s) => s.activeBufferId) + // A call whose conversation is not the one on screen. The stage lives in + // the conversation; this is the corner it retreats to. + const activeCall = useChat((s) => s.activeCall) const activeGroupId = useChat((s) => s.activeGroupId) const joinPanelAccountId = useChat((s) => s.joinPanelAccountId) const buffer = useActiveBuffer() const joinAccount = accounts.find((a) => a.id === joinPanelAccountId) + // Shown in the corner only when the conversation it belongs to is not the + // one being read - and not while a panel is covering the log either, since + // that is walking away from it too. + const callElsewhere = !!activeCall && (activeCall.bufferId !== activeBufferId || activePanel !== '') const [sidebarFolded, setSidebarFolded] = usePref('ui.sidebarFolded', false) const [userListFolded, setUserListFolded] = usePref('ui.userListFolded', false) @@ -208,11 +215,11 @@ export default function App(): JSX.Element { than by the daemon - the media is the window's. */} - {/* The call this window is holding, wherever the window has wandered - to. It does not live under the conversation: walking away from a - conversation does not hang up, and a call that vanished when you - looked at something else would be a call you could not hang up. */} - + {/* Where a call goes when you walk away from it: a corner of its own, + the way a phone keeps the picture in the corner. In the conversation + it belongs to it sits above the log instead - see MessageList - so + the two are never both on screen. */} + {callElsewhere && } {/* Over everything for the same reason: a file is offered while you are reading something else, and often in a conversation you are not. */} diff --git a/src/renderer/src/Popout.tsx b/src/renderer/src/Popout.tsx index af1c836..e5f4541 100644 --- a/src/renderer/src/Popout.tsx +++ b/src/renderer/src/Popout.tsx @@ -40,6 +40,7 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element { const [booted, setBooted] = useState(false) const buffer = useActiveBuffer() const linkUp = useChat((s) => s.linkUp) + const activeCall = useChat((s) => s.activeCall) // Folded by default: a popout is a narrow window opened to read one // conversation, and a member list would take a third of it before anything // has been read. @@ -135,7 +136,9 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element { answered here is answered here. */} - + {/* A popped-out conversation is one conversation, so a call that is not + this one is in the corner and this one is above the log. */} + {activeCall && activeCall.bufferId !== bufferId && } ) diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 8559aaa..8a9e7dc 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -852,67 +852,180 @@ * Sized so it can be looked at without swallowing the log: a call is watched * and the conversation is read, and the two happen at once. */ +/* + * In the conversation it belongs to, above the log - the same place the voice + * call view sits, because a call and the conversation it is in are the same + * conversation and people type into a channel while talking in it. + */ .call-stage { - /* Over everything, because a call is not part of the conversation it - started in: it follows the window rather than the buffer, and has to be - hangable-up from wherever somebody has wandered to. */ - position: absolute; - right: var(--spacing-m); - bottom: 90px; - z-index: 40; - width: 380px; display: flex; flex-direction: column; + flex: none; gap: var(--spacing-xs); + margin: var(--spacing-xs) var(--spacing-m) 0; padding: var(--spacing-xs); border: 1px solid var(--outline-medium); border-radius: var(--corner-radius); background: var(--surface-container); } -.call-stage-video { +/* + * And in a corner of its own once you walk away from that conversation, the + * way a phone keeps the picture in the corner rather than ending the call. + */ +.call-stage.pip { + position: absolute; + right: var(--spacing-m); + bottom: 90px; + z-index: 40; + width: 340px; + margin: 0; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +/* + * The corner view's own title bar: who is in the call, and the control that + * puts it away. + * + * Only in the corner, where the panel is a window in all but name and wants a + * window's furniture. In the conversation the tiles carry the names + * themselves and a second list would be the same thing twice. + */ +.call-stage-title { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: 0 2px 2px; + color: var(--surface-variant-text); +} + +.call-stage-title .small { + flex: 1; + min-width: 0; +} + +/* Put away: one line that says a call is still running, and offers it back. */ +.call-stage.minimized { + padding: 2px var(--spacing-xs); +} + +.call-stage.minimized.pip { + width: auto; + max-width: 320px; +} + +/* + * One tile per person, in as square a grid as the number allows. + * + * The grid grows downwards rather than squeezing: five people in a row would + * be five slivers, and the whole point of a tile is that a face is legible in + * it. Bounded in height so a call never takes the conversation over. + */ +.video-grid { + display: grid; + gap: 6px; + align-content: start; + /* Centred, because the tiles are sized to fit rather than stretched: a row + of three in a wide window leaves room at the sides, and that room belongs + equally on both. */ + justify-content: center; + overflow: hidden; +} + +/* + * Landscape, like every camera and every other client: a face fits in a + * sixteen-by-nine tile the way it does not fit in a tall one, and the whole + * row reads as one thing. + */ +.video-tile { position: relative; - height: 214px; + width: 100%; + aspect-ratio: 16 / 9; + min-width: 0; + /* It is a button now - pressing a face is how somebody is singled out - so + the button's own chrome has to go before it can look like a picture. */ + padding: 0; + border: none; border-radius: var(--corner-radius-small); overflow: hidden; - background: #000; + cursor: pointer; + background: var(--surface-container-highest); } -.call-remote { - width: 100%; - height: 100%; - object-fit: contain; -} - -/* Nothing to look at yet, so the element is not what is on screen. */ -.call-remote.audio-only { - opacity: 0; -} - -.call-stage-placeholder { - position: absolute; - inset: 0; +/* + * One person, large, with everybody else in a row underneath. + * + * What a grid cannot say: that this is the person being watched. Discord and + * every other client draw it the same way, and the strip is what keeps it a + * call rather than a broadcast - the others are still there, still lit when + * they talk, and still one press away from being the large one. + */ +.video-stage { display: flex; flex-direction: column; align-items: center; - justify-content: center; - gap: var(--spacing-s); + gap: 6px; + overflow: hidden; } -/* Yourself, small and out of the way: it is there to check you are in frame, - not to be watched. */ -.call-local { - position: absolute; - right: var(--spacing-s); - bottom: var(--spacing-s); - width: 140px; - max-height: 100px; - border-radius: var(--corner-radius-small); - border: 1px solid var(--outline-medium); - background: #000; +.video-strip { + display: flex; + gap: 6px; + justify-content: center; + max-width: 100%; + /* More people than fit: scrolled rather than shrunk to nothing, since a + thumbnail below a certain size stops being a person. */ + overflow-x: auto; + overflow-y: hidden; +} + +.video-strip .video-tile { + flex: none; + height: 100%; +} + +/* Who is talking, in the same green the voice call view uses - said with a + ring rather than by moving anybody, since a grid that reordered itself + every time somebody spoke would be unreadable. */ +.video-tile.speaking { + outline: 3px solid var(--success); + outline-offset: -3px; +} + +.video-tile-picture { + width: 100%; + height: 100%; object-fit: cover; } +/* Nothing to look at: the name and a face, which is what a call without + cameras actually is. */ +.video-tile-picture.audio-only { + opacity: 0; +} + +.video-tile-face { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.video-tile-name { + position: absolute; + left: 4px; + bottom: 4px; + display: flex; + align-items: center; + gap: 3px; + max-width: calc(100% - 8px); + padding: 1px 6px; + border-radius: var(--corner-radius-small); + background: rgba(0, 0, 0, 0.55); + color: #fff; +} + .call-stage-controls { display: flex; align-items: center; @@ -924,6 +1037,10 @@ margin-right: auto; } +/* Smaller in the corner than in the conversation, and shorter, so a call + somebody has walked away from stays out of the way. */ + + /* * Which window to show a room. * diff --git a/src/renderer/src/components/CallStage.tsx b/src/renderer/src/components/CallStage.tsx index 14860fd..4cf0548 100644 --- a/src/renderer/src/components/CallStage.tsx +++ b/src/renderer/src/components/CallStage.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from 'react' import { Icon, IconButton } from './Icon' +import { Avatar } from './Avatar' import { useChat, useStore } from '../state/hooks' +import type { CallTile } from '../state/store' import { bufferDisplayName, classes } from '../lib/util' /** @@ -19,32 +21,106 @@ import { bufferDisplayName, classes } from '../lib/util' * which is what a call is on any service - the screen-share button here is the * same button Discord's will be, and the tiles are the same tiles. */ -export function CallStage(): JSX.Element | null { +/** Sixteen by nine, which is what cameras and screens both are. */ +const TILE_RATIO = 16 / 9 +/** No tile gets bigger than this, however much room there is. */ +const TILE_MAX = 420 +const TILE_GAP = 6 + +/** + * How to lay out a call of this size in the space there is. + * + * Every column count is tried and the one that makes the tiles biggest wins, + * subject to them all fitting - which is what a grid of faces should do and + * what a fixed table of counts cannot: three people in a wide short box want + * one row, and the same three in a tall narrow one want three. + * + * Capped, because a call between two people on a large screen should not be + * two enormous windows - past a certain size a face stops being easier to + * read and the conversation underneath just disappears. + */ +export function fitTiles( + count: number, + width: number, + height: number, + max: number = TILE_MAX +): { columns: number; tileWidth: number } { + if (count < 1 || width < 1) return { columns: 1, tileWidth: 0 } + let best = { columns: 1, tileWidth: 0 } + for (let columns = 1; columns <= count; columns++) { + const rows = Math.ceil(count / columns) + // What the width allows, and what the height allows; the smaller governs. + const byWidth = (width - TILE_GAP * (columns - 1)) / columns + const byHeight = ((height - TILE_GAP * (rows - 1)) / rows) * TILE_RATIO + const tileWidth = Math.min(byWidth, byHeight, max) + if (tileWidth > best.tileWidth) best = { columns, tileWidth } + } + return best +} + +export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX.Element | null { const store = useStore() const call = useChat((s) => s.activeCall) + const minimized = useChat((s) => s.callMinimized) const buffers = useChat((s) => s.buffers) - const localVideo = useRef(null) - const remoteVideo = useRef(null) - const [hasRemoteVideo, setHasRemoteVideo] = useState(false) + const videos = useRef(new Map()) + const grid = useRef(null) + const [tiles, setTiles] = useState([]) + /** How wide the grid is, which decides how the tiles are laid out. */ + const [room, setRoom] = useState({ width: 640, height: 260 }) + /** + * Whose picture is being looked at, if anybody. + * + * Null is the grid, where nobody is more important than anybody else. Naming + * one person is a decision the person watching made, so it is undone the + * same way it was made rather than by anything the call does. + */ + const [focused, setFocused] = useState(null) - // The streams are not state: they are live objects that change tracks under - // a call, so the elements are pointed at them rather than re-rendered from - // them. + // Measured rather than assumed: the same call is a different shape in a + // narrow window, in a full-screen one, and in the corner it retreats to. + useEffect(() => { + const el = grid.current + if (!el) return + const measure = (): void => + setRoom({ + width: el.clientWidth, + // As much height as a call may take before it is taking over the + // conversation rather than sitting above it. + height: Math.min(window.innerHeight * (mode === 'pip' ? 0.3 : 0.45), mode === 'pip' ? 260 : 460) + }) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + window.addEventListener('resize', measure) + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + } + }, [mode, minimized]) + + // The streams are not state: they are live objects whose tracks change under + // a call - somebody turning a camera on, somebody starting to share - so the + // elements are pointed at them rather than re-rendered from them. useEffect(() => { if (!call) return const attach = (): void => { - const { local, remote } = store.callStreams() - if (localVideo.current && local && localVideo.current.srcObject !== local) { - localVideo.current.srcObject = local + const current = store.callTiles() + setTiles((was) => + was.length === current.length && was.every((t, i) => t.id === current[i].id && t.hasVideo === current[i].hasVideo) + ? was + : current + ) + // Both slots: in the focused layout the same person is on the stage and + // in the strip underneath, which is two elements showing one stream. + for (const tile of current) { + for (const slot of ['stage', 'strip']) { + const el = videos.current.get(`${tile.id}#${slot}`) + if (el && tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream + } } - if (remoteVideo.current && remote && remoteVideo.current.srcObject !== remote) { - remoteVideo.current.srcObject = remote - } - setHasRemoteVideo(!!remote?.getVideoTracks().some((t) => t.readyState === 'live')) } attach() - // Tracks arrive after the call connects, and again when somebody starts - // sharing a screen - so this keeps looking rather than attaching once. const timer = setInterval(attach, 700) return () => clearInterval(timer) }, [call, store]) @@ -52,23 +128,155 @@ export function CallStage(): JSX.Element | null { if (!call) return null const where = buffers.find((b) => b.id === call.bufferId)?.name ?? '' + // Somebody who has left the call cannot go on being the one looked at. + const stage = tiles.find((t) => t.id === focused) ?? null + const layout = fitTiles(Math.max(tiles.length, 1), room.width, room.height) + /** The row of everybody else under the stage, and how tall it is. */ + const stripHeight = mode === 'pip' ? 42 : 74 + const stageFit = fitTiles( + 1, + room.width, + room.height - (tiles.length > 1 ? stripHeight + TILE_GAP : 0), + // Uncapped: the whole point of picking somebody out is to see them larger + // than the grid would ever draw them. + Infinity + ) const phase = call.phase === 'ringing' ? 'Ringing…' : call.phase === 'connecting' ? 'Connecting…' : 'Connected' - return ( -
-
- {/* Muted, because this is our own microphone coming back: playing it - would be the oldest mistake in video calling. */} -