(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

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