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:
+53
-44
@@ -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 (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
const showTitleBar = isElectronMac();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export function JoinPage() {
|
||||
// Loading state
|
||||
if (isLoadingPreview) {
|
||||
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="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">
|
||||
@@ -213,7 +213,7 @@ export function JoinPage() {
|
||||
// Error state — invalid/expired invite
|
||||
if (previewError || !preview) {
|
||||
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="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">
|
||||
@@ -247,7 +247,7 @@ export function JoinPage() {
|
||||
|
||||
// Main invite page
|
||||
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="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||
{/* Space preview */}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function LoginPage() {
|
||||
const isDisabled = isLoading || retryAfter > 0;
|
||||
|
||||
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="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||
<div className="text-center mb-6">
|
||||
|
||||
@@ -236,7 +236,7 @@ export function RegisterPage() {
|
||||
const isDisabled = isRegistering || retryAfter > 0;
|
||||
|
||||
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="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden">
|
||||
{/* Progress dots */}
|
||||
|
||||
@@ -255,7 +255,7 @@ export function AppLayout() {
|
||||
|
||||
if (isLoading || !user) {
|
||||
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">
|
||||
<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" />
|
||||
@@ -268,7 +268,7 @@ export function AppLayout() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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 />
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
id="@me"
|
||||
name="Direct Messages"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
|
||||
// ─── Status indicator ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -411,8 +412,16 @@ export function ConnectedInstances() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary shrink-0 ml-2">
|
||||
Local
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</form>
|
||||
</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 ── */}
|
||||
<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 { 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;
|
||||
}
|
||||
|
||||
@@ -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 room = getActiveRoom();
|
||||
console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing);
|
||||
if (!room) return;
|
||||
try {
|
||||
if (!isScreenSharing) {
|
||||
|
||||
+17
@@ -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<string | null>;
|
||||
setInstanceUrl: (url: string) => Promise<void>;
|
||||
clearInstanceUrl: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
|
||||
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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,7 +220,7 @@ export async function stopScreenShare(room: Room): Promise<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function changeScreenShare(room: Room): Promise<void> {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
await stopScreenShare(room);
|
||||
setTimeout(async () => {
|
||||
await startScreenShare(room);
|
||||
}, 200);
|
||||
|
||||
Reference in New Issue
Block a user