(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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue