(feat): improve mobile
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(feat): add camera sharing
(fix): vite tauri connection
(fix): methanium/ui theme not applied to call popout
This commit is contained in:
Alois 2026-08-01 01:25:41 +02:00
commit 62aaa7c01d
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
31 changed files with 1915 additions and 859 deletions

View file

@ -19,7 +19,6 @@
"dependencies": {
"@livekit/components-react": "^2.9.20",
"@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -11,7 +11,7 @@ import {
import { useEffect, useState } from "react";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare";
import MediaShareButton from "./buttons/mediaShare";
import LeaveButton from "./buttons/leave";
import {
setCallIsPopout,
@ -132,10 +132,10 @@ export default function Actions() {
tooltip="Deafen"
portalContainer={portalContainer}
/>
<ScreenshareButton
<MediaShareButton
className={sharedClasses}
iconSize={sharedIconSize}
tooltip="Screenshare"
tooltip="Share media"
/>
<InviteButton
className={sharedClasses}

View file

@ -0,0 +1,171 @@
import { useEffect, useState } from "react";
import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@methanium/ui";
import { MonitorDot, ScreenShare } from "lucide-react";
import { toast } from "@tensamin/shared/log";
import {
startScreenShare,
stopCameraShare,
stopScreenShare,
useCall,
} from "../../store";
import { getMediaShareAdapter, type MediaShareKind } from "../../mediaShare";
import MediaShareDialog from "../mediaShareDialog";
export default function MediaShareButton({
className,
iconSize,
tooltip,
defaultPortal,
}: {
className?: string;
iconSize?: number;
tooltip?: string;
defaultPortal?: boolean;
}) {
const isScreensharing = useCall((state) => state.screenShareEnabled);
const cameraEnabled = useCall((state) => state.cameraEnabled);
const screenRef = useCall((state) => state.screenRef);
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
const [dialogKind, setDialogKind] = useState<MediaShareKind | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!defaultPortal) setPortalContainer(screenRef?.current ?? undefined);
}, [screenRef, defaultPortal]);
async function beginScreenShare() {
try {
const capabilities = await getMediaShareAdapter().getCapabilities();
if (capabilities.screenPicker === "sources") {
setDialogKind("screen");
} else {
await startScreenShare({ includeAudio: true });
}
} catch (error) {
console.error("Failed to start screen sharing", error);
toast(
"error",
error instanceof Error
? error.message
: "Failed to start screen sharing.",
);
}
}
async function stop(kind: MediaShareKind) {
try {
await (kind === "screen" ? stopScreenShare() : stopCameraShare());
} catch (error) {
console.error(`Failed to stop ${kind} sharing`, error);
toast("error", `Failed to stop ${kind} sharing.`);
}
}
const trigger = (
<Button
variant={isScreensharing || cameraEnabled ? "subtleDefault" : "default"}
className="w-full! h-full!"
>
{isScreensharing || cameraEnabled ? (
<MonitorDot
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
) : (
<ScreenShare
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
)}
</Button>
);
return (
<>
<Tooltip>
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
<PopoverTrigger
render={({ onClick }) =>
tooltip ? (
<TooltipTrigger
render={({ ref }) => (
<span
ref={ref as React.Ref<HTMLSpanElement>}
onClick={onClick}
className={className}
>
{trigger}
</span>
)}
/>
) : (
<span className={className} onClick={onClick}>
{trigger}
</span>
)
}
/>
<PopoverContent
className="flex w-48 flex-col gap-2"
portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
>
<Button
disabled={isScreensharing}
onClick={() => {
setMenuOpen(false);
void beginScreenShare();
}}
>
Share screen
</Button>
<Button
disabled={cameraEnabled}
onClick={() => {
setMenuOpen(false);
setDialogKind("camera");
}}
>
Share camera
</Button>
{isScreensharing ? (
<Button variant="destructive" onClick={() => void stop("screen")}>
Stop screen sharing
</Button>
) : null}
{cameraEnabled ? (
<Button variant="destructive" onClick={() => void stop("camera")}>
Stop camera
</Button>
) : null}
</PopoverContent>
</Popover>
{tooltip ? (
<TooltipContent
portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
>
{tooltip}
</TooltipContent>
) : null}
</Tooltip>
{dialogKind ? (
<MediaShareDialog
kind={dialogKind}
open
onOpenChange={(open) => !open && setDialogKind(null)}
portalContainer={portalContainer}
/>
) : null}
</>
);
}

View file

@ -1,175 +0,0 @@
import { useEffect, useState } from "react";
import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@methanium/ui";
import { MonitorDot, ScreenShare } from "lucide-react";
import { toast } from "@tensamin/shared/log";
import { isTauri } from "@tauri-apps/api/core";
import { setScreenShareEnabled, useCall } from "../../store";
import ScreenShareDialog from "../screenshareDialog";
export default function ScreenshareButton({
className,
iconSize,
tooltip,
defaultPortal,
}: {
className?: string;
iconSize?: number;
tooltip?: string;
defaultPortal?: boolean;
}) {
const isScreensharing = useCall((state) => state.screenShareEnabled);
const screenRef = useCall((state) => state.screenRef);
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
const [dialogOpen, setDialogOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (defaultPortal) return;
setPortalContainer(screenRef?.current ?? undefined);
}, [screenRef, defaultPortal]);
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 (
<>
<Tooltip>
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
<PopoverTrigger
render={({ onClick: popoverClick }) =>
tooltip ? (
<TooltipTrigger
render={({ ref, onClick: tooltipClick }) => (
<Button
ref={ref as React.Ref<HTMLButtonElement>}
onClick={(event) => {
popoverClick?.(event);
tooltipClick?.(event);
}}
variant={isScreensharing ? "subtleDefault" : "default"}
className={className}
>
{isScreensharing ? (
<MonitorDot
style={{
scale: (iconSize ? iconSize + 100 : 100) + "%",
}}
/>
) : (
<ScreenShare
style={{
scale: (iconSize ? iconSize + 100 : 100) + "%",
}}
/>
)}
</Button>
)}
/>
) : (
<Button
variant={isScreensharing ? "subtleDefault" : "default"}
className={className}
onClick={popoverClick}
>
{isScreensharing ? (
<MonitorDot
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
) : (
<ScreenShare
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
)}
</Button>
)
}
/>
<PopoverContent
className="flex w-40 flex-col gap-2"
portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
>
<Button
disabled={isScreensharing}
onClick={() => {
setMenuOpen(false);
if (isTauri()) {
setDialogOpen(true);
return;
}
void startWebShare();
}}
>
Start screenshare
</Button>
<Button
variant="destructive"
disabled={!isScreensharing}
onClick={() => {
setMenuOpen(false);
void stopShare();
}}
>
Stop screenshare
</Button>
<Button variant="outline">Change quality</Button>
</PopoverContent>
</Popover>
{tooltip && (
<TooltipContent
portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
>
{tooltip}
</TooltipContent>
)}
</Tooltip>
{isTauri() && (
<ScreenShareDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
isScreensharing={isScreensharing}
portalContainer={portalContainer}
/>
)}
</>
);
}

