(feat): update grid view stuff
(feat): add desktop app screensharing
This commit is contained in:
parent
8e294d230e
commit
7b5cdca0ff
11 changed files with 1469 additions and 87 deletions
|
|
@ -19,10 +19,12 @@
|
|||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
|
||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { useDesktopMedia } from "@tensamin/tauri/context";
|
||||
import { setScreenShareEnabled, useCall } from "../../store";
|
||||
import ScreenShareDialog from "./screenshareDialog";
|
||||
|
||||
export default function ScreenshareButton({
|
||||
className,
|
||||
|
|
@ -10,43 +14,98 @@ export default function ScreenshareButton({
|
|||
iconSize?: number;
|
||||
}) {
|
||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { isDesktopTauri } = useDesktopMedia();
|
||||
|
||||
async function startWebShare() {
|
||||
try {
|
||||
await setScreenShareEnabled(true, {
|
||||
audio: true,
|
||||
systemAudio: "include",
|
||||
surfaceSwitching: "include",
|
||||
video: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start web screen share", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start screen sharing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopShare() {
|
||||
try {
|
||||
await setScreenShareEnabled(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop screen share", error);
|
||||
toast("error", "Failed to stop screen sharing.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<>
|
||||
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
||||
className={className}
|
||||
>
|
||||
{isScreensharing ? (
|
||||
<MonitorDot
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenShare
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="flex w-52 flex-col gap-2">
|
||||
<Button
|
||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
||||
className={className}
|
||||
disabled={isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
|
||||
if (isDesktopTauri) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void startWebShare();
|
||||
}}
|
||||
>
|
||||
{isScreensharing ? (
|
||||
<MonitorDot
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenShare
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
)}
|
||||
Start sharing
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-auto flex flex-col gap-2">
|
||||
<Button
|
||||
disabled={isScreensharing}
|
||||
onClick={() => void setScreenShareEnabled(true)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isScreensharing}
|
||||
onClick={() => void setScreenShareEnabled(false)}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button>idk</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void stopShare();
|
||||
}}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
<Button disabled variant="outline">
|
||||
Stream quality soon
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isDesktopTauri && (
|
||||
<ScreenShareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
isScreensharing={isScreensharing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
325
packages/call/src/components/buttons/screenshareDialog.tsx
Normal file
325
packages/call/src/components/buttons/screenshareDialog.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from "@tensamin/ui";
|
||||
import {
|
||||
type DesktopScreenShareAudioOutput,
|
||||
type DesktopScreenShareCapabilities,
|
||||
type DesktopScreenShareSource,
|
||||
useDesktopMedia,
|
||||
} from "@tensamin/tauri/context";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { AppWindow, Loader2, MonitorUp } from "lucide-react";
|
||||
import type { ScreenShareCaptureOptions } from "livekit-client";
|
||||
import {
|
||||
setScreenShareEnabled,
|
||||
startLinuxDesktopScreenShare,
|
||||
} from "../../store";
|
||||
|
||||
const NONE_AUDIO_OUTPUT = "__none__";
|
||||
|
||||
function buildScreenShareOptions(
|
||||
source: DesktopScreenShareSource,
|
||||
capabilities: DesktopScreenShareCapabilities,
|
||||
selectedAudioOutputId: string,
|
||||
shareAudio: boolean,
|
||||
): ScreenShareCaptureOptions {
|
||||
const wantsAudio = capabilities.usesPipeWireAudioPicker
|
||||
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
|
||||
: capabilities.supportsReliableSystemAudio && shareAudio;
|
||||
|
||||
return {
|
||||
audio: wantsAudio
|
||||
? {
|
||||
autoGainControl: false,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
}
|
||||
: false,
|
||||
video: {
|
||||
displaySurface: source.kind === "window" ? "window" : "monitor",
|
||||
},
|
||||
systemAudio: wantsAudio ? "include" : "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
selfBrowserSurface: "exclude",
|
||||
contentHint: "detail",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ScreenShareDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isScreensharing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isScreensharing: boolean;
|
||||
}) {
|
||||
const {
|
||||
getScreenShareCapabilities,
|
||||
listScreenShareAudioOutputs,
|
||||
listScreenShareSources,
|
||||
} = useDesktopMedia();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sources, setSources] = useState<DesktopScreenShareSource[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<DesktopScreenShareAudioOutput[]>(
|
||||
[],
|
||||
);
|
||||
const [capabilities, setCapabilities] =
|
||||
useState<DesktopScreenShareCapabilities | null>(null);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
const [selectedAudioOutputId, setSelectedAudioOutputId] =
|
||||
useState<string>(NONE_AUDIO_OUTPUT);
|
||||
const [shareAudio, setShareAudio] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
setLoading(true);
|
||||
setSelectedSourceId(null);
|
||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
||||
setShareAudio(false);
|
||||
|
||||
Promise.all([listScreenShareSources(), getScreenShareCapabilities()])
|
||||
.then(async ([nextSources, nextCapabilities]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSources(nextSources);
|
||||
setCapabilities(nextCapabilities);
|
||||
|
||||
if (nextCapabilities.usesPipeWireAudioPicker) {
|
||||
const nextOutputs = await listScreenShareAudioOutputs();
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioOutputs(nextOutputs);
|
||||
} else {
|
||||
setAudioOutputs([]);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load desktop share sources", error);
|
||||
toast("error", "Failed to load screen share sources.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
getScreenShareCapabilities,
|
||||
listScreenShareAudioOutputs,
|
||||
listScreenShareSources,
|
||||
open,
|
||||
]);
|
||||
|
||||
const selectedSource =
|
||||
sources.find((source) => source.id === selectedSourceId) ?? null;
|
||||
|
||||
async function startSharing() {
|
||||
if (!selectedSource || !capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (capabilities.platform === "linux") {
|
||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
||||
} else {
|
||||
await setScreenShareEnabled(
|
||||
true,
|
||||
buildScreenShareOptions(
|
||||
selectedSource,
|
||||
capabilities,
|
||||
selectedAudioOutputId,
|
||||
shareAudio,
|
||||
),
|
||||
);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to start screen share", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start screen sharing.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSharing() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await setScreenShareEnabled(false);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop screen share", error);
|
||||
toast("error", "Failed to stop screen sharing.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="p-4 pb-3">
|
||||
<DialogTitle>Share your screen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the window or display you want to share with the call.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{sources.map((source) => {
|
||||
const selected = source.id === selectedSourceId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSourceId(source.id)}
|
||||
className={[
|
||||
"flex min-h-28 flex-col justify-between rounded-xl border p-4 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:bg-muted/50",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{source.kind === "window" ? (
|
||||
<AppWindow className="size-4" />
|
||||
) : (
|
||||
<MonitorUp className="size-4" />
|
||||
)}
|
||||
{source.name}
|
||||
</div>
|
||||
{source.subtitle && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{source.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!loading && sources.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No windows or displays are currently available for capture.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{capabilities?.usesPipeWireAudioPicker ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Share audio output</Label>
|
||||
<Select
|
||||
value={selectedAudioOutputId}
|
||||
onValueChange={(value) =>
|
||||
setSelectedAudioOutputId(value ?? NONE_AUDIO_OUTPUT)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
|
||||
{audioOutputs.map((output) => (
|
||||
<SelectItem key={output.id} value={output.id}>
|
||||
{output.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a PipeWire output to try sharing system audio with your
|
||||
screen.
|
||||
</p>
|
||||
</div>
|
||||
) : capabilities?.supportsSystemAudioSwitch ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share system audio alongside your screen when the runtime can
|
||||
provide it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-system-audio"
|
||||
checked={shareAudio}
|
||||
disabled={!capabilities.supportsReliableSystemAudio}
|
||||
onCheckedChange={setShareAudio}
|
||||
/>
|
||||
</div>
|
||||
{!capabilities.supportsReliableSystemAudio && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
System audio sharing is not available on this platform/runtime
|
||||
yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading available share targets...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isScreensharing && (
|
||||
<Button variant="destructive" disabled={loading} onClick={stopSharing}>
|
||||
Stop sharing
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={loading || selectedSource == null}
|
||||
onClick={startSharing}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Start sharing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
|
|
@ -11,9 +12,11 @@ import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
|
|||
import {
|
||||
ExternalE2EEKeyProvider,
|
||||
LocalAudioTrack,
|
||||
type LocalTrack,
|
||||
Room,
|
||||
RoomEvent,
|
||||
type RemoteTrack,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
setLogExtension,
|
||||
getLogger,
|
||||
|
|
@ -62,6 +65,11 @@ type Runtime = {
|
|||
getUser: GetUserFn;
|
||||
};
|
||||
|
||||
type ScreenShareSession = {
|
||||
tracks: Array<LocalTrack | MediaStreamTrack>;
|
||||
cleanup?: () => void;
|
||||
};
|
||||
|
||||
type CallStore = {
|
||||
state: CallState;
|
||||
view: CallView;
|
||||
|
|
@ -73,6 +81,7 @@ type CallStore = {
|
|||
deaf: boolean;
|
||||
micEnabled: boolean;
|
||||
screenShareEnabled: boolean;
|
||||
screenShareSession: ScreenShareSession | null;
|
||||
isEncrypted: boolean;
|
||||
room: Room;
|
||||
keyProvider: ExternalE2EEKeyProvider;
|
||||
|
|
@ -136,11 +145,12 @@ function requireRuntime(runtime: Runtime | null): Runtime {
|
|||
}
|
||||
|
||||
export function syncParticipantState() {
|
||||
const { room } = useCall.getState();
|
||||
const { room, screenShareSession } = useCall.getState();
|
||||
|
||||
useCall.setState({
|
||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
||||
screenShareEnabled:
|
||||
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
||||
isEncrypted:
|
||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
||||
});
|
||||
|
|
@ -225,6 +235,8 @@ export async function connect(callId: string) {
|
|||
}
|
||||
|
||||
export function disconnect() {
|
||||
void clearPublishedScreenShare();
|
||||
|
||||
useCall.setState({
|
||||
state: "closing",
|
||||
invitedUserId: null,
|
||||
|
|
@ -234,6 +246,7 @@ export function disconnect() {
|
|||
currentCallData: null,
|
||||
deaf: false,
|
||||
view: "preview",
|
||||
screenShareSession: null,
|
||||
});
|
||||
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
|
|
@ -325,11 +338,163 @@ export async function toggleMute() {
|
|||
syncParticipantState();
|
||||
}
|
||||
|
||||
export async function setScreenShareEnabled(enabled: boolean) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
async function clearPublishedScreenShare() {
|
||||
const screenShareSession = useCall.getState().screenShareSession;
|
||||
|
||||
if (!screenShareSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
screenShareSession.tracks.map((track) =>
|
||||
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
||||
log(1, "call", "red", "Failed to unpublish screen share track", error);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
screenShareSession.cleanup?.();
|
||||
|
||||
useCall.setState({ screenShareSession: null });
|
||||
}
|
||||
|
||||
async function publishScreenShareTracks(
|
||||
tracks: Array<LocalTrack | MediaStreamTrack>,
|
||||
cleanup?: () => void,
|
||||
) {
|
||||
if (tracks.length === 0) {
|
||||
throw new Error("No screen share tracks were created.");
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
tracks.map((track) =>
|
||||
room.localParticipant.publishTrack(track, {
|
||||
source:
|
||||
track.kind === Track.Kind.Video
|
||||
? Track.Source.ScreenShare
|
||||
: Track.Source.ScreenShareAudio,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
const mediaStreamTrack =
|
||||
track instanceof MediaStreamTrack ? track : track.mediaStreamTrack;
|
||||
|
||||
mediaStreamTrack.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
void stopScreenShare();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
useCall.setState({ screenShareSession: { tracks, cleanup } });
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
||||
await clearPublishedScreenShare();
|
||||
|
||||
const tracks = await room.localParticipant.createScreenTracks(options);
|
||||
|
||||
await publishScreenShareTracks(
|
||||
tracks,
|
||||
() => tracks.forEach((track) => track.stop()),
|
||||
);
|
||||
}
|
||||
|
||||
export async function startLinuxDesktopScreenShare(sourceId: string) {
|
||||
await clearPublishedScreenShare();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1280;
|
||||
canvas.height = 720;
|
||||
canvas.style.display = "none";
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
canvas.remove();
|
||||
throw new Error("Failed to initialize the screen share canvas.");
|
||||
}
|
||||
|
||||
const stream = canvas.captureStream(8);
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
|
||||
if (!videoTrack) {
|
||||
canvas.remove();
|
||||
throw new Error("Failed to create a video track for screen sharing.");
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
let stopped = false;
|
||||
let frameRequestInFlight = false;
|
||||
|
||||
const renderFrame = async () => {
|
||||
if (stopped || frameRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
frameRequestInFlight = true;
|
||||
|
||||
try {
|
||||
const dataUrl = await invoke<string>("capture_screen_share_frame", {
|
||||
sourceId,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("Failed to decode screen share frame."));
|
||||
image.src = dataUrl;
|
||||
});
|
||||
|
||||
if (canvas.width !== image.naturalWidth || canvas.height !== image.naturalHeight) {
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
} finally {
|
||||
frameRequestInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
await renderFrame();
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void renderFrame().catch((error) => {
|
||||
log(1, "call", "red", "Failed to capture Linux screen share frame", error);
|
||||
});
|
||||
}, 125);
|
||||
|
||||
await publishScreenShareTracks([videoTrack], () => {
|
||||
stopped = true;
|
||||
window.clearInterval(interval);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
canvas.remove();
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopScreenShare() {
|
||||
await clearPublishedScreenShare();
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export async function setScreenShareEnabled(
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
) {
|
||||
if (enabled) {
|
||||
await startScreenShare(options);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopScreenShare();
|
||||
}
|
||||
|
||||
export function resetCallState() {
|
||||
useCall.setState({
|
||||
state: "closed",
|
||||
|
|
@ -340,6 +505,7 @@ export function resetCallState() {
|
|||
livekitToken: null,
|
||||
currentCallData: null,
|
||||
deaf: false,
|
||||
screenShareSession: null,
|
||||
});
|
||||
|
||||
syncParticipantState();
|
||||
|
|
@ -375,6 +541,7 @@ export const useCall = create<CallStore>(() => ({
|
|||
deaf: false,
|
||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
||||
screenShareSession: null,
|
||||
isEncrypted:
|
||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
||||
room,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export default function View() {
|
|||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let nextParticipant = 1;
|
||||
let nextParticipant = 0;
|
||||
|
||||
return layout.rowCounts.map((count, rowIndex) => {
|
||||
const participants = Array.from(
|
||||
|
|
@ -148,21 +148,29 @@ export default function View() {
|
|||
});
|
||||
}, [layout.rowCounts]);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const participants = [
|
||||
...room.remoteParticipants.values(),
|
||||
room.localParticipant,
|
||||
];
|
||||
return participants;
|
||||
}, [room.localParticipant, room.remoteParticipants]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full w-full overflow-hidden p-3">
|
||||
<div ref={containerRef} className="h-[75vh] w-full overflow-hidden p-3">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.rowIndex} className="flex justify-center gap-3">
|
||||
{row.participants.map((participant) => (
|
||||
{row.participants.map((userIndex) => (
|
||||
<div
|
||||
key={participant}
|
||||
key={userIndex}
|
||||
className="flex items-center justify-center rounded-xl bg-red-500"
|
||||
style={{
|
||||
width: layout.tileWidth,
|
||||
height: layout.tileHeight,
|
||||
}}
|
||||
>
|
||||
Test {participant}
|
||||
Test {users[userIndex]?.identity}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue