diff --git a/icon.png b/icon.png new file mode 100644 index 00000000..8b6aaccc Binary files /dev/null and b/icon.png differ diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7bc26517..2435bb25 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -4,6 +4,7 @@ directories: output: dist-electron files: - dist/**/* + - resources/**/* - "!node_modules" publish: - provider: generic diff --git a/packages/desktop/resources/instance-picker.html b/packages/desktop/resources/instance-picker.html new file mode 100644 index 00000000..73ec2a80 --- /dev/null +++ b/packages/desktop/resources/instance-picker.html @@ -0,0 +1,376 @@ + + + + + + Backspace — Connect + + + +
+ +
+
+
+
+

Welcome to Backspace

+

Connect to your instance

+
+ +
+
+ + +
+ +
+ + + + + + +
+
+
+ + + + diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index f52d509c..0fa2f200 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,6 +8,8 @@ import { ipcMain, shell, screen, + session, + desktopCapturer, } from 'electron'; import path from 'path'; import fs from 'fs'; @@ -17,9 +19,36 @@ let tray: Tray | null = null; let isQuitting = false; let pendingDeepLink: string | null = null; -const DEV_URL = 'http://localhost:5173'; -const PROD_URL = 'http://localhost:3000'; -const SERVER_URL = process.env.BACKSPACE_URL || (app.isPackaged ? PROD_URL : DEV_URL); +// ─── Instance URL Persistence ──────────────────────────────────────────────── + +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 ─────────────────────────────────────────────── @@ -171,7 +200,21 @@ function createWindow(): void { 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?.show(); @@ -248,6 +291,15 @@ function createTray(): void { mainWindow?.hide(); }, }, + { + label: 'Change Instance', + click: () => { + clearInstanceUrl(); + mainWindow?.loadFile(getPickerPath()); + mainWindow?.show(); + mainWindow?.focus(); + }, + }, { type: 'separator' }, { label: 'Quit', @@ -319,6 +371,39 @@ function registerIpcHandlers(): void { 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 ipcMain.on('install-update', () => { try { @@ -337,6 +422,12 @@ function registerIpcHandlers(): void { // 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 ──────────────────────────────────────────────────────────── @@ -420,7 +511,125 @@ if (!gotTheLock) { // ─── 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((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(); createWindow(); createTray(); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 5c21a8d9..21ee5c29 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -49,4 +49,17 @@ contextBridge.exposeInMainWorld('backspace', { onDeepLink: (callback: (url: string) => void) => { 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'), }); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 812a8860..06808c8e 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,7 +5,9 @@ import { RegisterPage } from './components/auth/RegisterPage'; import { AppLayout } from './components/layout/AppLayout'; import { JoinPage } from './components/JoinPage'; import { SwAutoUpdate } from './components/ui/SwUpdatePrompt'; +import { ScreenSharePicker } from './components/voice/ScreenSharePicker'; import { useAuthStore } from './stores/authStore'; +import { isElectronMac } from './platform/platform'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -27,50 +29,57 @@ function AuthRedirect({ children }: { children: React.ReactNode }) { } export function App() { - return ( - <> - - - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - } /> - } /> - - + const showTitleBar = isElectronMac(); + return ( +
+ {showTitleBar && ( +
+ )} +
+ + + + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + } /> + } /> + +
+
); } diff --git a/packages/web/src/components/JoinPage.tsx b/packages/web/src/components/JoinPage.tsx index 72a42ef4..d8ad1b34 100644 --- a/packages/web/src/components/JoinPage.tsx +++ b/packages/web/src/components/JoinPage.tsx @@ -197,7 +197,7 @@ export function JoinPage() { // Loading state if (isLoadingPreview) { return ( -
+
@@ -213,7 +213,7 @@ export function JoinPage() { // Error state — invalid/expired invite if (previewError || !preview) { return ( -
+
@@ -247,7 +247,7 @@ export function JoinPage() { // Main invite page return ( -
+
{/* Space preview */} diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx index e594df30..86ea5b55 100644 --- a/packages/web/src/components/auth/LoginPage.tsx +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -61,7 +61,7 @@ export function LoginPage() { const isDisabled = isLoading || retryAfter > 0; return ( -
+
diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 8a2146f1..9b0fc9ce 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -236,7 +236,7 @@ export function RegisterPage() { const isDisabled = isRegistering || retryAfter > 0; return ( -
+
{/* Progress dots */} diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index d45397c6..fc20f069 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -255,7 +255,7 @@ export function AppLayout() { if (isLoading || !user) { return ( -
+
@@ -268,7 +268,7 @@ export function AppLayout() { } return ( -
+
{/* Space sidebar - always visible on desktop, toggled on mobile */}
diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index b1af63e6..23db3fd2 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -13,6 +13,7 @@ import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; +import { isElectronMac } from '../../platform/platform'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; // ─── Resolved layout types ───────────────────────────────────────────────── @@ -1247,7 +1248,7 @@ export function SpaceSidebar() { }, [openFolderId, resolvedLayout]); return ( -
-
- Local +
+ Local + {isElectron() && ( + + )}
diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index a3c96ba8..fe3c446d 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -9,6 +9,7 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { FederationOpResult } from '../../../utils/federationOps'; +import { isElectron } from '../../../platform/platform'; export function AccountPanel() { @@ -687,6 +688,27 @@ export function AccountPanel() {
+ {/* ── Connected Instance (Electron only) ── */} + {isElectron() && ( +
+
Connected Instance
+
+
+
+
{window.location.origin}
+
Currently connected instance
+
+ +
+
+
+ )} + {/* ── Danger Zone ── */}
Danger Zone
diff --git a/packages/web/src/components/ui/SwUpdatePrompt.tsx b/packages/web/src/components/ui/SwUpdatePrompt.tsx index bd4ebd30..ce72576f 100644 --- a/packages/web/src/components/ui/SwUpdatePrompt.tsx +++ b/packages/web/src/components/ui/SwUpdatePrompt.tsx @@ -1,13 +1,10 @@ import { useRegisterSW } from 'virtual:pwa-register/react'; import { useEffect } from 'react'; -import { isElectron } from '../../platform/platform'; export function SwAutoUpdate() { - const inElectron = isElectron(); - useRegisterSW({ onRegisteredSW(_swUrl, registration) { - if (!registration || inElectron) return; + if (!registration) return; setInterval(() => { registration.update(); }, 60_000); @@ -15,12 +12,11 @@ export function SwAutoUpdate() { }); useEffect(() => { - if (inElectron) return; if (!navigator.serviceWorker) return; const onControllerChange = () => window.location.reload(); navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); - }, [inElectron]); + }, []); return null; } diff --git a/packages/web/src/components/voice/ScreenSharePicker.tsx b/packages/web/src/components/voice/ScreenSharePicker.tsx new file mode 100644 index 00000000..3ba9d9b4 --- /dev/null +++ b/packages/web/src/components/voice/ScreenSharePicker.tsx @@ -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(() => ({ + 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('screens'); + const [selectedId, setSelectedId] = useState(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 ( +
+ {/* Backdrop */} +
closePicker(null)} + /> + + {/* Modal card */} +
+ {/* Header */} +
+

Share Your Screen

+ +
+ + {/* Tabs */} +
+ { setActiveTab('screens'); setSelectedId(null); }} + label="Screens" + count={screens.length} + /> + { setActiveTab('windows'); setSelectedId(null); }} + label="Windows" + count={windows.length} + /> +
+ + {/* Search (windows tab only) */} + {activeTab === 'windows' && ( +
+ setSearch(e.target.value)} + placeholder="Search windows..." + className="input-search w-full" + autoFocus + /> +
+ )} + + {/* Source grid */} +
+ {activeSources.length === 0 ? ( +
+ {activeTab === 'windows' && search.trim() + ? 'No windows match your search' + : `No ${activeTab} available`} +
+ ) : ( +
+ {activeSources.map((source) => ( + setSelectedId(source.id)} + onDoubleClick={() => closePicker(source.id)} + /> + ))} +
+ )} +
+ + {/* Footer */} +
+ {isMac ? ( +
System audio is not available on macOS
+ ) : ( +
System audio will be shared
+ )} +
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function TabButton({ active, onClick, label, count }: { + active: boolean; + onClick: () => void; + label: string; + count: number; +}) { + return ( + + ); +} + +function SourceCard({ source, selected, onClick, onDoubleClick }: { + source: ElectronScreenSource; + selected: boolean; + onClick: () => void; + onDoubleClick: () => void; +}) { + return ( + + ); +} diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 046fc922..facdfca2 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -59,6 +59,7 @@ export function VoiceControls() { const handleScreenShare = async () => { const room = getActiveRoom(); + console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing); if (!room) return; try { if (!isScreenSharing) { diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index 69af55b5..d49e08f0 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -1,5 +1,13 @@ /** 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 { // Platform info platform: NodeJS.Platform; @@ -25,6 +33,15 @@ interface BackspaceElectronAPI { // Deep linking (Task 2.3) 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; + setInstanceUrl: (url: string) => Promise; + clearInstanceUrl: () => Promise; } interface Window { diff --git a/packages/web/src/platform/platform.ts b/packages/web/src/platform/platform.ts index 3739b4ea..d49a7887 100644 --- a/packages/web/src/platform/platform.ts +++ b/packages/web/src/platform/platform.ts @@ -2,6 +2,10 @@ export function isElectron(): boolean { return typeof window !== 'undefined' && typeof window.backspace !== 'undefined'; } +export function isElectronMac(): boolean { + return isElectron() && window.backspace?.platform === 'darwin'; +} + export function getElectronAPI(): BackspaceElectronAPI | null { return window.backspace ?? null; } diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index f5758d52..437bfd40 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -455,3 +455,7 @@ .animate-call-button-glow { animation: none !important; } .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; } diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index 07d3c79c..95b43ac2 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -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 { + console.log('[SS] startScreenShare called, room state:', room.state); const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig); try { @@ -147,6 +150,7 @@ export async function startScreenShare(room: Room): Promise { simulcast: opts.publish.simulcast, } as any); + console.log('[SS] setScreenShareEnabled returned:', !!track); if (!track) { return false; } @@ -159,34 +163,7 @@ export async function startScreenShare(room: Room): Promise { } useVoiceStore.setState({ isScreenSharing: true }); - - // 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); - + applyScreenShareOverdrive(room); return true; } catch (err) { console.error('[ScreenShare] Failed to start screen share:', err); @@ -194,6 +171,37 @@ export async function startScreenShare(room: Room): Promise { } } +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- @@ -212,7 +220,7 @@ export async function stopScreenShare(room: Room): Promise { // --------------------------------------------------------------------------- export async function changeScreenShare(room: Room): Promise { - await room.localParticipant.setScreenShareEnabled(false); + await stopScreenShare(room); setTimeout(async () => { await startScreenShare(room); }, 200);