View file

@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Switch,
} from "@methanium/ui";
import { toast } from "@tensamin/shared/log";
import { Loader2 } from "lucide-react";
import {
getMediaShareAdapter,
type MediaShareKind,
type MediaShareSource,
} from "../mediaShare";
import { startCameraShare, startScreenShare } from "../store";
import MediaSourceCard from "./mediaSourceCard";
export default function MediaShareDialog({
kind,
open,
onOpenChange,
portalContainer,
}: {
kind: MediaShareKind;
open: boolean;
onOpenChange: (open: boolean) => void;
portalContainer?: HTMLElement;
}) {
const [loading, setLoading] = useState(false);
const [sources, setSources] = useState<MediaShareSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [shareAudio, setShareAudio] = useState(true);
const [canShareAudio, setCanShareAudio] = useState(false);
const [cameraPreview, setCameraPreview] = useState<MediaStream | null>(null);
const [cameraPreviewVersion, setCameraPreviewVersion] = useState(0);
const cameraPreviewRef = useRef<MediaStream | null>(null);
function stopCameraPreview() {
cameraPreviewRef.current?.getTracks().forEach((track) => track.stop());
cameraPreviewRef.current = null;
setCameraPreview(null);
}
useEffect(() => {
if (!open) return;
let active = true;
setLoading(true);
setSelectedSourceId(null);
Promise.all([
getMediaShareAdapter().listSources(kind),
getMediaShareAdapter().getCapabilities(),
])
.then(([nextSources, capabilities]) => {
if (!active) return;
const availableSources =
kind === "camera" && nextSources.length === 0
? [
{
id: "__default_camera__",
kind: "camera" as const,
name: "Default camera",
},
]
: nextSources;
setSources(availableSources);
setSelectedSourceId(availableSources[0]?.id ?? null);
setCanShareAudio(kind === "screen" && capabilities.canShareScreenAudio);
})
.catch((error) => {
console.error("Failed to load media share sources", error);
toast("error", "Failed to load media sources.");
})
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [kind, open]);
useEffect(() => {
if (kind !== "camera" || !open || !selectedSourceId) {
stopCameraPreview();
return;
}
let active = true;
stopCameraPreview();
const sourceId =
selectedSourceId === "__default_camera__" ? undefined : selectedSourceId;
void navigator.mediaDevices
.getUserMedia({
audio: false,
video: sourceId ? { deviceId: { exact: sourceId } } : true,
})
.then((stream) => {
if (!active) {
stream.getTracks().forEach((track) => track.stop());
return;
}
cameraPreviewRef.current = stream;
setCameraPreview(stream);
})
.catch((error) => {
console.error("Failed to preview camera", error);
});
return () => {
active = false;
stopCameraPreview();
};
}, [cameraPreviewVersion, kind, open, selectedSourceId]);
async function startSharing() {
setLoading(true);
try {
if (kind === "camera") {
stopCameraPreview();
await startCameraShare(
selectedSourceId === "__default_camera__"
? undefined
: (selectedSourceId ?? undefined),
);
} else {
await startScreenShare({
sourceId: selectedSourceId ?? undefined,
includeAudio: canShareAudio && shareAudio,
});
}
onOpenChange(false);
} catch (error) {
console.error(`Failed to share ${kind}`, error);
if (kind === "camera") {
setCameraPreviewVersion((version) => version + 1);
}
toast(
"error",
error instanceof Error ? error.message : `Failed to share ${kind}.`,
);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[90%] gap-0 overflow-hidden p-0"
portalProps={{ container: portalContainer }}
>
<DialogHeader className="p-4 pb-3">
<DialogTitle>
{kind === "camera" ? "Share a camera" : "Share your screen"}
</DialogTitle>
</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 (
<MediaSourceCard
key={source.id}
source={source}
selected={selected}
previewStream={selected ? cameraPreview : null}
onSelect={() => setSelectedSourceId(source.id)}
/>
);
})}
</div>
{!loading && sources.length === 0 ? (
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No {kind === "camera" ? "cameras" : "windows or displays"} found.
</p>
) : null}
{canShareAudio ? (
<div className="flex items-center justify-between gap-4 rounded-xl border p-4">
<div className="space-y-1">
<Label htmlFor="share-system-audio">Share audio</Label>
<p className="text-xs text-muted-foreground">
Some apps and protected media do not allow audio capture.
</p>
</div>
<Switch
id="share-system-audio"
checked={shareAudio}
onCheckedChange={setShareAudio}
/>
</div>
) : null}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading sources...
</div>
) : null}
</div>
<DialogFooter className="m-0! p-2!">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={loading || sources.length === 0 || !selectedSourceId}
onClick={startSharing}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
Start sharing
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,77 @@
import { cn } from "@methanium/ui";
import { AppWindow, Camera, MonitorUp } from "lucide-react";
import { useEffect, useRef } from "react";
import type { MediaShareSource } from "../mediaShare";
function SourceIcon({ source }: { source: MediaShareSource }) {
const className = "size-8 text-muted-foreground";
if (source.kind === "camera") return <Camera className={className} />;
if (source.kind === "window") return <AppWindow className={className} />;
return <MonitorUp className={className} />;
}
function VideoPreview({ stream }: { stream: MediaStream }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.srcObject = stream;
void video.play().catch(() => undefined);
return () => {
video.srcObject = null;
};
}, [stream]);
return (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="h-full w-full object-cover"
/>
);
}
export default function MediaSourceCard({
source,
selected,
previewStream,
onSelect,
}: {
source: MediaShareSource;
selected: boolean;
previewStream?: MediaStream | null;
onSelect: () => void;
}) {
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cn(
"overflow-hidden rounded-xl border bg-card text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
selected
? "border-4 border-(--primary)!"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<div className="flex aspect-video w-full items-center justify-center overflow-hidden bg-muted">
{previewStream ? (
<VideoPreview stream={previewStream} />
) : source.thumbnail ? (
<img
src={source.thumbnail}
alt=""
className="h-full w-full object-cover"
/>
) : (
<SourceIcon source={source} />
)}
</div>
</button>
);
}

