(feat): improve mobile
(feat): add camera sharing (fix): vite tauri connection (fix): methanium/ui theme not applied to call popout
This commit is contained in:
parent
b5ce3c554d
commit
62aaa7c01d
31 changed files with 1915 additions and 859 deletions
|
|
@ -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}
|
||||
|
|
|
|||
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
171
packages/call/src/components/buttons/mediaShare.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
224
packages/call/src/components/mediaShareDialog.tsx
Normal file
224
packages/call/src/components/mediaShareDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
77
packages/call/src/components/mediaSourceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
84
packages/call/src/mediaShare/browser.ts
Normal file
84
packages/call/src/mediaShare/browser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
154
packages/call/src/mediaShare/controller.ts
Normal file
154
packages/call/src/mediaShare/controller.ts
Normal 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 };
|
||||
}
|
||||
54
packages/call/src/mediaShare/electron.ts
Normal file
54
packages/call/src/mediaShare/electron.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
packages/call/src/mediaShare/index.ts
Normal file
27
packages/call/src/mediaShare/index.ts
Normal 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";
|
||||
270
packages/call/src/mediaShare/tauri.ts
Normal file
270
packages/call/src/mediaShare/tauri.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
packages/call/src/mediaShare/types.ts
Normal file
33
packages/call/src/mediaShare/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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%";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue