diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index ddca021..dd4ba33 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -166,14 +166,26 @@ jobs: # hit on every push after that, so the only thing recompiled per push is # nobilis itself, which is the few minutes that genuinely cannot be # avoided. + # + # ImageOS is in the key because the runner image is part of what built + # these artifacts, and pinning this job to 22.04 changed it without + # changing anything the key looked at. The next run restored a tree + # compiled on 24.04 under an exact-match key, cargo believed the crates + # it named were already built, and the build died on "can't find crate + # for tor_bytes" - a dependency whose rlib the restored tree did not + # have. The Windows job, still on ubuntu-latest, restored its own + # matching cache and passed, which is how the image turned out to be + # the variable. - name: cache the cargo target directory uses: actions/cache@v4 with: path: nobilis/target - key: cargo-target-linux-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} + key: cargo-target-linux-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} # A lockfile bump should start from the previous tree and rebuild - # what actually changed, rather than starting from nothing. - restore-keys: cargo-target-linux- + # what actually changed, rather than starting from nothing. Scoped + # to the image for the reason above: falling back across images is + # the same trap one step further down. + restore-keys: cargo-target-linux-${{ env.ImageOS }}- # The Electron binary, and the appimagetool and nsis bundles # electron-builder fetches at pack time. Small, and it removes a real @@ -378,8 +390,11 @@ jobs: uses: actions/cache@v4 with: path: nobilis/target - key: cargo-target-windows-gnu-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} - restore-keys: cargo-target-windows-gnu- + # Scoped to the image for the same reason as the Linux job's: this + # directory also holds host-built artifacts - build scripts and + # proc macros - even though the binary itself is cross-compiled. + key: cargo-target-windows-gnu-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} + restore-keys: cargo-target-windows-gnu-${{ env.ImageOS }}- - name: cache electron downloads uses: actions/cache@v4 diff --git a/nobilis b/nobilis index bda039d..92dffca 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit bda039d6939495664d78eded47bd983a27dd19d3 +Subproject commit 92dffca011ca3f2140e62058a45aa1fa0d6b71ad diff --git a/src/main/index.ts b/src/main/index.ts index 821cc4d..9d56e2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,6 @@ import { app, BrowserWindow, clipboard, - desktopCapturer, dialog, globalShortcut, ipcMain, @@ -30,6 +29,7 @@ import { clearnetLinks } from '../shared/clearnet' import { readCapped, pictureNamedIn } from './imagepage' import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink' import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol' +import { installScreenShare } from './screenshare' import { defaultDownloadDir, saveMedia } from './downloads' import type { Buffer as ChatBuffer } from '../shared/wire' import { log } from './log' @@ -273,7 +273,23 @@ function createWindow(): void { mainWindow = null }) - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + mainWindow.webContents.setWindowOpenHandler(({ url, frameName }) => { + // The one window the page may open itself: a call popped out of the + // conversation. The page draws into it directly - see StageWindow - so it + // is blank, frameless like this one, and black before anything arrives. + if (frameName === 'moho-call' && (url === '' || url === 'about:blank')) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + title: 'moho — call', + frame: false, + backgroundColor: '#000000', + minWidth: 360, + minHeight: 240, + autoHideMenuBar: true + } + } + } shell.openExternal(url) return { action: 'deny' } }) @@ -648,20 +664,6 @@ function wireIpc(): void { // far. Separate from pickFile because the two dialogs ask opposite // questions, and a save dialog that cannot name a default file is a save // dialog people cancel. - // What could be shared into a call. Thumbnails at a size worth looking at - // but not worth waiting for - this is a picker, not a preview. - ipcMain.handle(IPC.screenSources, async () => { - const sources = await desktopCapturer.getSources({ - types: ['screen', 'window'], - thumbnailSize: { width: 320, height: 180 } - }) - return sources.map((s) => ({ - id: s.id, - name: s.name, - thumbnail: s.thumbnail.toDataURL() - })) - }) - ipcMain.handle(IPC.pickSavePath, async (e, suggested?: string) => { const parent = callerWindow(e) ?? mainWindow if (!parent) return null @@ -841,6 +843,18 @@ app.whenReady().then(() => { log.toDirectory(path.join(cacheRoot(), 'moho')) log.info('moho starting, logging to', log.file() ?? '(nowhere)') + // What a call did, from the window that held it. The renderer writes one + // `[call] {...}` line per event to its console - see lib/calllog.ts - and + // this is the only place they are kept: a console dies with its window, and + // the record of why a call had no picture is wanted after the call is over. + // Only that prefix, so nothing else a page prints ends up in the file. + app.on('web-contents-created', (_e, contents) => { + contents.on('console-message', (event) => { + const message = (event as unknown as { message?: string }).message + if (typeof message === 'string' && message.startsWith('[call] ')) log.info(message) + }) + }) + // Said once, in the log and in the window, and never in the way. // // Chromium's sandbox needs unprivileged user namespaces, and an AppImage @@ -879,6 +893,9 @@ app.whenReady().then(() => { }) session.defaultSession.setPermissionCheckHandler((_contents, permission) => allowed.has(permission)) + // Every screen share in the app, on every desktop - see screenshare.ts. + installScreenShare(session.defaultSession, () => mainWindow?.webContents) + // Someone tried to launch a second copy: treat it as "show me moho", which // is almost always what they meant - especially when the window is hidden // to the tray and looks like nothing is running. diff --git a/src/main/screenshare.ts b/src/main/screenshare.ts new file mode 100644 index 0000000..8ecd1ba --- /dev/null +++ b/src/main/screenshare.ts @@ -0,0 +1,130 @@ +import { desktopCapturer, ipcMain, webContents, type Session, type WebContents } from 'electron' +import { IPC, type ScreenSource } from '../shared/ipc' +import { log } from './log' + +/** + * Sharing a screen, the same way on every desktop. + * + * Every share in the app - a Matrix call, a Matrix call on a media server, + * a Discord stream - asks the one standard question, `getDisplayMedia`, and + * this answers it. What differs between desktops is only who chooses what is + * shown, and that is decided here, once, rather than in each place that + * shares: + * + * - **The desktop chooses** on Wayland. Every Wayland compositor - GNOME, + * KDE, sway, Hyprland - hands screens out through xdg-desktop-portal, which + * always puts up its own "what do you want to share" window and gives an + * application no list to draw one of its own from. So the request is + * answered straight away and the portal asks. + * - **moho chooses** everywhere else: X11, Windows, and macOS before 15. The + * screens and windows are listed and moho's own picker is shown in the + * window that asked. + * - **macOS 15 and later** have a system picker of their own, which Electron + * uses instead of calling this at all. + * + * The rule that must never be broken is that there is exactly one question. + * Listing sources on Wayland is not free - it opens a portal session to draw + * the thumbnails, which is a portal window of its own - and opening the chosen + * source opens a second. That was two windows for one share, and no picture + * until the second was answered. + */ + +/** + * Whether the desktop, rather than moho, chooses what a share shows. + * + * This has to agree with Chromium, because it is Chromium that decides which + * capturer to use, and if the two disagree a share asks twice or not at all. + * Chromium's rule (WebRTC's `DesktopCapturer::IsRunningUnderWayland`) is a + * Linux session whose XDG_SESSION_TYPE says Wayland *and* which has a Wayland + * display to talk to; it captures through the portal whether or not the + * window itself is drawn through XWayland. So this is that rule and nothing + * else - no compositor, distribution or GPU comes into it. + * + * `MOHO_SCREENSHARE=picker` or `=portal` overrides it, for a session that + * describes itself wrongly. + */ +export function desktopPicksScreen( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'linux') return false + if (env.MOHO_SCREENSHARE === 'picker') return false + if (env.MOHO_SCREENSHARE === 'portal') return true + return (env.XDG_SESSION_TYPE ?? '').startsWith('wayland') && !!env.WAYLAND_DISPLAY +} + +/** Questions put to a window's picker, waiting for its answer. */ +const waiting = new Map void>() +let nextQuestion = 1 + +/** + * Shows the picker in `contents` and waits for a choice. + * + * Answered with null if the window closes first, so a request is never left + * hanging on a picker nobody can see any more. + */ +function askWindow(contents: WebContents, sources: ScreenSource[]): Promise { + return new Promise((resolve) => { + const question = nextQuestion++ + const gone = (): void => answer(null) + const answer = (id: string | null): void => { + waiting.delete(question) + contents.off('destroyed', gone) + resolve(id) + } + waiting.set(question, answer) + contents.once('destroyed', gone) + contents.send(IPC.screenPick, question, sources) + }) +} + +/** + * Answers every `getDisplayMedia` in `ses`. + * + * `fallback` is the window to ask when the request cannot say which one it + * came from - a frame that has already navigated away. + */ +export function installScreenShare(ses: Session, fallback: () => WebContents | undefined): void { + ipcMain.on(IPC.screenPicked, (_event, question: number, id: string | null) => { + waiting.get(question)?.(typeof id === 'string' ? id : null) + }) + + const portal = desktopPicksScreen() + log.info(`[screenshare] ${portal ? 'the desktop portal chooses what is shared' : 'moho lists screens and windows to choose from'}`) + + ses.setDisplayMediaRequestHandler( + (request, callback) => { + if (portal) { + // Any id will do: the portal capturer ignores it and asks. + callback({ video: { id: 'screen:0:0', name: 'Entire screen' } }) + return + } + void (async () => { + try { + const sources = await desktopCapturer.getSources({ + types: ['screen', 'window'], + // A still worth recognising a window by, not worth waiting for. + thumbnailSize: { width: 320, height: 180 } + }) + const contents = (request.frame && webContents.fromFrame(request.frame)) || fallback() + if (!contents || sources.length === 0) { + callback({}) + return + } + const chosen = await askWindow( + contents, + sources.map((s) => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.toDataURL() })) + ) + const source = sources.find((s) => s.id === chosen) + // No stream is a refusal, which is what backing out of the picker is. + callback(source ? { video: source } : {}) + } catch (e) { + log.warn('[screenshare] could not list what to share:', (e as Error).message) + callback({}) + } + })() + }, + // macOS 15+: the system's own picker, and this handler is not called. + { useSystemPicker: true } + ) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8912bec..099968b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' -import { IPC, POPOUT_FLAG, type PopoutState } from '../shared/ipc' +import { IPC, POPOUT_FLAG, type PopoutState, type ScreenSource } from '../shared/ipc' import type { NobilisEvent } from '../shared/wire' /** @@ -136,15 +136,21 @@ const api = { pickFile: (): Promise => ipcRenderer.invoke(IPC.pickFile), /** - * What could be shared into a call: every screen and window, each with a - * still of what is on it. + * Answers the main process when it needs a screen or window chosen. * - * Asked of the desktop rather than of the browser. Electron refuses - * getDisplayMedia's own picker, and a person choosing what to show a room - * needs to see which window they are choosing. + * Asked in the middle of a `getDisplayMedia`, on the desktops where moho + * draws its own picker - see main/screenshare.ts. The handler resolves with + * the chosen source's id, or null for nothing. */ - screenSources: (): Promise<{ id: string; name: string; thumbnail: string }[]> => - ipcRenderer.invoke(IPC.screenSources), + onScreenPick(cb: (sources: ScreenSource[]) => Promise): () => void { + const handler = (_e: unknown, question: number, sources: ScreenSource[]): void => { + void cb(sources) + .catch(() => null) + .then((id) => ipcRenderer.send(IPC.screenPicked, question, id)) + } + ipcRenderer.on(IPC.screenPick, handler) + return () => ipcRenderer.off(IPC.screenPick, handler) + }, pickSavePath: (suggested?: string): Promise => ipcRenderer.invoke(IPC.pickSavePath, suggested), pickDirectory: (): Promise => ipcRenderer.invoke(IPC.pickDirectory), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 222a2ac..e7e43f3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,9 +18,11 @@ import { AccountsPanel } from './components/AccountsPanel' import { SettingsPanel } from './components/settings/SettingsPanel' import { DownloadsPanel } from './components/DownloadsPanel' import { JoinPanel } from './components/JoinPanel' +import { PeekBar, PeekView } from './components/PeekView' import { Toasts } from './components/Toasts' import { IncomingCallPanel } from './components/IncomingCallPanel' -import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage' +import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage' +import { CallWindowHost } from './components/stage/CallWindowHost' import { StreamStage } from './components/StreamStage' import { DiscordModal } from './components/DiscordModal' import { FileDrop } from './components/FileDrop' @@ -82,6 +84,10 @@ export default function App(): JSX.Element { * composer all belong to a buffer that is not on screen. */ const onMentionsPage = activePanel === '' && activeGroupId === MENTIONS_GROUP_ID + // A room being looked into stands where the conversation would. It has no + // buffer, so everything below that acts on the open conversation is off. + const peek = useChat((s) => s.peek) + const peeking = !!peek && activePanel === '' // Which Kick channels are actually being watched, told to the daemon as it // changes. Kick counts watch time from an authenticated subscription and @@ -99,7 +105,7 @@ export default function App(): JSX.Element { accounts.find((a) => a.id === allBuffers.find((b) => b.id === id)?.accountId)?.service const now = watchedKickBuffers( allBuffers, - activePanel === '' && !onMentionsPage ? activeBufferId : '', + activePanel === '' && !onMentionsPage && !peeking ? activeBufferId : '', watching?.bufferId, serviceOf ) @@ -113,17 +119,19 @@ export default function App(): JSX.Element { // daemon answers rather than treats as a failure. void window.moho.rpc('setKickWatching', { bufferId, watching: true }).catch(() => {}) } - }, [booted, allBuffers, accounts, activeBufferId, activePanel, onMentionsPage, watching]) + }, [booted, allBuffers, accounts, activeBufferId, activePanel, onMentionsPage, peeking, watching]) const openThread = useChat((s) => s.openThread) // A stale restored id is harmless: it simply resolves to no buffer and the // empty state shows, exactly as it would for "". const showNickList = - !userListFolded && activePanel === '' && !onMentionsPage && buffer?.kind === 'channel' + !userListFolded && activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' const headerTitle = - activePanel === 'accounts' + peeking + ? `Looking · ${peek?.name || peek?.alias || ''}` + : activePanel === 'accounts' ? 'Accounts' : activePanel === 'settings' ? 'Settings' @@ -169,7 +177,7 @@ export default function App(): JSX.Element { somewhere you go to find a conversation; this is where you already are when you want to do something to the one you are reading. */} - {activePanel === '' && !onMentionsPage && buffer ? ( + {activePanel === '' && !onMentionsPage && !peeking && buffer ? ( ) : ( {headerTitle} @@ -179,7 +187,7 @@ export default function App(): JSX.Element { conversation, which the mentions page is not showing - leaving them there would offer to search a channel that isn't on screen. */} - {activePanel === '' && !onMentionsPage && buffer && ( + {activePanel === '' && !onMentionsPage && !peeking && buffer && ( )} @@ -191,7 +199,7 @@ export default function App(): JSX.Element { direct message. */} {activePanel === '' && } - {activePanel === '' && !onMentionsPage && buffer?.kind === 'channel' && ( + {activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' && ( } - {activePanel !== '' && ( + {activePanel === '' && !onMentionsPage && !peeking && buffer && } + {(activePanel !== '' || peeking) && ( store.setActivePanel('')} + title={peeking ? 'Stop looking' : 'Back to chat'} + onClick={() => (peeking ? store.stopPeek() : store.setActivePanel(''))} /> )} @@ -219,10 +227,15 @@ export default function App(): JSX.Element { hasAccounts={accounts.length > 0} hasBuffer={!!activeBufferId} activeGroupId={activeGroupId} + peeking={peeking} /> - {activePanel === '' && activeBufferId !== '' && activeGroupId !== MENTIONS_GROUP_ID && ( + {/* Where the box would be: what this pane is, and the way in. */} + {peeking && } + + {/* No box while looking: there is nobody here to say it as. */} + {activePanel === '' && !peeking && activeBufferId !== '' && activeGroupId !== MENTIONS_GROUP_ID && ( <> {/* Above the box, saying why it will not work here yet. */} {buffer && } @@ -274,6 +287,10 @@ export default function App(): JSX.Element { it belongs to it sits above the log instead - see MessageList - so the two are never both on screen. */} {callElsewhere && } + {/* The call's sound, played from here whatever is drawing its pictures, + and the window a call can be popped out into. */} + + {/* A stream keeps playing when you go and read something else, and goes to the same corner - it is the same surface, differently fed. */} {streamElsewhere && } @@ -311,12 +328,14 @@ function Body({ activePanel, hasAccounts, hasBuffer, - activeGroupId + activeGroupId, + peeking }: { activePanel: string hasAccounts: boolean hasBuffer: boolean activeGroupId: string + peeking: boolean }): JSX.Element { if (activePanel === 'settings') return // Above the no-accounts case below: a finished download is still worth @@ -326,6 +345,8 @@ function Body({ // show - there is nothing to chat in yet. if (activePanel === 'accounts' || !hasAccounts) return if (activePanel === 'join') return + // A room being looked into: the pane's own view, with nothing to type into. + if (peeking) return // The mentions page replaces the log rather than sitting beside it: it is a // list of places to go, and every row leads into a conversation. if (activeGroupId === MENTIONS_GROUP_ID) return diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 6776e74..cfdb35e 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -2034,136 +2034,6 @@ img.composer-emoji { gap: var(--spacing-s); } -/* --- the call, while it is happening ------------------------------------ */ - -.call-view { - flex: none; - border-bottom: 1px solid var(--outline-medium); - background: var(--surface-container-low); -} - -.call-view-head { - display: flex; - align-items: center; - gap: var(--spacing-s); - padding: var(--spacing-xs) var(--spacing-m); -} - -/* The title takes the slack so the buttons stay at the right edge. */ -.call-view-head > .ellipsis { - flex: 1; - min-width: 0; - font-weight: 500; -} - -.call-view-head .calling { - color: var(--error); -} - -.call-tiles { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-m); - padding: var(--spacing-s) var(--spacing-m) var(--spacing-m); -} - -.call-tile { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-xs); - width: 88px; - text-align: center; -} - -/* The ring, drawn on the picture rather than the tile so it reads as being - around the person and not around a box. Outline rather than border: a - border would resize the avatar every time somebody started talking, and - the whole row would twitch. */ -.call-tile img, -.call-tile .avatar-fallback { - outline: 3px solid transparent; - outline-offset: 2px; - transition: outline-color 0.12s ease; -} - -.call-tile.talking img, -.call-tile.talking .avatar-fallback { - outline-color: var(--success); -} - -/* Holds the badges against the picture. */ -.call-face { - position: relative; - display: inline-flex; -} - -.call-badges { - position: absolute; - right: -2px; - bottom: -2px; - display: flex; - gap: 1px; - padding: 2px; - border-radius: 999px; - /* Its own plate, because these sit over an avatar that could be any - colour and a bare glyph would vanish against half of them. */ - background: var(--surface-container-highest); - color: var(--surface-text); -} - -/* Says a stream exists; deliberately not a button that opens one. */ -.call-watch { - padding: 0 var(--spacing-xs); - border: 1px solid var(--outline-medium); - border-radius: 999px; - background: none; - color: var(--surface-variant-text); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.call-watch[disabled] { - opacity: 0.75; - cursor: not-allowed; -} - -/* The one being watched, so a row of Live badges says which one is open. */ -.call-watch.active { - border-color: var(--primary); - color: var(--primary); -} - -/* Somebody else's shared screen, above the row of faces rather than among - them: it is the thing being looked at, and a picture sized like an avatar - is not worth having. */ -.discord-stream-tile { - display: flex; - flex-direction: column; - gap: var(--spacing-xxs); - margin-bottom: var(--spacing-xs); -} - -.discord-stream-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-xs); -} - -/* Height-capped rather than free: the canvas takes its pixel size from the - stream, and a 1080p share would otherwise push the roster off the panel. - `contain` because the sender chooses the shape and letterboxing a picture - is better than stretching somebody's desktop. */ -.discord-stream-canvas { - width: 100%; - max-height: 320px; - object-fit: contain; - border-radius: var(--corner-radius-small); - background: #000; -} - /* The bar that appears while picking people for a group message. */ .group-dm-bar { display: flex; @@ -6800,77 +6670,6 @@ video.lightbox-media { color: var(--error); } -/* --- Looking into a room ---------------------------------------------- */ - -/* What is in a room, read from outside it. Sits under the list rather than - over it, so the row it came from stays visible - the point of looking is to - decide about that room, and a panel covering it takes away what the decision - is about. */ -.room-peek { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - margin: var(--spacing-s) 0; - padding: var(--spacing-s); - border: 1px solid var(--outline-medium); - border-radius: var(--corner-radius-small); - background: var(--surface-container-lowest); -} - -.room-peek-head { - display: flex; - align-items: center; - gap: var(--spacing-s); - font-weight: 500; -} - -.room-peek-head .ellipsis { - flex: 1; - min-width: 0; -} - -.room-peek-topic { - color: var(--surface-variant-text); - /* A topic can be a paragraph. Three lines is enough to judge a room by and - short enough that the conversation under it stays on screen. */ - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.room-peek-log { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 240px; - overflow-y: auto; -} - -.room-peek-line { - display: flex; - gap: var(--spacing-s); - align-items: baseline; -} - -.room-peek-from { - flex: none; - max-width: 12ch; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--surface-variant-text); - font-size: 0.82rem; -} - -.room-peek-body { - min-width: 0; - font-size: 0.85rem; - /* Somebody else's text, from a room nobody here has joined: it wraps and - it never stretches the dialog. */ - overflow-wrap: anywhere; -} - /* --- Link previews ---------------------------------------------------- */ /* The picture a homeserver found behind a link. Capped rather than sized to @@ -7085,3 +6884,370 @@ video.lightbox-media { gap: var(--spacing-xs); color: var(--surface-variant-text); } + +.room-search-suggested { padding: var(--spacing-xs) var(--spacing-m) var(--spacing-s); display: flex; flex-direction: column; gap: var(--spacing-xs); } +.room-search-suggested-toggle, +.room-search-suggested-all { align-self: flex-start; background: none; border: 0; padding: 0; color: var(--primary); cursor: pointer; font: inherit; } +.room-search-suggested-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--spacing-xxs) var(--spacing-m); max-height: 180px; overflow-y: auto; } +.room-search-suggestion { display: flex; flex-direction: column; align-items: flex-start; text-align: left; background: none; border: 0; padding: var(--spacing-xxs) 0; color: inherit; cursor: pointer; font: inherit; } +.room-search-suggestion:hover .room-search-suggestion-name { text-decoration: underline; } +.room-search-suggestion-name { font-weight: 600; } + +/* A room being looked into, in the pane a conversation would fill. */ +.peek-view { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.peek-facts { padding: var(--spacing-xs) var(--spacing-m); border-bottom: 1px solid var(--outline-light); } +.peek-bar { flex: none; background: var(--surface-container-high); } +.peek-bar-row { display: flex; align-items: center; gap: var(--spacing-s); padding: var(--spacing-s) var(--spacing-m); } +.peek-bar-text { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.peek-topic { padding: var(--spacing-xs) var(--spacing-m); color: var(--surface-variant-text); border-bottom: 1px solid var(--outline-light); } +.peek-log { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: var(--spacing-s) 0 var(--spacing-m); } + +/* --- The call stage ---------------------------------------------------- */ + +/* A call, drawn the way Discord's client draws one: a black stage above the + conversation that looks like a video before there is any video in it. + Deliberately the same in both themes - it is a picture, and pictures are + shown on black. */ +.stage { + position: relative; + flex: none; + background: #000; + color: #f2f3f5; + overflow: hidden; + user-select: none; +} + +.stage.in-window, +.stage.fullscreen { + position: fixed; + inset: 0; + height: auto; +} + +.stage-window-root { + position: fixed; + inset: 0; + background: #000; +} + +.stage.collapsed { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-s); +} + +.stage.collapsed .stage-head { + position: static; + flex: 1; + min-width: 0; + padding: 0; + background: none; +} + +.stage-head { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + align-items: center; + gap: var(--spacing-s); + height: 44px; + padding: 0 var(--spacing-s) 0 var(--spacing-m); + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent); +} + +/* A popped-out stage is its window's title bar too: frameless, so the head + is what the window is moved by. Buttons stay pressable. */ +.stage-head.window-drag { + -webkit-app-region: drag; +} + +.stage-head.window-drag button { + -webkit-app-region: no-drag; +} + +.stage-title { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-weight: 600; +} + +.stage-subtitle { + font-weight: 400; + font-size: 12px; + color: #b5bac1; + margin-left: var(--spacing-xs); +} + +.stage-head-buttons { + display: flex; + gap: 2px; +} + +.stage-head-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 6px; + background: transparent; + color: #dbdee1; + cursor: pointer; +} + +.stage-head-button:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.stage-area { + position: absolute; + left: var(--spacing-m); + right: var(--spacing-m); + display: flex; + align-items: center; + justify-content: center; +} + +.stage-empty { + color: #949ba4; +} + +.stage-grid { + display: grid; + gap: 8px; + justify-content: center; + align-content: center; +} + +.stage-focus { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; + height: 100%; + justify-content: center; +} + +.stage-strip { + display: flex; + gap: 8px; + justify-content: center; + max-width: 100%; + overflow-x: auto; +} + +.stage-tile { + position: relative; + aspect-ratio: 16 / 9; + max-width: 100%; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + outline: none; + transition: box-shadow 0.12s ease; +} + +.stage-tile.strip { + flex: none; +} + +.stage-tile:focus-visible { + box-shadow: inset 0 0 0 2px #00a8fc; +} + +/* Who is talking: the green ring Discord draws. */ +.stage-tile.speaking { + box-shadow: inset 0 0 0 3px #23a55a; +} + +.stage-tile-face .avatar-fallback, +.stage-tile-face img { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.stage-picture { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.stage-tile-name { + position: absolute; + left: 8px; + bottom: 8px; + z-index: 1; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 16px); + padding: 2px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 13px; + font-weight: 500; +} + +.stage-tile.strip .stage-tile-name { + left: 4px; + bottom: 4px; + font-size: 11px; + padding: 1px 6px; +} + +.stage-tile-state { + color: #f23f43; +} + +.stage-tile-live { + position: absolute; + top: 8px; + left: 8px; + z-index: 1; + padding: 1px 6px; + border-radius: 4px; + background: #da373c; + color: #fff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.stage-tile-action { + position: absolute; + z-index: 1; + padding: 8px 16px; + border: 0; + border-radius: 4px; + background: rgba(0, 0, 0, 0.72); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.stage-tile-action:hover { + background: #5865f2; +} + +.stage-tile-dismiss { + position: absolute; + top: 6px; + right: 6px; + z-index: 1; + display: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: #fff; + cursor: pointer; +} + +.stage-tile:hover .stage-tile-dismiss { + display: inline-flex; +} + +/* The buttons, along the bottom middle. */ +.stage-controls { + position: absolute; + left: 0; + right: 0; + bottom: 14px; + z-index: 2; + display: flex; + justify-content: center; + gap: 10px; +} + +.stage-controls.compact { + position: static; + gap: 4px; +} + +.stage-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border: 0; + border-radius: 50%; + background: #2b2d31; + color: #f2f3f5; + cursor: pointer; + transition: background 0.12s ease; +} + +.stage-button.compact { + width: 32px; + height: 32px; +} + +.stage-button:hover { + background: #3a3c42; +} + +/* Something switched on that is doing something: a screen being shared. */ +.stage-button.active { + background: #f2f3f5; + color: #111214; +} + +/* Something switched off that normally is on: a muted microphone. */ +.stage-button.off { + background: #f2f3f5; + color: #da373c; +} + +.stage-button.danger { + background: #da373c; + color: #fff; +} + +.stage-button.danger:hover { + background: #a12828; +} + +.stage-resize { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 3; + height: 6px; + cursor: ns-resize; +} + +.stage-resize:hover { + background: rgba(255, 255, 255, 0.12); +} + +/* The line left above the conversation while the call is in its own window. */ +.stage-away { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-m); + background: var(--surface-container-high); + border-bottom: 1px solid var(--outline-light); +} diff --git a/src/renderer/src/components/CallStage.tsx b/src/renderer/src/components/CallStage.tsx index 4de851f..08e722e 100644 --- a/src/renderer/src/components/CallStage.tsx +++ b/src/renderer/src/components/CallStage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Icon, IconButton } from './Icon' import { VideoStage } from './VideoStage' +import { Stage, fitTiles, type StageButton, type StageTile } from './stage/Stage' import { Avatar } from './Avatar' import { useChat, useStore } from '../state/hooks' import type { CallTile } from '../state/store' @@ -22,141 +23,159 @@ 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. */ -/** 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 +export { fitTiles } from './stage/Stage' + +/** The gap between tiles in the corner player. */ 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 { +export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null { const store = useStore() const call = useChat((s) => s.activeCall) const minimized = useChat((s) => s.callMinimized) + const poppedOut = useChat((s) => s.callPoppedOut) const buffers = useChat((s) => s.buffers) 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. - */ + /** How wide the corner player's grid is, which decides its layout. */ + const [room, setRoom] = useState({ width: 320, height: 180 }) + /** Whose picture the corner player is showing large, if anybody. */ const [focused, setFocused] = useState(null) - // 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) - }) + if (!el || mode !== 'pip') return + const measure = (): void => setRoom({ width: el.clientWidth, height: Math.min(window.innerHeight * 0.3, 260) }) measure() const observer = new ResizeObserver(measure) observer.observe(el) - window.addEventListener('resize', measure) - return () => { - observer.disconnect() - window.removeEventListener('resize', measure) - } + return () => observer.disconnect() }, [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. + // elements are pointed at them rather than re-rendered from them. Every + // element showing a person is kept pointed at their stream, however many + // places they are drawn at once. useEffect(() => { if (!call) return const attach = (): void => { 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.length === current.length && + was.every( + (t, i) => + t.id === current[i].id && + t.hasVideo === current[i].hasVideo && + t.speaking === current[i].speaking && + t.muted === current[i].muted + ) ? 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 + for (const [key, el] of videos.current) { + if (key.startsWith(`${tile.id}#`) && tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream } } } attach() - const timer = setInterval(attach, 700) + const timer = setInterval(attach, 300) return () => clearInterval(timer) }, [call, store]) if (!call) return null + // In a window of its own: the conversation keeps a line saying so, and the + // corner keeps nothing. + if (mode !== 'window' && poppedOut) { + if (mode === 'pip') return null + return ( +
+ + The call is in its own window. + +
+ ) + } + 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' - // Who is in it, named. Only in the corner: in the conversation the tiles are - // large enough to read the names off, and a second list of them would be the - // same information twice. + /** + * A video element for one person in one place. Always muted: the call's + * sound is played once, by CallAudio, not by every picture of every person + * - two pictures of one person would otherwise be two voices. + */ + const video = (tile: CallTile, slot: string, className: string): JSX.Element => ( +