View file

@ -178,6 +178,13 @@ export default function Base({
Track.Source.ScreenShare,
);
const screenSharePreview = participant?.attributes["screenSharePreview"];
const cameraPublication = getTrackPublicationBySource(
participant,
Track.Source.Camera,
);
const cameraDisabled = useCall((state) =>
user ? state.disabledCameraParticipantIds.includes(user.UserId) : false,
);
const [ownId, setOwnId] = useState(0);
useEffect(() => {
@ -271,7 +278,7 @@ export default function Base({
flush && !fill && "border-x-0!",
type === "user" &&
isSpeaking &&
"border-4 border-(--primary-foreground-alt)/75!",
"border-4 border-(--primary-foreground-alt)/75! rounded-lg",
)}
>
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
@ -341,23 +348,31 @@ export default function Base({
</div>
) : null)}
{type === "user" && (
<Avatar
style={{
width: "32cqh",
height: "32cqh",
}}
>
<AvatarImage src={user.Avatar} />
<AvatarFallback
{type === "user" &&
(cameraPublication?.track && !cameraDisabled ? (
<VideoViewer
fill={fill}
flush={flush}
participantId={participant.identity}
publication={cameraPublication}
/>
) : (
<Avatar
style={{
fontSize: "11cqh",
width: "32cqh",
height: "32cqh",
}}
>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<AvatarImage src={user.Avatar} />
<AvatarFallback
style={{
fontSize: "11cqh",
}}
>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
</div>
</div>
}

View file

@ -6,7 +6,7 @@ import {
Slider,
} from "@methanium/ui";
import type { User } from "@tensamin/user/context";
import { useCall } from "../../store";
import { setParticipantCameraDisabled, useCall } from "../../store";
import { useState } from "react";
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
@ -33,6 +33,9 @@ export default function ContextMenu({
const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds,
);
const cameraDisabled = useCall((state) =>
state.disabledCameraParticipantIds.includes(user.UserId),
);
return (
<ContextMenuContent className="p-1">
@ -71,6 +74,19 @@ export default function ContextMenu({
<p>Mute Soundboard</p>
<EmptyCheckboxIndicator checked={soundboardMuted} />
</ContextMenuCheckboxItem>
{user.UserId !== ownId ? (
<ContextMenuCheckboxItem
checked={cameraDisabled}
onCheckedChange={(checked) =>
setParticipantCameraDisabled(user.UserId, checked)
}
onSelect={(e) => e.preventDefault()}
className="flex justify-between"
>
<p>Disable camera</p>
<EmptyCheckboxIndicator checked={cameraDisabled} />
</ContextMenuCheckboxItem>
) : null}
<ContextMenuCheckboxItem
checked={serverDeafened}
onCheckedChange={setServerDeafened}

View file

@ -69,6 +69,7 @@ function MobileCallPill({
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined
>(undefined);
const safeAreaRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const initialCoords = {
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
@ -120,21 +121,37 @@ function MobileCallPill({
};
}, [lastSpeakingUser?.Avatar]);
const getCoordsForPosition = useCallback((nextPosition: Positions): Point => {
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const getSafeArea = useCallback(() => {
const element = safeAreaRef.current;
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
const style = getComputedStyle(element);
return {
x: nextPosition.endsWith("right")
? window.innerWidth - width - MOBILE_MARGIN
: MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - height - MOBILE_MARGIN
: MOBILE_MARGIN,
top: parseFloat(style.paddingTop) || 0,
right: parseFloat(style.paddingRight) || 0,
bottom: parseFloat(style.paddingBottom) || 0,
left: parseFloat(style.paddingLeft) || 0,
};
}, []);
const getCoordsForPosition = useCallback(
(nextPosition: Positions): Point => {
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
return {
x: nextPosition.endsWith("right")
? window.innerWidth - safeArea.right - width - MOBILE_MARGIN
: safeArea.left + MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN
: safeArea.top + MOBILE_MARGIN,
};
},
[getSafeArea],
);
const setCoordsSafe = useCallback((next: Point) => {
coordsRef.current = next;
setCoords(next);
@ -166,90 +183,102 @@ function MobileCallPill({
}
return (
<Card
ref={pillRef}
onClick={() => {
if (movedRef.current) {
movedRef.current = false;
return;
}
setOpenMobile(false);
void openCallPage(callId);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - coordsRef.current.x,
y: event.clientY - coordsRef.current.y,
};
dragStartRef.current = { x: event.clientX, y: event.clientY };
movedRef.current = false;
setIsDragging(true);
}}
onPointerMove={(event) => {
if (!isDragging) return;
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const next = {
x: Math.min(
window.innerWidth - width - MOBILE_MARGIN,
Math.max(MOBILE_MARGIN, event.clientX - dragOffsetRef.current.x),
),
y: Math.min(
window.innerHeight - height - MOBILE_MARGIN,
Math.max(MOBILE_MARGIN, event.clientY - dragOffsetRef.current.y),
),
};
if (
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
Math.abs(event.clientY - dragStartRef.current.y) > 3
) {
movedRef.current = true;
}
setCoordsSafe(next);
}}
onPointerUp={(event) => {
if (!isDragging) return;
const nextPosition = `${
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
setIsDragging(false);
snapToPosition(nextPosition);
}}
onPointerCancel={() => {
setIsDragging(false);
snapToPosition(position);
}}
className={cn(
"fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundColor: avatarBackgroundColor,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition: isDragging
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
<div
ref={safeAreaRef}
className="pointer-events-none fixed inset-0 z-200 pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
<Card
ref={pillRef}
onClick={() => {
if (movedRef.current) {
movedRef.current = false;
return;
}
setOpenMobile(false);
void openCallPage(callId);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - coordsRef.current.x,
y: event.clientY - coordsRef.current.y,
};
dragStartRef.current = { x: event.clientX, y: event.clientY };
movedRef.current = false;
setIsDragging(true);
}}
onPointerMove={(event) => {
if (!isDragging) return;
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
const next = {
x: Math.min(
window.innerWidth - safeArea.right - width - MOBILE_MARGIN,
Math.max(
safeArea.left + MOBILE_MARGIN,
event.clientX - dragOffsetRef.current.x,
),
),
y: Math.min(
window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN,
Math.max(
safeArea.top + MOBILE_MARGIN,
event.clientY - dragOffsetRef.current.y,
),
),
};
if (
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
Math.abs(event.clientY - dragStartRef.current.y) > 3
) {
movedRef.current = true;
}
setCoordsSafe(next);
}}
onPointerUp={(event) => {
if (!isDragging) return;
const nextPosition = `${
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
setIsDragging(false);
snapToPosition(nextPosition);
}}
onPointerCancel={() => {
setIsDragging(false);
snapToPosition(position);
}}
className={cn(
"pointer-events-auto fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundColor: avatarBackgroundColor,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition: isDragging
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
</div>
);
}

View file

@ -1,336 +0,0 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from "@methanium/ui";
import {
type DesktopScreenShareAudioOutput,
type DesktopScreenShareCapabilities,
type DesktopScreenShareSource,
useDesktopMedia,
} from "@tensamin/shared/desktopMedia";
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.showAudioOutputSelector
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
: capabilities.hasReliableSystemAudio && 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,
portalContainer,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
isScreensharing: boolean;
portalContainer?: HTMLElement;
}) {
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.showAudioOutputSelector) {
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.runtime === "electron") {
await startLinuxDesktopScreenShare(selectedSource.id);
} else 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"
portalProps={{ container: portalContainer }}
>
<DialogHeader className="p-4 pb-3">
<DialogTitle>Share your screen</DialogTitle>
<DialogDescription>
Choose a window or display you want to share.
</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 found.
</p>
)}
{capabilities?.showAudioOutputSelector ? (
<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>
{selectedAudioOutputId === NONE_AUDIO_OUTPUT
? "None"
: (audioOutputs.find(
(output) => output.id === selectedAudioOutputId,
)?.name ?? "None")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
{audioOutputs.map((output) => (
<SelectItem key={output.id} value={output.id}>
{output.isDefault
? `${output.name} (Default)`
: output.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : capabilities?.showAudioSwitch ? (
<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.hasReliableSystemAudio}
onCheckedChange={setShareAudio}
/>
</div>
{!capabilities.hasReliableSystemAudio && (
<p className="text-xs text-muted-foreground">
System audio sharing is not available on this platform.
</p>
)}
</div>
) : null}
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading sources...
</div>
)}
</div>
<DialogFooter className="m-0! p-2!">
<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

@ -10,7 +10,7 @@ import {
TooltipTrigger,
useIsMobile,
} from "@methanium/ui";
import ScreenshareButton from "./buttons/screenshare";
import MediaShareButton from "./buttons/mediaShare";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import { Room, Track } from "livekit-client";
@ -199,7 +199,7 @@ export default function SidebarBox() {
<div className="flex justify-center gap-1">
<MuteButton className="h-9! w-[24%]" />
<DeafButton className="h-9! w-[24%]" />
<ScreenshareButton className="h-9! w-[24%]" defaultPortal />
<MediaShareButton className="h-9! w-[24%]!" defaultPortal />
<LeaveButton className="h-9! w-[24%]" />
</div>
</CardContent>

View file

@ -0,0 +1,84 @@
import type {
MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind,
MediaShareRequest,
MediaShareSession,
MediaShareSource,
} from "./types";
export async function listCameraSources(): Promise<MediaShareSource[]> {
const permissionStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
let devices: MediaDeviceInfo[];
try {
devices = await navigator.mediaDevices.enumerateDevices();
} finally {
permissionStream.getTracks().forEach((track) => track.stop());
}
let cameraIndex = 0;
return devices
.filter((device) => device.kind === "videoinput")
.map((device) => ({
id: device.deviceId,
kind: "camera" as const,
name: device.label || `Camera ${++cameraIndex}`,
}));
}
export async function startCamera(
sourceId?: string,
): Promise<MediaShareSession> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: sourceId ? { deviceId: { exact: sourceId } } : true,
});
return streamSession(stream);
}
function streamSession(stream: MediaStream): MediaShareSession {
return {
tracks: stream.getTracks(),
stop: async () => {
stream.getTracks().forEach((track) => track.stop());
},
};
}
export class BrowserMediaShareAdapter implements MediaShareAdapter {
async getCapabilities(): Promise<MediaShareCapabilities> {
return {
runtime: "browser",
screenPicker: "native",
canShareScreenAudio: true,
canSelectScreenAudioOutput: false,
};
}
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
return kind === "camera" ? listCameraSources() : [];
}
async start(request: MediaShareRequest): Promise<MediaShareSession> {
if (request.kind === "camera") {
return startCamera(request.sourceId);
}
const options: DisplayMediaStreamOptions & {
systemAudio: "include" | "exclude";
surfaceSwitching: "include" | "exclude";
} = {
audio: request.includeAudio ?? true,
video: true,
systemAudio: request.includeAudio === false ? "exclude" : "include",
surfaceSwitching: "include",
};
const stream = await navigator.mediaDevices.getDisplayMedia(options);
return streamSession(stream);
}
}

View file

@ -0,0 +1,154 @@
import { log } from "@tensamin/shared/log";
import { type LocalTrack, Room, Track } from "livekit-client";
import {
getMediaShareAdapter,
type MediaShareKind,
type MediaShareRequest,
type MediaShareSession,
} from ".";
export type LocalMediaShareSession = {
tracks: Array<LocalTrack | MediaStreamTrack>;
capture: MediaShareSession;
};
type MediaShareStoreState = {
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
};
type MediaShareStoreSetState = (
updater:
| Partial<MediaShareStoreState>
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
) => void;
type MediaShareControllerOptions = {
room: Room;
getState: () => MediaShareStoreState;
setState: MediaShareStoreSetState;
getLocalParticipantId: () => number | null;
startWatching: (participantId: number) => void;
stopWatching: (participantId: number) => void;
syncParticipantState: () => void;
};
export function createMediaShareController({
room,
getState,
setState,
getLocalParticipantId,
startWatching,
stopWatching,
syncParticipantState,
}: MediaShareControllerOptions) {
function getSession(kind: MediaShareKind) {
return kind === "screen"
? getState().screenShareSession
: getState().cameraSession;
}
function setSession(
kind: MediaShareKind,
session: LocalMediaShareSession | null,
) {
setState(
kind === "screen"
? { screenShareSession: session }
: { cameraSession: session },
);
}
async function clearPublishedShare(kind: MediaShareKind) {
const session = getSession(kind);
if (!session) return;
setSession(kind, null);
await Promise.all(
session.tracks.map((track) =>
room.localParticipant.unpublishTrack(track, true).catch((error) => {
log(1, "call", "red", `Failed to unpublish ${kind} track`, error);
}),
),
);
await session.capture.stop().catch((error) => {
log(1, "call", "red", `Failed to stop ${kind} capture`, error);
});
if (kind === "screen") {
const localParticipantId = getLocalParticipantId();
if (localParticipantId != null) stopWatching(localParticipantId);
}
}
async function publishShare(
kind: MediaShareKind,
capture: MediaShareSession,
) {
if (capture.tracks.length === 0) {
await capture.stop();
throw new Error(`No ${kind} tracks were created.`);
}
const published: MediaStreamTrack[] = [];
try {
for (const track of capture.tracks) {
await room.localParticipant.publishTrack(track, {
source:
track.kind === Track.Kind.Audio
? Track.Source.ScreenShareAudio
: kind === "screen"
? Track.Source.ScreenShare
: Track.Source.Camera,
});
published.push(track);
}
} catch (error) {
await Promise.all(
published.map((track) =>
room.localParticipant.unpublishTrack(track, true),
),
);
await capture.stop();
throw error;
}
for (const track of capture.tracks) {
track.addEventListener(
"ended",
() => {
void stop(kind);
},
{ once: true },
);
}
setSession(kind, { tracks: capture.tracks, capture });
syncParticipantState();
if (kind === "screen") {
const localParticipantId = getLocalParticipantId();
if (localParticipantId != null) startWatching(localParticipantId);
}
}
async function start(request: MediaShareRequest) {
await clearPublishedShare(request.kind);
const capture = await getMediaShareAdapter().start(request);
await publishShare(request.kind, capture);
}
async function stop(kind: MediaShareKind) {
await clearPublishedShare(kind);
syncParticipantState();
}
async function clearAll() {
await Promise.all([
clearPublishedShare("screen"),
clearPublishedShare("camera"),
]);
}
return { clearAll, start, stop };
}

View file

@ -0,0 +1,54 @@
import type {} from "@tensamin/shared/desktopMedia";
import { BrowserMediaShareAdapter, listCameraSources } from "./browser";
import type {
MediaShareCapabilities,
MediaShareKind,
MediaShareRequest,
MediaShareSession,
MediaShareSource,
} from "./types";
export class ElectronMediaShareAdapter extends BrowserMediaShareAdapter {
override async getCapabilities(): Promise<MediaShareCapabilities> {
const capabilities =
await window.tensaminDesktop?.media?.getScreenShareCapabilities?.();
return {
runtime: "electron",
screenPicker: "sources",
canShareScreenAudio: capabilities?.hasReliableSystemAudio ?? false,
canSelectScreenAudioOutput:
capabilities?.showAudioOutputSelector ?? false,
};
}
override async listSources(
kind: MediaShareKind,
): Promise<MediaShareSource[]> {
if (kind === "camera") {
return listCameraSources();
}
return (
(await window.tensaminDesktop?.media?.listScreenShareSources?.()) ?? []
);
}
override async start(request: MediaShareRequest): Promise<MediaShareSession> {
if (request.kind === "camera") {
return super.start(request);
}
if (!request.sourceId) {
throw new Error("Choose a screen or window to share.");
}
const select = window.tensaminDesktop?.media?.selectScreenShareSource;
if (!select) {
throw new Error("Electron screen capture is unavailable.");
}
await select(request.sourceId);
return super.start({ ...request, sourceId: undefined });
}
}

View file

@ -0,0 +1,27 @@
import { BrowserMediaShareAdapter } from "./browser";
import { ElectronMediaShareAdapter } from "./electron";
import { TauriMediaShareAdapter } from "./tauri";
import type { MediaShareAdapter } from "./types";
let adapter: MediaShareAdapter | null = null;
export function getMediaShareAdapter(): MediaShareAdapter {
if (!adapter) {
adapter = window.tensaminMobileMedia
? new TauriMediaShareAdapter()
: window.tensaminDesktop?.media
? new ElectronMediaShareAdapter()
: new BrowserMediaShareAdapter();
}
return adapter;
}
export type {
MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind,
MediaShareRequest,
MediaShareSession,
MediaShareSource,
} from "./types";

View file

@ -0,0 +1,270 @@
import { listCameraSources, startCamera } from "./browser";
import type {
MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind,
MediaShareRequest,
MediaShareSession,
MediaShareSource,
} from "./types";
type MobileMediaApi = {
startScreenShare: (includeAudio: boolean) => void;
stopScreenShare: () => void;
requestCameraPermission: () => void;
};
declare global {
interface Window {
tensaminMobileMedia?: MobileMediaApi;
}
}
type FrameDetail = {
data: string;
mimeType: string;
width: number;
height: number;
};
type AudioDetail = {
data: string;
sampleRate: number;
channelCount: number;
encoding: "pcm16le";
};
function eventDetail<T>(event: Event): T {
return (event as CustomEvent<T>).detail;
}
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) {
bytes[index] = decoded.charCodeAt(index);
}
return bytes;
}
async function requestCameraPermission() {
const bridge = window.tensaminMobileMedia;
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for camera permission."));
}, 30_000);
const onPermission = (event: Event) => {
cleanup();
const permission = eventDetail<{ camera: boolean }>(event);
if (permission.camera) {
resolve();
} else {
reject(new Error("Camera permission was denied."));
}
};
const cleanup = () => {
window.clearTimeout(timeout);
window.removeEventListener(
"tensamin-mobile-camera-permission",
onPermission,
);
};
window.addEventListener("tensamin-mobile-camera-permission", onPermission);
bridge.requestCameraPermission();
});
}
async function startMobileScreen(
includeAudio: boolean,
): Promise<MediaShareSession> {
const bridge = window.tensaminMobileMedia;
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
return new Promise<MediaShareSession>((resolve, reject) => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
reject(new Error("Unable to create the mobile capture canvas."));
return;
}
const stream = canvas.captureStream(15);
let audioContext: AudioContext | null = null;
let audioNode: ScriptProcessorNode | null = null;
let audioDestination: MediaStreamAudioDestinationNode | null = null;
const audioQueue: Float32Array[] = [];
let audioQueueOffset = 0;
let queuedAudioSamples = 0;
let started = false;
let resolved = false;
let firstFrame = false;
let lastError: Error | null = null;
let errorTimer = 0;
const timeout = window.setTimeout(() => {
cleanup();
bridge.stopScreenShare();
reject(
lastError ?? new Error("Timed out starting mobile screen sharing."),
);
}, 60_000);
const complete = () => {
if (!started || !firstFrame || resolved) return;
resolved = true;
window.clearTimeout(timeout);
resolve({
tracks: stream.getTracks(),
stop: async () => {
bridge.stopScreenShare();
cleanup();
},
});
};
const onStarted = (event: Event) => {
started = true;
const detail = eventDetail<{ includeAudio: boolean }>(event);
if (detail.includeAudio) {
audioContext = new AudioContext({ sampleRate: 48_000 });
audioNode = audioContext.createScriptProcessor(2048, 0, 1);
audioDestination = audioContext.createMediaStreamDestination();
audioNode.onaudioprocess = ({ outputBuffer }) => {
const output = outputBuffer.getChannelData(0);
output.fill(0);
let outputOffset = 0;
while (outputOffset < output.length && audioQueue.length > 0) {
const chunk = audioQueue[0];
const available = chunk.length - audioQueueOffset;
const count = Math.min(available, output.length - outputOffset);
output.set(
chunk.subarray(audioQueueOffset, audioQueueOffset + count),
outputOffset,
);
outputOffset += count;
audioQueueOffset += count;
queuedAudioSamples -= count;
if (audioQueueOffset === chunk.length) {
audioQueue.shift();
audioQueueOffset = 0;
}
}
};
audioNode.connect(audioDestination);
void audioContext.resume();
for (const track of audioDestination.stream.getAudioTracks()) {
stream.addTrack(track);
}
}
complete();
};
const onFrame = (event: Event) => {
const detail = eventDetail<FrameDetail>(event);
const image = new Image();
image.onload = () => {
if (canvas.width !== detail.width || canvas.height !== detail.height) {
canvas.width = detail.width;
canvas.height = detail.height;
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
firstFrame = true;
complete();
};
image.src = `data:${detail.mimeType};base64,${detail.data}`;
};
const onAudio = (event: Event) => {
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
const samples = new Int16Array(
bytes.buffer,
bytes.byteOffset,
Math.floor(bytes.byteLength / 2),
);
const chunk = new Float32Array(samples.length);
for (let index = 0; index < samples.length; index += 1) {
chunk[index] = samples[index] / 32768;
}
audioQueue.push(chunk);
queuedAudioSamples += chunk.length;
const maximumQueuedSamples = 48_000 * 2;
while (queuedAudioSamples > maximumQueuedSamples && audioQueue.length) {
const dropped = audioQueue.shift();
if (!dropped) break;
queuedAudioSamples -= dropped.length - audioQueueOffset;
audioQueueOffset = 0;
}
};
const onStopped = () => {
cleanup();
if (!resolved) reject(new Error("Mobile screen sharing was stopped."));
};
const onError = (event: Event) => {
lastError = new Error(eventDetail<{ message: string }>(event).message);
window.clearTimeout(errorTimer);
errorTimer = window.setTimeout(() => {
if (!started && !resolved) {
cleanup();
reject(lastError ?? new Error("Mobile screen sharing failed."));
}
}, 300);
};
const listeners: Array<[string, EventListener]> = [
["tensamin-mobile-screen-started", onStarted],
["tensamin-mobile-screen-frame", onFrame],
["tensamin-mobile-screen-audio", onAudio],
["tensamin-mobile-screen-stopped", onStopped],
["tensamin-mobile-screen-error", onError],
];
const cleanup = () => {
window.clearTimeout(timeout);
window.clearTimeout(errorTimer);
listeners.forEach(([name, listener]) =>
window.removeEventListener(name, listener),
);
stream.getTracks().forEach((track) => track.stop());
audioNode?.disconnect();
audioNode = null;
void audioContext?.close();
audioContext = null;
};
listeners.forEach(([name, listener]) =>
window.addEventListener(name, listener),
);
bridge.startScreenShare(includeAudio);
});
}
export class TauriMediaShareAdapter implements MediaShareAdapter {
async getCapabilities(): Promise<MediaShareCapabilities> {
return {
runtime: "tauri",
screenPicker: "system",
canShareScreenAudio: true,
canSelectScreenAudioOutput: false,
};
}
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
if (kind !== "camera") return [];
await requestCameraPermission();
return listCameraSources();
}
async start(request: MediaShareRequest): Promise<MediaShareSession> {
if (request.kind === "screen") {
return startMobileScreen(request.includeAudio ?? true);
}
await requestCameraPermission();
return startCamera(request.sourceId);
}
}

View file

@ -0,0 +1,33 @@
export type MediaShareKind = "screen" | "camera";
export type MediaShareSource = {
id: string;
kind: "screen" | "window" | "camera";
name: string;
subtitle?: string | null;
thumbnail?: string | null;
};
export type MediaShareCapabilities = {
runtime: "browser" | "electron" | "tauri";
screenPicker: "native" | "sources" | "system";
canShareScreenAudio: boolean;
canSelectScreenAudioOutput: boolean;
};
export type MediaShareRequest = {
kind: MediaShareKind;
sourceId?: string;
includeAudio?: boolean;
};
export type MediaShareSession = {
tracks: MediaStreamTrack[];
stop: () => Promise<void>;
};
export interface MediaShareAdapter {
getCapabilities(): Promise<MediaShareCapabilities>;
listSources(kind: MediaShareKind): Promise<MediaShareSource[]>;
start(request: MediaShareRequest): Promise<MediaShareSession>;
}

View file

@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
}
}
function syncDocumentClasses(targetDocument: Document) {
targetDocument.documentElement.className = document.documentElement.className;
function syncDocumentAttributes(targetDocument: Document) {
for (const attribute of document.documentElement.attributes) {
targetDocument.documentElement.setAttribute(
attribute.name,
attribute.value,
);
}
targetDocument.body.className = document.body.className;
}
@ -58,12 +63,12 @@ function PopoutScreen() {
popoutWindow.document.title = document.title;
popoutWindow.document.body.innerHTML = "";
popoutWindow.document.body.style.margin = "0";
copyDocumentStyles(popoutWindow.document);
syncDocumentAttributes(popoutWindow.document);
popoutWindow.document.documentElement.style.height = "100%";
popoutWindow.document.body.style.height = "100%";
copyDocumentStyles(popoutWindow.document);
syncDocumentClasses(popoutWindow.document);
const containerElement = popoutWindow.document.createElement("div");
containerElement.style.width = "100%";
containerElement.style.height = "100%";

View file

@ -1,177 +0,0 @@
import { log } from "@tensamin/shared/log";
import type {} from "@tensamin/shared/desktopMedia";
import {
type LocalTrack,
Room,
type ScreenShareCaptureOptions,
Track,
} from "livekit-client";
export type ScreenShareSession = {
tracks: Array<LocalTrack | MediaStreamTrack>;
cleanup?: () => void;
};
type ScreenShareStoreState = {
screenShareSession: ScreenShareSession | null;
};
type ScreenShareStoreSetState = (
updater:
| Partial<ScreenShareStoreState>
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
) => void;
type ScreenShareControllerOptions = {
room: Room;
getState: () => ScreenShareStoreState;
setState: ScreenShareStoreSetState;
getLocalParticipantId: () => number | null;
startWatching: (participantId: number, options?: { focus?: boolean }) => void;
stopWatching: (participantId: number) => void;
syncParticipantState: () => void;
};
export function createScreenShareController({
room,
getState,
setState,
getLocalParticipantId,
startWatching,
stopWatching,
syncParticipantState,
}: ScreenShareControllerOptions) {
async function clearPublishedScreenShare() {
const screenShareSession = 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?.();
const localParticipantId = getLocalParticipantId();
if (localParticipantId != null) {
stopWatching(localParticipantId);
}
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 },
);
}
setState({ screenShareSession: { tracks, cleanup } });
syncParticipantState();
const localParticipantId = getLocalParticipantId();
if (localParticipantId != null) {
startWatching(localParticipantId, { focus: true });
}
}
async function startScreenShare(options?: ScreenShareCaptureOptions) {
await clearPublishedScreenShare();
const tracks = await room.localParticipant.createScreenTracks(options);
await publishScreenShareTracks(tracks, () => {
tracks.forEach((track) => track.stop());
});
}
async function startLinuxDesktopScreenShare(sourceId: string) {
await clearPublishedScreenShare();
if (window.tensaminDesktop?.media?.selectScreenShareSource) {
await window.tensaminDesktop.media.selectScreenShareSource(sourceId);
const tracks = await room.localParticipant.createScreenTracks({
audio: false,
video: true,
systemAudio: "exclude",
surfaceSwitching: "exclude",
selfBrowserSurface: "exclude",
contentHint: "detail",
});
await publishScreenShareTracks(tracks, () => {
tracks.forEach((track) => track.stop());
});
return;
}
throw new Error(
`Electron desktop media bridge is unavailable. Cannot capture ${sourceId}.`,
);
}
async function stopScreenShare() {
await clearPublishedScreenShare();
syncParticipantState();
}
async function setScreenShareEnabled(
enabled: boolean,
options?: ScreenShareCaptureOptions,
) {
if (enabled) {
await startScreenShare(options);
return;
}
await stopScreenShare();
}
return {
clearPublishedScreenShare,
startLinuxDesktopScreenShare,
startScreenShare,
stopScreenShare,
setScreenShareEnabled,
};
}

View file

@ -26,16 +26,16 @@ import {
Room,
RoomEvent,
type RemoteTrack,
type ScreenShareCaptureOptions,
Track,
setLogExtension,
getLogger,
} from "livekit-client";
import z from "zod";
import {
createScreenShareController,
type ScreenShareSession,
} from "./screenshare";
createMediaShareController,
type LocalMediaShareSession,
} from "./mediaShare/controller";
import type { MediaShareRequest } from "./mediaShare";
import {
getSpeakingDetector,
disposeSpeakingDetector,
@ -69,8 +69,7 @@ type IncomingCallInvite = {
senderId: number;
};
type CurrentCallData =
| (z.infer<typeof mtp.CallData.response> & { exists: boolean })
| null;
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type NavigateFn = (options: {
to: string;
@ -102,8 +101,11 @@ type CallStore = {
currentCallData: CurrentCallData;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: ScreenShareSession | null;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
@ -302,11 +304,21 @@ function matchesRemoteTrackSelector(
}
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
const state = useCall.getState();
const watchesScreen =
state.watchedStreamParticipantIds.includes(participantId);
const cameraDisabled =
state.disabledCameraParticipantIds.includes(participantId);
for (const publication of getRemoteTrackPublications(participantId)) {
publication.setSubscribed(
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio,
);
const subscribed =
publication.source === Track.Source.Camera
? !cameraDisabled
: publication.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShareAudio
? watchesScreen
: publication.kind === Track.Kind.Audio;
publication.setSubscribed(subscribed);
}
}
@ -596,11 +608,13 @@ export function getRoomMetadata() {
// Sync local participant flags and screen-share derived state for the active call UI.
export function syncParticipantState() {
const { screenShareSession } = useCall.getState();
const { cameraSession, screenShareSession } = useCall.getState();
const room = getRoom();
useCall.setState({
micEnabled: room.localParticipant.isMicrophoneEnabled,
cameraEnabled:
cameraSession != null || room.localParticipant.isCameraEnabled,
screenShareEnabled:
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
isEncrypted:
@ -769,6 +783,20 @@ export function setParticipantTrackSubscribed(
syncParticipantState();
}
export function setParticipantCameraDisabled(
participantId: number,
disabled: boolean,
) {
useCall.setState((state) => ({
disabledCameraParticipantIds: disabled
? state.disabledCameraParticipantIds.includes(participantId)
? state.disabledCameraParticipantIds
: [...state.disabledCameraParticipantIds, participantId]
: state.disabledCameraParticipantIds.filter((id) => id !== participantId),
}));
setParticipantTrackSubscribed(participantId, Track.Source.Camera, !disabled);
}
// Focus a participant in the main call view even when they are not sharing a screen.
export function focusParticipant(
participantId: number,
@ -835,9 +863,8 @@ export function stopWatchingFocusedStream() {
stopWatchingStream(focusedParticipantId);
}
let screenShareController: ReturnType<
typeof createScreenShareController
> | null = null;
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
null;
function getNoiseFilterAssetBaseUrl() {
if (window.location.protocol === "file:") {
@ -847,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
return "/assets";
}
function getScreenShareController() {
if (!screenShareController) {
screenShareController = createScreenShareController({
function getMediaShareController() {
if (!mediaShareController) {
mediaShareController = createMediaShareController({
room: getRoom(),
getState: () => ({
screenShareSession: useCall.getState().screenShareSession,
cameraSession: useCall.getState().cameraSession,
}),
setState: (updater) => {
useCall.setState((state) =>
typeof updater === "function"
? updater({ screenShareSession: state.screenShareSession })
? updater({
screenShareSession: state.screenShareSession,
cameraSession: state.cameraSession,
})
: updater,
);
},
@ -869,7 +900,7 @@ function getScreenShareController() {
});
}
return screenShareController;
return mediaShareController;
}
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
@ -921,7 +952,7 @@ export async function disconnect() {
await clearScreenSharePreview();
try {
await getScreenShareController().clearPublishedScreenShare();
await getMediaShareController().clearAll();
} catch (error) {
log(
1,
@ -943,6 +974,9 @@ export async function disconnect() {
deaf: false,
view: "preview",
screenShareSession: null,
cameraSession: null,
cameraEnabled: false,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1070,37 +1104,37 @@ export async function toggleMute() {
syncParticipantState();
}
// Start browser-native screen sharing for the current participant.
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
await getScreenShareController().startScreenShare(options);
export async function startScreenShare(
request: Omit<MediaShareRequest, "kind"> = {},
) {
await getMediaShareController().start({ ...request, kind: "screen" });
await publishScreenSharePreview();
}
// Start the Linux desktop capture path that renders frames through Tauri.
export async function startLinuxDesktopScreenShare(sourceId: string) {
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
await publishScreenSharePreview();
export async function startCameraShare(sourceId?: string) {
await getMediaShareController().start({ kind: "camera", sourceId });
}
// Stop the local participant's active screen share and related previews.
export async function stopScreenShare() {
await getScreenShareController().stopScreenShare();
await getMediaShareController().stop("screen");
await clearScreenSharePreview();
}
export async function stopCameraShare() {
await getMediaShareController().stop("camera");
}
// Toggle screen sharing on or off from UI controls.
export async function setScreenShareEnabled(
enabled: boolean,
options?: ScreenShareCaptureOptions,
request: Omit<MediaShareRequest, "kind"> = {},
) {
await getScreenShareController().setScreenShareEnabled(enabled, options);
if (enabled) {
await publishScreenSharePreview();
await startScreenShare(request);
return;
}
await clearScreenSharePreview();
await stopScreenShare();
}
// Reset the in-memory call store when leaving the call experience entirely.
@ -1115,7 +1149,10 @@ export function resetCallState() {
livekitToken: null,
currentCallData: null,
deaf: false,
cameraEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1169,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
currentCallData: null,
deaf: false,
micEnabled: false,
cameraEnabled: false,
screenShareEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1537,14 +1577,7 @@ export function useInitializeCall() {
const participantId = getParticipantId(participant.identity);
if (participantId != null) {
if (
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio
) {
publication.setSubscribed(true);
} else {
publication.setSubscribed(false);
}
syncRemoteParticipantTrackSubscriptions(participantId);
}
onParticipantStateChange();

View file

@ -1,12 +1,14 @@
import { RoomEvent } from "livekit-client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCall, getRoom } from "../../store";
import { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
import Base from "../../components/modals/base";
import { useIsMobile } from "@methanium/ui";
const SECONDARY_ROW_HEIGHT_PX = 180;
const STACK_GAP_PX = 12;
export default function View() {
const isMobile = useIsMobile();
const room = getRoom();
const layoutVersion = useCall((state) => state.layoutVersion);
@ -27,6 +29,10 @@ export default function View() {
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
const [participantVersion, setParticipantVersion] = useState(0);
useEffect(() => {
if (isMobile) setUsersInFocusedViewHidden(true);
}, [isMobile]);
useEffect(() => {
const syncParticipants = () => {
setParticipantVersion((version) => version + 1);

View file

@ -7,3 +7,4 @@
- Add quality selection
- Good preview page
- Settings page
- Make the Three-Dots button open a dialog/context-menu to configure the call for anonymous invites or other admin stuff