(feat): update grid view stuff

(feat): add desktop app screensharing
This commit is contained in:
Alois 2026-04-30 13:26:05 +02:00
commit 7b5cdca0ff
11 changed files with 1469 additions and 87 deletions

View file

@ -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:*",

View file

@ -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}
/>
)}
</>
);
}

View 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>
);
}

View file

@ -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,

View file

@ -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>