feat: Electron screen share picker, instance selector, and system audio loopback

- Custom screen share picker for Electron (ScreenSharePicker.tsx) with
  Aether Drift design: glass-bubble footer, adaptive grid, pill tabs,
  border-based selection (avoids overflow clipping), hover brightness
- Instance URL picker so Electron connects to any Backspace server
- System audio loopback on Windows/Linux via desktopCapturer callback
- macOS: video-only callback (OS blocks system audio capture)
- IPC bridge for screen source enumeration and selection
- Purge stale service worker caches on Electron launch
This commit is contained in:
Jannis Braun
2026-03-16 03:50:07 +01:00
parent 8ae3ffc912
commit 95ec3ff952
20 changed files with 1042 additions and 95 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

+1
View File
@@ -4,6 +4,7 @@ directories:
output: dist-electron output: dist-electron
files: files:
- dist/**/* - dist/**/*
- resources/**/*
- "!node_modules" - "!node_modules"
publish: publish:
- provider: generic - provider: generic
@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backspace — Connect</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0b0b10;
color: #d8d8de;
display: flex;
flex-direction: column;
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Subtle radial gradient at top — matches login page */
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at top, rgba(124,108,246,0.06) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* ── Titlebar drag strip ── */
.titlebar {
height: 32px;
min-height: 32px;
background: #0b0b10;
-webkit-app-region: drag;
position: relative;
z-index: 100;
}
/* ── Main content area ── */
.content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
padding: 0 2rem 2rem;
}
.container {
-webkit-app-region: no-drag;
width: 100%;
max-width: 480px;
}
/* ── Card — solid elevated surface (matches login) ── */
.card {
background: #252530;
border-radius: 6px;
padding: 2rem;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
}
/* ── Header ── */
.header {
text-align: center;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: #efefef;
margin-bottom: 0.25rem;
}
.header p {
font-size: 0.875rem;
color: #5c5c68;
margin-top: 0.25rem;
}
/* ── Form field ── */
.field {
margin-bottom: 1.25rem;
}
.field label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: #a0a0aa;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0.5rem;
}
.field input {
width: 100%;
padding: 0.625rem 0.75rem;
background: #111118;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
color: #efefef;
font-family: inherit;
font-size: 0.875rem;
outline: none;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.field input::placeholder {
color: #5c5c68;
}
.field input:focus {
box-shadow: 0 0 0 2px #7c6cf6, inset 0 1px 2px rgba(0, 0, 0, 0.25);
border-color: rgba(124, 108, 246, 0.3);
}
.field input:disabled {
opacity: 0.5;
}
/* ── Button — accent-primary with white text (matches login) ── */
.connect-btn {
width: 100%;
padding: 0.625rem;
background: #7c6cf6;
color: #ffffff;
border: none;
border-radius: 4px;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.connect-btn:hover:not(:disabled) {
background: rgba(124, 108, 246, 0.8);
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Error display — matches login error style ── */
.error-box {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(253, 164, 175, 0.1);
border: 1px solid rgba(253, 164, 175, 0.3);
border-radius: 4px;
color: #fca5a5;
font-size: 0.875rem;
text-align: center;
}
/* ── Instance info — success card with mint accent ── */
.instance-info {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(134, 239, 172, 0.1);
border: 1px solid rgba(134, 239, 172, 0.25);
border-radius: 4px;
text-align: center;
}
.instance-info .name {
font-weight: 600;
color: #efefef;
font-size: 0.875rem;
}
.instance-info .version {
font-size: 0.75rem;
color: #a0a0aa;
margin-top: 0.125rem;
}
/* ── Loading state ── */
.loading-text {
margin-top: 1rem;
text-align: center;
font-size: 0.8125rem;
color: #5c5c68;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(92, 92, 104, 0.3);
border-top-color: #5c5c68;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 0.375rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Autofill dark theme ── */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #efefef;
-webkit-box-shadow: 0 0 0px 1000px #111118 inset;
transition: background-color 5000s ease-in-out 0s;
}
/* ── Selection ── */
::selection {
background: rgba(134, 239, 172, 0.25);
color: #efefef;
}
/* ── Accessibility: reduced transparency ── */
@media (prefers-reduced-transparency: reduce) {
.card {
background: #252530;
}
}
</style>
</head>
<body>
<div class="titlebar"></div>
<div class="content">
<div class="container">
<div class="card">
<div class="header">
<h1>Welcome to Backspace</h1>
<p>Connect to your instance</p>
</div>
<form id="picker-form">
<div class="field">
<label for="url-input">Instance URL</label>
<input
id="url-input"
type="text"
placeholder="backspace.example.com"
autocomplete="off"
spellcheck="false"
autofocus
/>
</div>
<button type="submit" class="connect-btn" id="connect-btn">Connect</button>
</form>
<div id="instance-info" class="instance-info" style="display: none;">
<div class="name" id="instance-name"></div>
<div class="version" id="instance-version"></div>
</div>
<div id="error-box" class="error-box" style="display: none;"></div>
<div id="loading" class="loading-text" style="display: none;">
<span class="spinner"></span> Connecting...
</div>
</div>
</div>
</div>
<script>
const form = document.getElementById('picker-form');
const input = document.getElementById('url-input');
const btn = document.getElementById('connect-btn');
const errorBox = document.getElementById('error-box');
const loadingEl = document.getElementById('loading');
const infoEl = document.getElementById('instance-info');
const nameEl = document.getElementById('instance-name');
const versionEl = document.getElementById('instance-version');
function normalizeUrl(raw) {
let url = raw.trim();
if (!url) return '';
// Strip trailing slashes
url = url.replace(/\/+$/, '');
// Prepend https:// if no protocol
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
return url;
}
function showError(text) {
errorBox.textContent = text;
errorBox.style.display = 'block';
loadingEl.style.display = 'none';
}
function hideError() {
errorBox.style.display = 'none';
}
function setLoading(loading) {
input.disabled = loading;
btn.disabled = loading;
loadingEl.style.display = loading ? 'block' : 'none';
if (loading) {
hideError();
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = normalizeUrl(input.value);
if (!url) {
showError('Please enter an instance URL');
return;
}
infoEl.style.display = 'none';
hideError();
setLoading(true);
try {
const res = await fetch(url + '/api/instance/info', {
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
throw new Error('Server returned ' + res.status);
}
const data = await res.json();
if (!data.name) {
throw new Error('Not a Backspace instance');
}
// Show instance info
nameEl.textContent = data.name;
versionEl.textContent = data.version ? 'v' + data.version : '';
infoEl.style.display = 'block';
loadingEl.style.display = 'none';
// Save and navigate
if (window.backspace && window.backspace.setInstanceUrl) {
await window.backspace.setInstanceUrl(url);
}
} catch (err) {
setLoading(false);
infoEl.style.display = 'none';
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
showError('Connection timed out — check the URL and try again');
} else if (err.message === 'Failed to fetch' || err.name === 'TypeError') {
showError('Could not connect — check the URL and try again');
} else {
showError('Could not connect — ' + err.message);
}
}
});
</script>
</body>
</html>
+214 -5
View File
@@ -8,6 +8,8 @@ import {
ipcMain, ipcMain,
shell, shell,
screen, screen,
session,
desktopCapturer,
} from 'electron'; } from 'electron';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -17,9 +19,36 @@ let tray: Tray | null = null;
let isQuitting = false; let isQuitting = false;
let pendingDeepLink: string | null = null; let pendingDeepLink: string | null = null;
const DEV_URL = 'http://localhost:5173'; // ─── Instance URL Persistence ────────────────────────────────────────────────
const PROD_URL = 'http://localhost:3000';
const SERVER_URL = process.env.BACKSPACE_URL || (app.isPackaged ? PROD_URL : DEV_URL); function getInstanceUrlPath(): string {
return path.join(app.getPath('userData'), 'instance-url.json');
}
function loadInstanceUrl(): string | null {
try {
const data = JSON.parse(fs.readFileSync(getInstanceUrlPath(), 'utf-8'));
return typeof data.url === 'string' ? data.url : null;
} catch {
return null;
}
}
function saveInstanceUrl(url: string): void {
fs.writeFileSync(getInstanceUrlPath(), JSON.stringify({ url }));
}
function clearInstanceUrl(): void {
try {
fs.unlinkSync(getInstanceUrlPath());
} catch {
// File may not exist — ignore
}
}
function getPickerPath(): string {
return path.join(__dirname, '..', 'resources', 'instance-picker.html');
}
// ─── Window State Persistence ─────────────────────────────────────────────── // ─── Window State Persistence ───────────────────────────────────────────────
@@ -171,7 +200,21 @@ function createWindow(): void {
mainWindow.maximize(); mainWindow.maximize();
} }
mainWindow.loadURL(SERVER_URL); // URL resolution priority:
// 1. BACKSPACE_URL env var (managed deployments)
// 2. Saved instance URL from picker
// 3. No URL → show instance picker
const envUrl = process.env.BACKSPACE_URL;
if (envUrl) {
mainWindow.loadURL(envUrl);
} else {
const savedUrl = loadInstanceUrl();
if (savedUrl) {
mainWindow.loadURL(savedUrl);
} else {
mainWindow.loadFile(getPickerPath());
}
}
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow?.show(); mainWindow?.show();
@@ -248,6 +291,15 @@ function createTray(): void {
mainWindow?.hide(); mainWindow?.hide();
}, },
}, },
{
label: 'Change Instance',
click: () => {
clearInstanceUrl();
mainWindow?.loadFile(getPickerPath());
mainWindow?.show();
mainWindow?.focus();
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Quit', label: 'Quit',
@@ -319,6 +371,39 @@ function registerIpcHandlers(): void {
mainWindow?.close(); mainWindow?.close();
}); });
// Instance URL management
ipcMain.handle('get-instance-url', () => loadInstanceUrl());
ipcMain.handle('set-instance-url', (_event, url: string) => {
saveInstanceUrl(url);
if (mainWindow) {
mainWindow.loadURL(url);
// Force Electron to re-evaluate drag regions after navigation
mainWindow.webContents.once('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
mainWindow.setSize(bounds.width + 1, bounds.height);
mainWindow.setSize(bounds.width, bounds.height);
}
});
}
});
ipcMain.handle('clear-instance-url', () => {
clearInstanceUrl();
if (mainWindow) {
mainWindow.loadFile(getPickerPath());
// Force Electron to re-evaluate drag regions after navigation
mainWindow.webContents.once('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
mainWindow.setSize(bounds.width + 1, bounds.height);
mainWindow.setSize(bounds.width, bounds.height);
}
});
}
});
// Auto-update IPC // Auto-update IPC
ipcMain.on('install-update', () => { ipcMain.on('install-update', () => {
try { try {
@@ -337,6 +422,12 @@ function registerIpcHandlers(): void {
// Auto-updater not available // Auto-updater not available
} }
}); });
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('screen-share-selected', (_event, sourceId: string | null) => {
// Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings
});
} }
// ─── Auto-Update ──────────────────────────────────────────────────────────── // ─── Auto-Update ────────────────────────────────────────────────────────────
@@ -420,7 +511,125 @@ if (!gotTheLock) {
// ─── App Lifecycle ────────────────────────────────────────────────────────── // ─── App Lifecycle ──────────────────────────────────────────────────────────
app.whenReady().then(() => { app.whenReady().then(async () => {
// macOS application menu with "Change Instance"
if (process.platform === 'darwin') {
const appMenu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: 'Change Instance',
click: () => {
clearInstanceUrl();
mainWindow?.loadFile(getPickerPath());
mainWindow?.show();
mainWindow?.focus();
},
},
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
},
]);
Menu.setApplicationMenu(appMenu);
}
// Purge ALL stale caches so Electron always loads fresh code on launch
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
await session.defaultSession.clearCache();
// Intercept getDisplayMedia() — show custom picker in renderer
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
console.log('[Main:ScreenShare] Handler invoked');
try {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
console.log('[Main:ScreenShare] Got', sources.length, 'sources');
if (sources.length === 0) {
console.warn('[Main:ScreenShare] No sources — macOS Screen Recording permission may not be granted');
// @ts-ignore — Electron throws if we pass {} when video was requested; pass nothing to deny
callback();
return;
}
const serialized = sources.map((source) => ({
id: source.id,
name: source.name,
thumbnailDataUrl: source.thumbnail.toDataURL(),
appIconDataUrl: source.appIcon && !source.appIcon.isEmpty()
? source.appIcon.toDataURL() : null,
isScreen: source.id.startsWith('screen:'),
}));
// Send sources to renderer, wait for user selection
mainWindow?.webContents.send('screen-share-sources', serialized);
const sourceId = await new Promise<string | null>((resolve) => {
ipcMain.once('screen-share-selected', (_event, id: string | null) => {
resolve(id);
});
});
console.log('[Main:ScreenShare] User selected:', sourceId);
if (!sourceId) {
// @ts-ignore — deny the request without crashing
callback();
return;
}
const selected = sources.find((s) => s.id === sourceId);
if (!selected) {
// @ts-ignore — deny the request without crashing
callback();
return;
}
// Provide the selected source — Electron creates the MediaStream
// Enable system audio loopback on Windows/Linux (macOS blocks at OS level)
if (process.platform === 'darwin') {
callback({ video: selected });
} else {
callback({ video: selected, audio: 'loopback' });
}
} catch (err) {
console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing
callback();
}
});
registerIpcHandlers(); registerIpcHandlers();
createWindow(); createWindow();
createTray(); createTray();
+13
View File
@@ -49,4 +49,17 @@ contextBridge.exposeInMainWorld('backspace', {
onDeepLink: (callback: (url: string) => void) => { onDeepLink: (callback: (url: string) => void) => {
ipcRenderer.on('deep-link', (_event, url) => callback(url)); ipcRenderer.on('deep-link', (_event, url) => callback(url));
}, },
// Screen share picker coordination
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
},
selectScreenSource: (sourceId: string | null) => {
ipcRenderer.send('screen-share-selected', sourceId);
},
// Instance URL management
getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'),
setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url),
clearInstanceUrl: () => ipcRenderer.invoke('clear-instance-url'),
}); });
+53 -44
View File
@@ -5,7 +5,9 @@ import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { JoinPage } from './components/JoinPage'; import { JoinPage } from './components/JoinPage';
import { SwAutoUpdate } from './components/ui/SwUpdatePrompt'; import { SwAutoUpdate } from './components/ui/SwUpdatePrompt';
import { ScreenSharePicker } from './components/voice/ScreenSharePicker';
import { useAuthStore } from './stores/authStore'; import { useAuthStore } from './stores/authStore';
import { isElectronMac } from './platform/platform';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@@ -27,50 +29,57 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
} }
export function App() { export function App() {
return ( const showTitleBar = isElectronMac();
<>
<SwAutoUpdate />
<Routes>
<Route
path="/login"
element={
<AuthRedirect>
<LoginPage />
</AuthRedirect>
}
/>
<Route
path="/register"
element={
<AuthRedirect>
<RegisterPage />
</AuthRedirect>
}
/>
<Route
path="/channels/:spaceId/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route
path="/join/:inviteCode"
element={<JoinPage />}
/>
<Route
path="/explore"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
</>
return (
<div className={`flex flex-col ${showTitleBar ? 'h-screen' : 'contents'}`}>
{showTitleBar && (
<div className="h-8 flex-shrink-0 bg-surface-base titlebar-drag" />
)}
<div className={showTitleBar ? 'flex-1 min-h-0' : 'contents'}>
<SwAutoUpdate />
<ScreenSharePicker />
<Routes>
<Route
path="/login"
element={
<AuthRedirect>
<LoginPage />
</AuthRedirect>
}
/>
<Route
path="/register"
element={
<AuthRedirect>
<RegisterPage />
</AuthRedirect>
}
/>
<Route
path="/channels/:spaceId/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route
path="/join/:inviteCode"
element={<JoinPage />}
/>
<Route
path="/explore"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
</div>
</div>
); );
} }
+3 -3
View File
@@ -197,7 +197,7 @@ export function JoinPage() {
// Loading state // Loading state
if (isLoadingPreview) { if (isLoadingPreview) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="text-center relative z-10"> <div className="text-center relative z-10">
<svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none"> <svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none">
@@ -213,7 +213,7 @@ export function JoinPage() {
// Error state — invalid/expired invite // Error state — invalid/expired invite
if (previewError || !preview) { if (previewError || !preview) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 text-center"> <div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-accent-rose/10 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-accent-rose/10 flex items-center justify-center">
@@ -247,7 +247,7 @@ export function JoinPage() {
// Main invite page // Main invite page
return ( return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10"> <div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
{/* Space preview */} {/* Space preview */}
@@ -61,7 +61,7 @@ export function LoginPage() {
const isDisabled = isLoading || retryAfter > 0; const isDisabled = isLoading || retryAfter > 0;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10"> <div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
<div className="text-center mb-6"> <div className="text-center mb-6">
@@ -236,7 +236,7 @@ export function RegisterPage() {
const isDisabled = isRegistering || retryAfter > 0; const isDisabled = isRegistering || retryAfter > 0;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden"> <div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden">
{/* Progress dots */} {/* Progress dots */}
@@ -255,7 +255,7 @@ export function AppLayout() {
if (isLoading || !user) { if (isLoading || !user) {
return ( return (
<div className="h-screen flex items-center justify-center bg-surface-chat"> <div className="h-full flex items-center justify-center bg-surface-chat">
<div className="text-center"> <div className="text-center">
<svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none"> <svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -268,7 +268,7 @@ export function AppLayout() {
} }
return ( return (
<div className="h-screen flex flex-col md:grid md:grid-cols-[312px_1fr] md:grid-rows-[minmax(0,1fr)] bg-surface-base overflow-hidden"> <div className="h-full flex flex-col md:grid md:grid-cols-[312px_1fr] md:grid-rows-[minmax(0,1fr)] bg-surface-base overflow-hidden">
{/* Space sidebar - always visible on desktop, toggled on mobile */} {/* Space sidebar - always visible on desktop, toggled on mobile */}
<div className={`fixed inset-y-0 left-0 z-40 flex w-[312px] transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} md:static md:z-auto md:w-auto md:transform-none`}> <div className={`fixed inset-y-0 left-0 z-40 flex w-[312px] transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} md:static md:z-auto md:w-auto md:transform-none`}>
<SpaceSidebar /> <SpaceSidebar />
@@ -13,6 +13,7 @@ import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
import { isElectronMac } from '../../platform/platform';
import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { useFloatingPosition } from '../../hooks/useFloatingPosition';
// ─── Resolved layout types ───────────────────────────────────────────────── // ─── Resolved layout types ─────────────────────────────────────────────────
@@ -1247,7 +1248,7 @@ export function SpaceSidebar() {
}, [openFolderId, resolvedLayout]); }, [openFolderId, resolvedLayout]);
return ( return (
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }} onDragOver={(e) => { if (dragState) e.preventDefault(); }} onDrop={handleDrop}> <nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24, ...(isElectronMac() ? { top: '32px' } : {}) }} onDragOver={(e) => { if (dragState) e.preventDefault(); }} onDrop={handleDrop}>
<SidebarItem <SidebarItem
id="@me" id="@me"
name="Direct Messages" name="Direct Messages"
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import type { InstanceInfoResponse } from '@backspace/shared'; import type { InstanceInfoResponse } from '@backspace/shared';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { isElectron } from '../../platform/platform';
// ─── Status indicator ──────────────────────────────────────────────────────── // ─── Status indicator ────────────────────────────────────────────────────────
@@ -411,8 +412,16 @@ export function ConnectedInstances() {
</div> </div>
</div> </div>
</div> </div>
<div className="text-xs text-txt-tertiary shrink-0 ml-2"> <div className="flex items-center gap-2 shrink-0 ml-2">
Local <span className="text-xs text-txt-tertiary">Local</span>
{isElectron() && (
<button
onClick={() => window.backspace?.clearInstanceUrl()}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary hover:bg-white/[0.04] rounded transition-colors"
>
Change
</button>
)}
</div> </div>
</div> </div>
@@ -9,6 +9,7 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
import { AVATAR_COLORS } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared';
import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared';
import type { FederationOpResult } from '../../../utils/federationOps'; import type { FederationOpResult } from '../../../utils/federationOps';
import { isElectron } from '../../../platform/platform';
export function AccountPanel() { export function AccountPanel() {
@@ -687,6 +688,27 @@ export function AccountPanel() {
</form> </form>
</div> </div>
{/* ── Connected Instance (Electron only) ── */}
{isElectron() && (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Connected Instance</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-txt-primary font-medium">{window.location.origin}</div>
<div className="text-xs text-txt-tertiary mt-0.5">Currently connected instance</div>
</div>
<button
onClick={() => window.backspace?.clearInstanceUrl()}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors"
>
Change Instance
</button>
</div>
</div>
</div>
)}
{/* ── Danger Zone ── */} {/* ── Danger Zone ── */}
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Danger Zone</div> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Danger Zone</div>
@@ -1,13 +1,10 @@
import { useRegisterSW } from 'virtual:pwa-register/react'; import { useRegisterSW } from 'virtual:pwa-register/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { isElectron } from '../../platform/platform';
export function SwAutoUpdate() { export function SwAutoUpdate() {
const inElectron = isElectron();
useRegisterSW({ useRegisterSW({
onRegisteredSW(_swUrl, registration) { onRegisteredSW(_swUrl, registration) {
if (!registration || inElectron) return; if (!registration) return;
setInterval(() => { setInterval(() => {
registration.update(); registration.update();
}, 60_000); }, 60_000);
@@ -15,12 +12,11 @@ export function SwAutoUpdate() {
}); });
useEffect(() => { useEffect(() => {
if (inElectron) return;
if (!navigator.serviceWorker) return; if (!navigator.serviceWorker) return;
const onControllerChange = () => window.location.reload(); const onControllerChange = () => window.location.reload();
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
}, [inElectron]); }, []);
return null; return null;
} }
@@ -0,0 +1,277 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { create } from 'zustand';
import { getElectronAPI } from '../../platform/platform';
// ---------------------------------------------------------------------------
// Zustand micro-store — bridges the event-driven API to React state
// ---------------------------------------------------------------------------
interface ScreenPickerState {
isOpen: boolean;
sources: ElectronScreenSource[];
}
const useScreenPickerStore = create<ScreenPickerState>(() => ({
isOpen: false,
sources: [],
}));
// ---------------------------------------------------------------------------
// Close helper — sends selection back to main process
// ---------------------------------------------------------------------------
function closePicker(sourceId: string | null) {
const api = getElectronAPI();
if (api) api.selectScreenSource(sourceId);
useScreenPickerStore.setState({ isOpen: false, sources: [] });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
type Tab = 'screens' | 'windows';
export function ScreenSharePicker() {
const { isOpen, sources } = useScreenPickerStore();
const [activeTab, setActiveTab] = useState<Tab>('screens');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
// Register listener for sources from main process (once on mount)
useEffect(() => {
const api = getElectronAPI();
console.log('[Picker] Mounted, registering onScreenShareSources listener, hasAPI:', !!api);
if (!api) return;
api.onScreenShareSources((incomingSources) => {
console.log('[Picker] Received', incomingSources.length, 'sources from main process');
useScreenPickerStore.setState({
isOpen: true,
sources: incomingSources,
});
});
}, []);
// Reset local state when picker opens
useEffect(() => {
if (isOpen) {
setActiveTab('screens');
setSelectedId(null);
setSearch('');
}
}, [isOpen]);
// Auto-select if there's exactly one screen
useEffect(() => {
if (isOpen && sources.length > 0 && !selectedId) {
const screens = sources.filter((s) => s.isScreen);
if (screens.length === 1 && activeTab === 'screens') {
setSelectedId(screens[0]!.id);
}
}
}, [isOpen, sources, selectedId, activeTab]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
closePicker(null);
}
}, []);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
const screens = useMemo(() => sources.filter((s) => s.isScreen), [sources]);
const windows = useMemo(() => {
const wins = sources.filter((s) => !s.isScreen);
if (!search.trim()) return wins;
const q = search.trim().toLowerCase();
return wins.filter((w) => w.name.toLowerCase().includes(q));
}, [sources, search]);
const isMac = getElectronAPI()?.platform === 'darwin';
if (!isOpen) return null;
const activeSources = activeTab === 'screens' ? screens : windows;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={() => closePicker(null)}
/>
{/* Modal card */}
<div className="relative w-full max-w-3xl mx-4 glass-modal rounded-lg animate-slide-up flex flex-col max-h-[calc(100vh-4rem)]">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0">
<h2 className="text-lg font-bold text-txt-primary">Share Your Screen</h2>
<button
onClick={() => closePicker(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-5 pb-3 flex-shrink-0">
<TabButton
active={activeTab === 'screens'}
onClick={() => { setActiveTab('screens'); setSelectedId(null); }}
label="Screens"
count={screens.length}
/>
<TabButton
active={activeTab === 'windows'}
onClick={() => { setActiveTab('windows'); setSelectedId(null); }}
label="Windows"
count={windows.length}
/>
</div>
{/* Search (windows tab only) */}
{activeTab === 'windows' && (
<div className="px-5 pb-3 flex-shrink-0">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search windows..."
className="input-search w-full"
autoFocus
/>
</div>
)}
{/* Source grid */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-5 py-2">
{activeSources.length === 0 ? (
<div className="text-center py-12 text-txt-tertiary text-sm">
{activeTab === 'windows' && search.trim()
? 'No windows match your search'
: `No ${activeTab} available`}
</div>
) : (
<div className={`grid gap-3 ${activeTab === 'screens' ? 'grid-cols-2' : 'grid-cols-3'}`}>
{activeSources.map((source) => (
<SourceCard
key={source.id}
source={source}
selected={selectedId === source.id}
onClick={() => setSelectedId(source.id)}
onDoubleClick={() => closePicker(source.id)}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex-shrink-0 flex flex-col items-center px-5 pt-2 pb-4">
{isMac ? (
<div className="text-[11px] text-txt-tertiary mb-2">System audio is not available on macOS</div>
) : (
<div className="text-[11px] text-txt-tertiary mb-2">System audio will be shared</div>
)}
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3">
<button
onClick={() => closePicker(null)}
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
</button>
<button
onClick={() => closePicker(selectedId)}
disabled={!selectedId}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Share
</button>
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TabButton({ active, onClick, label, count }: {
active: boolean;
onClick: () => void;
label: string;
count: number;
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
active
? 'bg-accent-primary text-white'
: 'bg-white/[0.06] text-txt-secondary hover:text-txt-primary hover:bg-white/[0.1]'
}`}
>
{label}
{count > 0 && (
<span className={`ml-1.5 text-xs ${active ? 'text-white/70' : 'text-txt-tertiary'}`}>
{count}
</span>
)}
</button>
);
}
function SourceCard({ source, selected, onClick, onDoubleClick }: {
source: ElectronScreenSource;
selected: boolean;
onClick: () => void;
onDoubleClick: () => void;
}) {
return (
<button
onClick={onClick}
onDoubleClick={onDoubleClick}
className={`group flex flex-col rounded-lg overflow-hidden transition-all text-left border-2 ${
selected
? 'border-accent-primary bg-accent-primary/10'
: 'border-white/[0.06] hover:border-border-soft bg-surface-base hover:bg-white/[0.04] hover:brightness-110'
}`}
>
{/* Thumbnail */}
<div className="relative aspect-video bg-black/40 overflow-hidden">
<img
src={source.thumbnailDataUrl}
alt={source.name}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
{/* Label */}
<div className="flex items-center gap-1.5 px-2.5 py-2 min-w-0">
{source.appIconDataUrl && (
<img
src={source.appIconDataUrl}
alt=""
className="w-4 h-4 flex-shrink-0"
draggable={false}
/>
)}
<span className={`text-xs truncate ${selected ? 'text-txt-primary' : 'text-txt-secondary'}`}>
{source.name}
</span>
</div>
</button>
);
}
@@ -59,6 +59,7 @@ export function VoiceControls() {
const handleScreenShare = async () => { const handleScreenShare = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing);
if (!room) return; if (!room) return;
try { try {
if (!isScreenSharing) { if (!isScreenSharing) {
+17
View File
@@ -1,5 +1,13 @@
/** Type augmentation for the Electron IPC bridge exposed by preload.ts */ /** Type augmentation for the Electron IPC bridge exposed by preload.ts */
interface ElectronScreenSource {
id: string; // "screen:0:0" or "window:12345:0"
name: string; // "Entire Screen" or "Firefox"
thumbnailDataUrl: string; // PNG data URL at 320×180
appIconDataUrl: string | null; // App icon (windows only)
isScreen: boolean; // true = display, false = window
}
interface BackspaceElectronAPI { interface BackspaceElectronAPI {
// Platform info // Platform info
platform: NodeJS.Platform; platform: NodeJS.Platform;
@@ -25,6 +33,15 @@ interface BackspaceElectronAPI {
// Deep linking (Task 2.3) // Deep linking (Task 2.3)
onDeepLink: (callback: (url: string) => void) => void; onDeepLink: (callback: (url: string) => void) => void;
// Screen share picker coordination
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
selectScreenSource: (sourceId: string | null) => void;
// Instance URL management
getInstanceUrl: () => Promise<string | null>;
setInstanceUrl: (url: string) => Promise<void>;
clearInstanceUrl: () => Promise<void>;
} }
interface Window { interface Window {
+4
View File
@@ -2,6 +2,10 @@ export function isElectron(): boolean {
return typeof window !== 'undefined' && typeof window.backspace !== 'undefined'; return typeof window !== 'undefined' && typeof window.backspace !== 'undefined';
} }
export function isElectronMac(): boolean {
return isElectron() && window.backspace?.platform === 'darwin';
}
export function getElectronAPI(): BackspaceElectronAPI | null { export function getElectronAPI(): BackspaceElectronAPI | null {
return window.backspace ?? null; return window.backspace ?? null;
} }
+4
View File
@@ -455,3 +455,7 @@
.animate-call-button-glow { animation: none !important; } .animate-call-button-glow { animation: none !important; }
.call-refraction::after { animation: none !important; opacity: 0; } .call-refraction::after { animation: none !important; opacity: 0; }
} }
/* ── Electron window drag regions ── */
.titlebar-drag { -webkit-app-region: drag; }
.titlebar-no-drag { -webkit-app-region: no-drag; }
+38 -30
View File
@@ -124,10 +124,13 @@ export async function applyOverdrive(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Start screen sharing // Start screen sharing — single path via setScreenShareEnabled()
// In Electron, getDisplayMedia() is intercepted by setDisplayMediaRequestHandler
// in the main process, which shows the custom picker automatically.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function startScreenShare(room: Room): Promise<boolean> { export async function startScreenShare(room: Room): Promise<boolean> {
console.log('[SS] startScreenShare called, room state:', room.state);
const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig); const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
try { try {
@@ -147,6 +150,7 @@ export async function startScreenShare(room: Room): Promise<boolean> {
simulcast: opts.publish.simulcast, simulcast: opts.publish.simulcast,
} as any); } as any);
console.log('[SS] setScreenShareEnabled returned:', !!track);
if (!track) { if (!track) {
return false; return false;
} }
@@ -159,34 +163,7 @@ export async function startScreenShare(room: Room): Promise<boolean> {
} }
useVoiceStore.setState({ isScreenSharing: true }); useVoiceStore.setState({ isScreenSharing: true });
applyScreenShareOverdrive(room);
// Overdrive at 2s — after WebRTC finishes negotiation
setTimeout(async () => {
if (!useVoiceStore.getState().isScreenSharing) return;
// Rebuild from fresh store state — no stale closures
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
width: { ideal: freshOpts.capture.width },
height: { ideal: freshOpts.capture.height },
frameRate: { ideal: freshOpts.capture.frameRate, min: 15 },
});
// Re-assert contentHint (LiveKit may strip it during renegotiation)
screenPub.track.mediaStreamTrack.contentHint = freshOpts.contentHint;
}
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 2000);
// Second overdrive at 5s — safety net for slow BWE convergence
setTimeout(async () => {
if (!useVoiceStore.getState().isScreenSharing) return;
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 5000);
return true; return true;
} catch (err) { } catch (err) {
console.error('[ScreenShare] Failed to start screen share:', err); console.error('[ScreenShare] Failed to start screen share:', err);
@@ -194,6 +171,37 @@ export async function startScreenShare(room: Room): Promise<boolean> {
} }
} }
// ---------------------------------------------------------------------------
// Shared overdrive scheduling
// ---------------------------------------------------------------------------
function applyScreenShareOverdrive(room: Room): void {
// Overdrive at 2s — after WebRTC finishes negotiation
setTimeout(async () => {
if (!useVoiceStore.getState().isScreenSharing) return;
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
width: { ideal: freshOpts.capture.width },
height: { ideal: freshOpts.capture.height },
frameRate: { ideal: freshOpts.capture.frameRate, min: 15 },
});
screenPub.track.mediaStreamTrack.contentHint = freshOpts.contentHint;
}
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 2000);
// Second overdrive at 5s — safety net for slow BWE convergence
setTimeout(async () => {
if (!useVoiceStore.getState().isScreenSharing) return;
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 5000);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stop screen sharing // Stop screen sharing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -212,7 +220,7 @@ export async function stopScreenShare(room: Room): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function changeScreenShare(room: Room): Promise<void> { export async function changeScreenShare(room: Room): Promise<void> {
await room.localParticipant.setScreenShareEnabled(false); await stopScreenShare(room);
setTimeout(async () => { setTimeout(async () => {
await startScreenShare(room); await startScreenShare(room);
}, 200); }, 200);