diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 2509428..926ace0 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -21,12 +21,9 @@ struct ScreenShareAudioOutput { #[serde(rename_all = "camelCase")] struct ScreenShareCapabilities { platform: String, - #[serde(rename = "usesPipeWireAudioPicker")] - uses_pipewire_audio_picker: bool, - #[serde(rename = "supportsSystemAudioSwitch")] - supports_system_audio_switch: bool, - #[serde(rename = "supportsReliableSystemAudio")] - supports_reliable_system_audio: bool, + show_audio_output_selector: bool, + show_audio_switch: bool, + has_reliable_system_audio: bool, } #[tauri::command] @@ -100,13 +97,14 @@ fn list_screen_share_sources() -> Result, String> { } #[tauri::command] -fn list_screen_share_audio_outputs() -> Result, String> { +fn list_audio_outputs() -> Result, String> { #[cfg(target_os = "linux")] { + use serde_json::Value; use std::process::Command; let output = Command::new("pactl") - .args(["list", "short", "sinks"]) + .args(["--format=json", "list", "sinks"]) .output() .map_err(|error| error.to_string())?; @@ -114,28 +112,51 @@ fn list_screen_share_audio_outputs() -> Result, Stri return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); } + let default_sink = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok() + .filter(|result| result.status.success()) + .map(|result| String::from_utf8_lossy(&result.stdout).trim().to_string()); + + let sinks: Value = serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?; + let sink_entries = sinks + .as_array() + .ok_or_else(|| "Unexpected pactl sink response".to_string())?; + let mut outputs = Vec::new(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - let mut parts = line.split('\t'); - - let Some(id) = parts.next() else { + for sink in sink_entries { + let Some(index) = sink.get("index").and_then(Value::as_i64) else { continue; }; - let Some(name) = parts.next() else { + let Some(name) = sink.get("name").and_then(Value::as_str) else { continue; }; - let description = parts.next_back().unwrap_or(name).to_string(); + let description = sink + .get("description") + .and_then(Value::as_str) + .or_else(|| { + sink.get("properties") + .and_then(|properties| properties.get("device.description")) + .and_then(Value::as_str) + }) + .unwrap_or(name) + .to_string(); + + let is_default = default_sink.as_deref() == Some(name); outputs.push(ScreenShareAudioOutput { - id: id.to_string(), + id: index.to_string(), name: description, - is_default: false, + is_default, }); } + outputs.sort_by_key(|output| !output.is_default); + return Ok(outputs); } @@ -143,57 +164,6 @@ fn list_screen_share_audio_outputs() -> Result, Stri Ok(Vec::new()) } -#[tauri::command] -fn capture_screen_share_frame(source_id: &str) -> Result { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - { - use base64::Engine; - use image::{codecs::jpeg::JpegEncoder, DynamicImage}; - use std::io::Cursor; - use xcap::{Monitor, Window}; - - let Some((kind, index)) = source_id.split_once(':') else { - return Err("Invalid source id".to_string()); - }; - - let index = index - .parse::() - .map_err(|_| "Invalid source index".to_string())?; - - let frame = match kind { - "screen" => Monitor::all() - .map_err(|error| error.to_string())? - .into_iter() - .nth(index) - .ok_or_else(|| "Screen source not found".to_string())? - .capture_image() - .map_err(|error| error.to_string())?, - "window" => Window::all() - .map_err(|error| error.to_string())? - .into_iter() - .filter(|window| !window.is_minimized().unwrap_or(false)) - .nth(index) - .ok_or_else(|| "Window source not found".to_string())? - .capture_image() - .map_err(|error| error.to_string())?, - _ => return Err("Unsupported source kind".to_string()), - }; - - let mut buffer = Cursor::new(Vec::new()); - let image = DynamicImage::ImageRgba8(frame); - - JpegEncoder::new_with_quality(&mut buffer, 70) - .encode_image(&image) - .map_err(|error| error.to_string())?; - - let encoded = base64::engine::general_purpose::STANDARD.encode(buffer.into_inner()); - return Ok(format!("data:image/jpeg;base64,{encoded}")); - } - - #[allow(unreachable_code)] - Err("Screen capture is not supported on this platform".to_string()) -} - #[tauri::command] fn get_screen_share_capabilities() -> ScreenShareCapabilities { ScreenShareCapabilities { @@ -207,9 +177,9 @@ fn get_screen_share_capabilities() -> ScreenShareCapabilities { "other" } .to_string(), - uses_pipewire_audio_picker: cfg!(target_os = "linux"), - supports_system_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")), - supports_reliable_system_audio: cfg!(target_os = "windows"), + show_audio_output_selector: cfg!(target_os = "linux"), + show_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")), + has_reliable_system_audio: cfg!(target_os = "windows"), } } @@ -254,9 +224,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ list_screen_share_sources, - list_screen_share_audio_outputs, - get_screen_share_capabilities, - capture_screen_share_frame + list_audio_outputs, + get_screen_share_capabilities ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/tauri/src/context.tsx b/apps/tauri/src/context.tsx index 97fd664..7d86f00 100644 --- a/apps/tauri/src/context.tsx +++ b/apps/tauri/src/context.tsx @@ -1,9 +1,4 @@ -import { - createContext, - useContext, - useMemo, - type ReactNode, -} from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { invoke, isTauri } from "@tauri-apps/api/core"; export type DesktopScreenShareSource = { @@ -21,13 +16,12 @@ export type DesktopScreenShareAudioOutput = { export type DesktopScreenShareCapabilities = { platform: "linux" | "macos" | "windows" | "other"; - usesPipeWireAudioPicker: boolean; - supportsSystemAudioSwitch: boolean; - supportsReliableSystemAudio: boolean; + showAudioOutputSelector: boolean; + showAudioSwitch: boolean; + hasReliableSystemAudio: boolean; }; type DesktopMediaContextValue = { - isDesktopTauri: boolean; getScreenShareCapabilities: () => Promise; listScreenShareSources: () => Promise; listScreenShareAudioOutputs: () => Promise; @@ -35,9 +29,9 @@ type DesktopMediaContextValue = { const defaultCapabilities: DesktopScreenShareCapabilities = { platform: "other", - usesPipeWireAudioPicker: false, - supportsSystemAudioSwitch: false, - supportsReliableSystemAudio: false, + showAudioOutputSelector: false, + showAudioSwitch: false, + hasReliableSystemAudio: false, }; const desktopMediaContext = createContext( @@ -69,7 +63,9 @@ async function getScreenShareCapabilities(): Promise("get_screen_share_capabilities"); + return invoke( + "get_screen_share_capabilities", + ); } export function useDesktopMedia() { @@ -85,7 +81,6 @@ export function useDesktopMedia() { export default function Provider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ - isDesktopTauri: isTauri(), getScreenShareCapabilities, listScreenShareSources, listScreenShareAudioOutputs, diff --git a/packages/call/src/components/actions.tsx b/packages/call/src/components/actions.tsx index 29290d4..23aabcc 100644 --- a/packages/call/src/components/actions.tsx +++ b/packages/call/src/components/actions.tsx @@ -1,15 +1,26 @@ -import { Card, CardContent } from "@tensamin/ui"; +import { Card, CardContent, Button } from "@tensamin/ui"; import MuteButton from "./buttons/mute"; import DeafButton from "./buttons/deaf"; import ScreenshareButton from "./buttons/screenshare"; import LeaveButton from "./buttons/leave"; +import { stopWatchingFocusedStream, useCall } from "../store"; +import InviteButton from "./buttons/invite"; export default function Actions() { const sharedClasses = "w-14 h-10"; const sharedIconSize = 15; + const view = useCall((state) => state.view); + const focusedParticipantId = useCall((state) => state.focusedParticipantId); + const watchedStreamParticipantIds = useCall( + (state) => state.watchedStreamParticipantIds, + ); + const isWatchingFocusedStream = + view === "focused" && + focusedParticipantId != null && + watchedStreamParticipantIds.includes(focusedParticipantId); return ( -
+
@@ -18,7 +29,18 @@ export default function Actions() { className={sharedClasses} iconSize={sharedIconSize} /> - + + {isWatchingFocusedStream ? ( + + ) : ( + + )}
diff --git a/packages/call/src/components/buttons/invite.tsx b/packages/call/src/components/buttons/invite.tsx new file mode 100644 index 0000000..8ab8e24 --- /dev/null +++ b/packages/call/src/components/buttons/invite.tsx @@ -0,0 +1,59 @@ +import { useTTP } from "@tensamin/ttp"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, +} from "@tensamin/ui"; +import Wrapper from "@tensamin/user/wrapper"; +import { Mail } from "lucide-react"; +import { sendCallInvite } from "../../store"; +import { log, toast } from "@tensamin/shared/log"; + +export default function InviteButton({ + className, + iconSize, +}: { + className?: string; + iconSize?: number; +}) { + const { contacts } = useTTP(); + + return ( + + + + + } + /> + + {contacts.map((contact) => ( + Loading...
} + component={(user) => ( + + )} + /> + ))} + + + ); +} diff --git a/packages/call/src/components/buttons/screenshare.tsx b/packages/call/src/components/buttons/screenshare.tsx index be82c55..190cbe1 100644 --- a/packages/call/src/components/buttons/screenshare.tsx +++ b/packages/call/src/components/buttons/screenshare.tsx @@ -2,9 +2,9 @@ import { useState } from "react"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import { MonitorDot, ScreenShare } from "lucide-react"; import { toast } from "@tensamin/shared/log"; -import { useDesktopMedia } from "@tensamin/tauri/context"; +import { isTauri } from "@tauri-apps/api/core"; import { setScreenShareEnabled, useCall } from "../../store"; -import ScreenShareDialog from "./screenshareDialog"; +import ScreenShareDialog from "../screenshareDialog"; export default function ScreenshareButton({ className, @@ -16,7 +16,6 @@ export default function ScreenshareButton({ const isScreensharing = useCall((state) => state.screenShareEnabled); const [dialogOpen, setDialogOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const { isDesktopTauri } = useDesktopMedia(); async function startWebShare() { try { @@ -67,13 +66,13 @@ export default function ScreenshareButton({ } /> - + - + - {isDesktopTauri && ( + {isTauri() && ( + {children} + + ); +} + +function Overlay({ + type, + user, + participant, +}: { + type: "user" | "stream"; + user: User; + participant: Participant; +}) { + return ( +
+ {type === "user" && ( + <> + {!participant.isMicrophoneEnabled && ( + + + + )} + + )} + {type === "stream" && ( + + + + )} + +

{user.display}

+
+
+ ); +} + +export default function Base({ + participant, + type, + flush = false, +}: { + participant: Participant | undefined; + type: "user" | "stream"; + flush?: boolean; +}) { + const { get } = useUser(); + + const focusedParticipantId = useCall((state) => state.focusedParticipantId); + const view = useCall((state) => state.view); + const [user, setUser] = useState(null); + + useEffect(() => { + const participantId = Number(participant?.identity); + + if ( + !participant || + !Number.isInteger(participantId) || + participantId <= 0 + ) { + // eslint-disable-next-line + setUser(null); + return; + } + + let active = true; + + void get(participantId).then((nextUser) => { + if (active) { + setUser(nextUser); + } + }); + + return () => { + active = false; + }; + }, [participant, get]); + + if (!participant || !user) { + return ( +
+ ); + } + + const onClick = () => { + if (view === "grid") { + focusParticipant(user.user_id); + } else { + if (user.user_id === focusedParticipantId) { + setCallView("grid"); + } else { + focusParticipant(user.user_id); + } + } + }; + + return ( + + +
+ {view === "grid" && ( + + )} +
+
+ {/* Detect video / user and place here */} + + {type === "stream" && + Array.from(participant.videoTrackPublications.values()).map( + (publication) => + publication.isSubscribed && publication.track ? ( + + ) : ( + + ), + )} + + {type === "user" &&

{user.display}

} +
+
+ } + /> + + Stop Watching + + + ); +} diff --git a/packages/call/src/components/modals/focused/user.tsx b/packages/call/src/components/modals/focused/user.tsx deleted file mode 100644 index c33ec90..0000000 --- a/packages/call/src/components/modals/focused/user.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function SmallUser() { - return
Small User
; -} diff --git a/packages/call/src/components/modals/focused/video.tsx b/packages/call/src/components/modals/focused/video.tsx deleted file mode 100644 index 2c84969..0000000 --- a/packages/call/src/components/modals/focused/video.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function SmallVideo() { - return
Small Video
; -} diff --git a/packages/call/src/components/modals/grid/user.tsx b/packages/call/src/components/modals/grid/user.tsx deleted file mode 100644 index 767f911..0000000 --- a/packages/call/src/components/modals/grid/user.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function BigUser() { - return
Big User
; -} diff --git a/packages/call/src/components/modals/grid/video.tsx b/packages/call/src/components/modals/grid/video.tsx deleted file mode 100644 index 5819513..0000000 --- a/packages/call/src/components/modals/grid/video.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function BigVideo() { - return
Big Video
; -} diff --git a/packages/call/src/components/modals/user.tsx b/packages/call/src/components/modals/user.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/call/src/components/modals/video.tsx b/packages/call/src/components/modals/video.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/call/src/components/buttons/screenshareDialog.tsx b/packages/call/src/components/screenshareDialog.tsx similarity index 86% rename from packages/call/src/components/buttons/screenshareDialog.tsx rename to packages/call/src/components/screenshareDialog.tsx index bef1196..d199c37 100644 --- a/packages/call/src/components/buttons/screenshareDialog.tsx +++ b/packages/call/src/components/screenshareDialog.tsx @@ -24,10 +24,7 @@ import { import { toast } from "@tensamin/shared/log"; import { AppWindow, Loader2, MonitorUp } from "lucide-react"; import type { ScreenShareCaptureOptions } from "livekit-client"; -import { - setScreenShareEnabled, - startLinuxDesktopScreenShare, -} from "../../store"; +import { setScreenShareEnabled, startLinuxDesktopScreenShare } from "../store"; const NONE_AUDIO_OUTPUT = "__none__"; @@ -37,9 +34,9 @@ function buildScreenShareOptions( selectedAudioOutputId: string, shareAudio: boolean, ): ScreenShareCaptureOptions { - const wantsAudio = capabilities.usesPipeWireAudioPicker + const wantsAudio = capabilities.showAudioOutputSelector ? selectedAudioOutputId !== NONE_AUDIO_OUTPUT - : capabilities.supportsReliableSystemAudio && shareAudio; + : capabilities.hasReliableSystemAudio && shareAudio; return { audio: wantsAudio @@ -76,9 +73,9 @@ export default function ScreenShareDialog({ const [loading, setLoading] = useState(false); const [sources, setSources] = useState([]); - const [audioOutputs, setAudioOutputs] = useState( - [], - ); + const [audioOutputs, setAudioOutputs] = useState< + DesktopScreenShareAudioOutput[] + >([]); const [capabilities, setCapabilities] = useState(null); const [selectedSourceId, setSelectedSourceId] = useState(null); @@ -107,7 +104,7 @@ export default function ScreenShareDialog({ setSources(nextSources); setCapabilities(nextCapabilities); - if (nextCapabilities.usesPipeWireAudioPicker) { + if (nextCapabilities.showAudioOutputSelector) { const nextOutputs = await listScreenShareAudioOutputs(); if (!active) { @@ -197,7 +194,7 @@ export default function ScreenShareDialog({ Share your screen - Pick the window or display you want to share with the call. + Choose a window or display you want to share. @@ -238,11 +235,11 @@ export default function ScreenShareDialog({ {!loading && sources.length === 0 && (

- No windows or displays are currently available for capture. + No windows or displays found.

)} - {capabilities?.usesPipeWireAudioPicker ? ( + {capabilities?.showAudioOutputSelector ? (
-

- Select a PipeWire output to try sharing system audio with your - screen. -

- ) : capabilities?.supportsSystemAudioSwitch ? ( + ) : capabilities?.showAudioSwitch ? (

- Share system audio alongside your screen when the runtime can - provide it. + Share system audio alongside your screen when the runtime + can provide it.

- {!capabilities.supportsReliableSystemAudio && ( + {!capabilities.hasReliableSystemAudio && (

- System audio sharing is not available on this platform/runtime - yet. + System audio sharing is not available on this platform.

)}
@@ -297,17 +297,21 @@ export default function ScreenShareDialog({ {loading && (
- Loading available share targets... + Loading sources...
)} - + {isScreensharing && ( - )} diff --git a/packages/call/src/components/top.tsx b/packages/call/src/components/top.tsx index 1824a86..bb66f98 100644 --- a/packages/call/src/components/top.tsx +++ b/packages/call/src/components/top.tsx @@ -5,24 +5,23 @@ import { useEffect, useState } from "react"; export default function TopBar() { const { get } = useUser(); const room = useCall((state) => state.room); + const view = useCall((state) => state.view); - const userIds = Array.from(room.remoteParticipants.values(), (participant) => - Number(participant.identity), - ); + const userIds = Array.from(room.remoteParticipants.values(), (participant) => { + const participantId = Number(participant.identity); + return Number.isInteger(participantId) && participantId > 0 + ? participantId + : null; + }).filter((participantId): participantId is number => participantId != null); const userIdsKey = userIds.join(","); const [users, setUsers] = useState([]); useEffect(() => { let active = true; + const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number); - if (userIds.length === 0) { - return () => { - active = false; - }; - } - - void Promise.all(userIds.map((id) => get(id))) + void Promise.all(ids.map((id) => get(id))) .then((users) => { if (!active) { return; @@ -41,7 +40,7 @@ export default function TopBar() { return () => { active = false; }; - }, [get, userIdsKey, userIds]); + }, [get, userIdsKey]); return (
@@ -50,6 +49,7 @@ export default function TopBar() {
{user.display}
))}
+ {view} ); } diff --git a/packages/call/src/components/videoViewer.tsx b/packages/call/src/components/videoViewer.tsx new file mode 100644 index 0000000..cfce71f --- /dev/null +++ b/packages/call/src/components/videoViewer.tsx @@ -0,0 +1,38 @@ +import { VideoTrack, useParticipantTracks } from "@livekit/components-react"; +import { TrackPublication } from "livekit-client"; +import { useCall } from "../store"; +import { cn } from "@tensamin/ui"; + +export default function VideoViewer({ + className, + flush = false, + publication, + participantId, +}: { + className?: string; + flush?: boolean; + publication: TrackPublication; + participantId: string; +}) { + const room = useCall((state) => state.room); + const tracks = useParticipantTracks([publication.source], { + participantIdentity: participantId, + room, + }); + const trackRef = tracks[0]; + + if (!trackRef) { + return null; + } + + return ( + + ); +} diff --git a/packages/call/src/screenshare.ts b/packages/call/src/screenshare.ts new file mode 100644 index 0000000..7d5971c --- /dev/null +++ b/packages/call/src/screenshare.ts @@ -0,0 +1,232 @@ +import { invoke } from "@tauri-apps/api/core"; +import { log } from "@tensamin/shared/log"; +import { + type LocalTrack, + Room, + type ScreenShareCaptureOptions, + Track, +} from "livekit-client"; + +export type ScreenShareSession = { + tracks: Array; + cleanup?: () => void; +}; + +type ScreenShareStoreState = { + screenShareSession: ScreenShareSession | null; +}; + +type ScreenShareStoreSetState = ( + updater: + | Partial + | ((state: ScreenShareStoreState) => Partial), +) => 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, + 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(); + + const canvas = document.createElement("canvas"); + canvas.width = 1280; + canvas.height = 720; + canvas.style.display = "none"; + document.body.appendChild(canvas); + + const context = canvas.getContext("2d"); + + if (!context) { + canvas.remove(); + throw new Error("Failed to initialize the screen share canvas."); + } + + const stream = canvas.captureStream(8); + const videoTrack = stream.getVideoTracks()[0]; + + if (!videoTrack) { + canvas.remove(); + throw new Error("Failed to create a video track for screen sharing."); + } + + const image = new Image(); + let stopped = false; + let frameRequestInFlight = false; + + const renderFrame = async () => { + if (stopped || frameRequestInFlight) { + return; + } + + frameRequestInFlight = true; + + try { + const dataUrl = await invoke("capture_screen_share_frame", { + sourceId, + }); + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => + reject(new Error("Failed to decode screen share frame.")); + image.src = dataUrl; + }); + + if ( + canvas.width !== image.naturalWidth || + canvas.height !== image.naturalHeight + ) { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + } + + context.drawImage(image, 0, 0, canvas.width, canvas.height); + } finally { + frameRequestInFlight = false; + } + }; + + await renderFrame(); + + const interval = window.setInterval(() => { + void renderFrame().catch((error) => { + log( + 1, + "call", + "red", + "Failed to capture Linux screen share frame", + error, + ); + }); + }, 125); + + await publishScreenShareTracks( + [videoTrack], + () => { + stopped = true; + window.clearInterval(interval); + stream.getTracks().forEach((track) => track.stop()); + canvas.remove(); + }, + ); + } + + 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, + }; +} diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 44eaf6d..21bb401 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { create } from "zustand"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { invoke } from "@tauri-apps/api/core"; import { useTTP } from "@tensamin/ttp"; import { log, toast } from "@tensamin/shared/log"; import { ttp } from "@tensamin/shared/data"; @@ -12,7 +11,7 @@ import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; import { ExternalE2EEKeyProvider, LocalAudioTrack, - type LocalTrack, + type Participant, Room, RoomEvent, type RemoteTrack, @@ -23,6 +22,10 @@ import { } from "livekit-client"; import z from "zod"; import { toast as sonnerToast } from "sonner"; +import { + createScreenShareController, + type ScreenShareSession, +} from "./screenshare"; // logging setLogExtension( @@ -53,6 +56,7 @@ type GetSharedSecretFn = ( remotePublicKey: string, ) => Promise; type DecryptTextFn = (sharedSecret: string, text: string) => Promise; +type EncryptTextFn = (sharedSecret: string, text: string) => Promise; type LoadFn = (key: string) => Promise; type GetUserFn = (userId: number) => Promise<{ public_key: string }>; @@ -61,15 +65,11 @@ type Runtime = { send: SendFn; getSharedSecret: GetSharedSecretFn; decryptText: DecryptTextFn; + encryptText: EncryptTextFn; load: LoadFn; getUser: GetUserFn; }; -type ScreenShareSession = { - tracks: Array; - cleanup?: () => void; -}; - type CallStore = { state: CallState; view: CallView; @@ -82,6 +82,10 @@ type CallStore = { micEnabled: boolean; screenShareEnabled: boolean; screenShareSession: ScreenShareSession | null; + focusedParticipantId: number | null; + watchedStreamParticipantIds: number[]; + pendingWatchedParticipantIds: number[]; + activeScreenShareParticipantIds: number[]; isEncrypted: boolean; room: Room; keyProvider: ExternalE2EEKeyProvider; @@ -135,6 +139,75 @@ function clearRemoteAudio() { } } +function getParticipantId(identity: string | undefined): number | null { + if (!identity) { + return null; + } + + const parsed = Number(identity); + return Number.isFinite(parsed) ? parsed : null; +} + +function getAllParticipants(): Participant[] { + return [...room.remoteParticipants.values(), room.localParticipant]; +} + +function getActiveScreenShareParticipantIds(): number[] { + return getAllParticipants() + .map((participant) => ({ + participantId: getParticipantId(participant.identity), + hasScreenShare: + participant.getTrackPublication(Track.Source.ScreenShare) != null, + })) + .filter( + (entry): entry is { participantId: number; hasScreenShare: true } => + entry.participantId != null && entry.hasScreenShare, + ) + .map((entry) => entry.participantId); +} + +function getScreenShareTrackForParticipant(participantId: number) { + return getAllParticipants() + .find( + (participant) => getParticipantId(participant.identity) === participantId, + ) + ?.getTrackPublication(Track.Source.ScreenShare)?.track; +} + +function hasParticipant(participantId: number) { + return getAllParticipants().some( + (participant) => getParticipantId(participant.identity) === participantId, + ); +} + +function syncScreenShareParticipants() { + const activeScreenShareParticipantIds = getActiveScreenShareParticipantIds(); + const state = useCall.getState(); + const watchedStreamParticipantIds = state.watchedStreamParticipantIds.filter( + (participantId) => activeScreenShareParticipantIds.includes(participantId), + ); + const pendingWatchedParticipantIds = watchedStreamParticipantIds.filter( + (participantId) => getScreenShareTrackForParticipant(participantId) == null, + ); + const focusedParticipantId = + state.focusedParticipantId != null && + (watchedStreamParticipantIds.includes(state.focusedParticipantId) || + hasParticipant(state.focusedParticipantId)) + ? state.focusedParticipantId + : null; + + useCall.setState({ + activeScreenShareParticipantIds, + watchedStreamParticipantIds, + pendingWatchedParticipantIds, + focusedParticipantId, + view: + state.view === "focused" && focusedParticipantId == null + ? "grid" + : state.view, + }); +} + // utils function requireRuntime(runtime: Runtime | null): Runtime { if (!runtime) { @@ -144,6 +217,7 @@ function requireRuntime(runtime: Runtime | null): Runtime { return runtime; } +// Sync local participant flags and screen-share derived state for the active call UI. export function syncParticipantState() { const { room, screenShareSession } = useCall.getState(); @@ -154,25 +228,27 @@ export function syncParticipantState() { isEncrypted: room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, }); + + syncScreenShareParticipants(); } // set state functions +// Store runtime dependencies from hooks so the call store can use them outside React. export function setCallRuntime(runtime: Runtime) { useCall.setState({ runtime }); } -export function setCallState(state: CallState) { - useCall.setState({ state }); -} - +// Switch between preview, grid, and focused call layouts. export function setCallView(view: CallView) { useCall.setState({ view }); } +// Keep the current call id in sync with navigation and connection flow. export function setCallId(callId: string | null) { useCall.setState({ callId }); } +// Cache server call metadata used by the preview screen. export function setCurrentCallData( currentCallData: CurrentCallData & { exists: boolean }, ) { @@ -180,6 +256,7 @@ export function setCurrentCallData( } // more utils +// Navigate the app into the dedicated call route for an active call. export async function openCallPage(callId: string) { await requireRuntime(useCall.getState().runtime).navigate({ to: "/call", @@ -187,6 +264,7 @@ export async function openCallPage(callId: string) { }); } +// Request the LiveKit token that authorizes this client to join a call. export async function getCallToken(callId: string): Promise { const response = await requireRuntime(useCall.getState().runtime) .send("call_token", { @@ -201,6 +279,128 @@ export async function getCallToken(callId: string): Promise { return data.call_token; } +// Encrypt the active call secret for a recipient and send the call invite. +export async function sendCallInvite(userId: number) { + const runtime = requireRuntime(useCall.getState().runtime); + const { callId, callSecret } = useCall.getState(); + + if (!callId || !callSecret) { + throw new Error("Cannot send call invite without an active call."); + } + + const ownUserId = (await runtime.load("user_id")) as number; + const privateKey = await runtime.load("private_key"); + const ownPublicKey = await runtime.getUser(ownUserId).then( + (data) => data.public_key, + ); + const remotePublicKey = await runtime.getUser(userId).then( + (data) => data.public_key, + ); + const sharedSecret = await runtime.getSharedSecret( + privateKey, + ownPublicKey, + remotePublicKey, + ); + const encryptedCallSecret = await runtime.encryptText(sharedSecret, callSecret); + + await runtime.send("call_invite", { + receiver_id: userId, + call_id: callId, + call_secret: encryptedCallSecret, + }); +} + +// Start tracking a participant's shared screen in the call UI. +export function startWatchingStream( + participantId: number, + options?: { focus?: boolean }, +) { + const focus = options?.focus ?? false; + const trackReady = getScreenShareTrackForParticipant(participantId) != null; + + useCall.setState((state) => ({ + watchedStreamParticipantIds: state.watchedStreamParticipantIds.includes( + participantId, + ) + ? state.watchedStreamParticipantIds + : [...state.watchedStreamParticipantIds, participantId], + pendingWatchedParticipantIds: trackReady + ? state.pendingWatchedParticipantIds.filter((id) => id !== participantId) + : state.pendingWatchedParticipantIds.includes(participantId) + ? state.pendingWatchedParticipantIds + : [...state.pendingWatchedParticipantIds, participantId], + focusedParticipantId: focus ? participantId : state.focusedParticipantId, + view: focus ? "focused" : state.view, + })); +} + +// Focus a participant in the main call view even when they are not sharing a screen. +export function focusParticipant(participantId: number) { + useCall.setState({ + focusedParticipantId: participantId, + view: "focused", + }); +} + +// Stop tracking a participant's shared screen and clean up related UI state. +export function stopWatchingStream(participantId: number) { + useCall.setState((state) => ({ + watchedStreamParticipantIds: state.watchedStreamParticipantIds.filter( + (id) => id !== participantId, + ), + pendingWatchedParticipantIds: state.pendingWatchedParticipantIds.filter( + (id) => id !== participantId, + ), + focusedParticipantId: + state.focusedParticipantId === participantId + ? null + : state.focusedParticipantId, + view: + state.view === "focused" && state.focusedParticipantId === participantId + ? "grid" + : state.view, + })); +} + +// Exit focused screen-share mode for the currently highlighted participant. +export function stopWatchingFocusedStream() { + const focusedParticipantId = useCall.getState().focusedParticipantId; + + if (focusedParticipantId == null) { + return; + } + + stopWatchingStream(focusedParticipantId); +} + +let screenShareController: ReturnType | null = + null; + +function getScreenShareController() { + if (!screenShareController) { + screenShareController = createScreenShareController({ + room, + getState: () => ({ + screenShareSession: useCall.getState().screenShareSession, + }), + setState: (updater) => { + useCall.setState((state) => + typeof updater === "function" + ? updater({ screenShareSession: state.screenShareSession }) + : updater, + ); + }, + getLocalParticipantId: () => getParticipantId(room.localParticipant.identity), + startWatching: startWatchingStream, + stopWatching: stopWatchingStream, + syncParticipantState, + }); + } + + return screenShareController; +} + +// Connect to LiveKit, enable the microphone, and move the UI into the live call. export async function connect(callId: string) { const token = await getCallToken(callId); @@ -234,8 +434,13 @@ export async function connect(callId: string) { syncParticipantState(); } -export function disconnect() { - void clearPublishedScreenShare(); +// Tear down the active call session and return the store to a closed state. +export async function disconnect() { + try { + await getScreenShareController().clearPublishedScreenShare(); + } catch (error) { + log(1, "call", "red", "Failed to clear screen share during disconnect", error); + } useCall.setState({ state: "closing", @@ -247,17 +452,27 @@ export function disconnect() { deaf: false, view: "preview", screenShareSession: null, + focusedParticipantId: null, + watchedStreamParticipantIds: [], + pendingWatchedParticipantIds: [], + activeScreenShareParticipantIds: [], }); room.remoteParticipants.forEach((participant) => { - participant.setVolume(100); + participant.setVolume(1); }); - room.disconnect(); - useCall.setState({ state: "closed" }); - syncParticipantState(); + try { + room.disconnect(); + } catch (error) { + log(1, "call", "red", "Failed to disconnect from room", error); + } finally { + useCall.setState({ state: "closed" }); + syncParticipantState(); + } } +// Prepare encryption and join or create a call with another user. export async function joinCall( userId: number, callSecret?: string, @@ -313,11 +528,12 @@ export async function joinCall( } } +// Mute or restore incoming call audio for every remote participant. export function toggleDeaf() { const nextDeaf = !useCall.getState().deaf; room.remoteParticipants.forEach((participant) => { - participant.setVolume(nextDeaf ? 0 : 100); + participant.setVolume(nextDeaf ? 0 : 1); }); if (nextDeaf && room.localParticipant.isMicrophoneEnabled) { @@ -327,6 +543,7 @@ export function toggleDeaf() { useCall.setState({ deaf: nextDeaf }); } +// Toggle the local microphone while keeping deaf/mute state consistent. export async function toggleMute() { const micEnabled = useCall.getState().micEnabled; @@ -338,163 +555,30 @@ export async function toggleMute() { syncParticipantState(); } -async function clearPublishedScreenShare() { - const screenShareSession = useCall.getState().screenShareSession; - - if (!screenShareSession) { - return; - } - - await Promise.all( - screenShareSession.tracks.map((track) => - room.localParticipant.unpublishTrack(track, true).catch((error) => { - log(1, "call", "red", "Failed to unpublish screen share track", error); - }), - ), - ); - - screenShareSession.cleanup?.(); - - useCall.setState({ screenShareSession: null }); -} - -async function publishScreenShareTracks( - tracks: Array, - cleanup?: () => void, -) { - if (tracks.length === 0) { - throw new Error("No screen share tracks were created."); - } - - await Promise.all( - tracks.map((track) => - room.localParticipant.publishTrack(track, { - source: - track.kind === Track.Kind.Video - ? Track.Source.ScreenShare - : Track.Source.ScreenShareAudio, - }), - ), - ); - - for (const track of tracks) { - const mediaStreamTrack = - track instanceof MediaStreamTrack ? track : track.mediaStreamTrack; - - mediaStreamTrack.addEventListener( - "ended", - () => { - void stopScreenShare(); - }, - { once: true }, - ); - } - - useCall.setState({ screenShareSession: { tracks, cleanup } }); - syncParticipantState(); -} - +// Start browser-native screen sharing for the current participant. export async function startScreenShare(options?: ScreenShareCaptureOptions) { - await clearPublishedScreenShare(); - - const tracks = await room.localParticipant.createScreenTracks(options); - - await publishScreenShareTracks( - tracks, - () => tracks.forEach((track) => track.stop()), - ); + await getScreenShareController().startScreenShare(options); } +// Start the Linux desktop capture path that renders frames through Tauri. export async function startLinuxDesktopScreenShare(sourceId: string) { - await clearPublishedScreenShare(); - - const canvas = document.createElement("canvas"); - canvas.width = 1280; - canvas.height = 720; - canvas.style.display = "none"; - document.body.appendChild(canvas); - - const context = canvas.getContext("2d"); - - if (!context) { - canvas.remove(); - throw new Error("Failed to initialize the screen share canvas."); - } - - const stream = canvas.captureStream(8); - const videoTrack = stream.getVideoTracks()[0]; - - if (!videoTrack) { - canvas.remove(); - throw new Error("Failed to create a video track for screen sharing."); - } - - const image = new Image(); - let stopped = false; - let frameRequestInFlight = false; - - const renderFrame = async () => { - if (stopped || frameRequestInFlight) { - return; - } - - frameRequestInFlight = true; - - try { - const dataUrl = await invoke("capture_screen_share_frame", { - sourceId, - }); - - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = () => reject(new Error("Failed to decode screen share frame.")); - image.src = dataUrl; - }); - - if (canvas.width !== image.naturalWidth || canvas.height !== image.naturalHeight) { - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - } - - context.drawImage(image, 0, 0, canvas.width, canvas.height); - } finally { - frameRequestInFlight = false; - } - }; - - await renderFrame(); - - const interval = window.setInterval(() => { - void renderFrame().catch((error) => { - log(1, "call", "red", "Failed to capture Linux screen share frame", error); - }); - }, 125); - - await publishScreenShareTracks([videoTrack], () => { - stopped = true; - window.clearInterval(interval); - stream.getTracks().forEach((track) => track.stop()); - canvas.remove(); - }); + await getScreenShareController().startLinuxDesktopScreenShare(sourceId); } +// Stop the local participant's active screen share and related previews. export async function stopScreenShare() { - await clearPublishedScreenShare(); - syncParticipantState(); + await getScreenShareController().stopScreenShare(); } +// Toggle screen sharing on or off from UI controls. export async function setScreenShareEnabled( enabled: boolean, options?: ScreenShareCaptureOptions, ) { - if (enabled) { - await startScreenShare(options); - return; - } - - await stopScreenShare(); + await getScreenShareController().setScreenShareEnabled(enabled, options); } +// Reset the in-memory call store when leaving the call experience entirely. export function resetCallState() { useCall.setState({ state: "closed", @@ -506,11 +590,16 @@ export function resetCallState() { currentCallData: null, deaf: false, screenShareSession: null, + focusedParticipantId: null, + watchedStreamParticipantIds: [], + pendingWatchedParticipantIds: [], + activeScreenShareParticipantIds: [], }); syncParticipantState(); } +// Attach the deep noise filter to the local microphone track when available. async function ensureNoiseFilter( noiseFilter: DeepFilterNoiseFilterProcessor, ): Promise { @@ -542,6 +631,10 @@ export const useCall = create(() => ({ micEnabled: room.localParticipant.isMicrophoneEnabled, screenShareEnabled: room.localParticipant.isScreenShareEnabled, screenShareSession: null, + focusedParticipantId: null, + watchedStreamParticipantIds: [], + pendingWatchedParticipantIds: [], + activeScreenShareParticipantIds: [], isEncrypted: room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, room, @@ -550,6 +643,7 @@ export const useCall = create(() => ({ runtime: null, })); +// Register app-level call listeners and wire React dependencies into the store. export function useInitializeCall() { const navigate = useNavigate(); const location = useLocation(); @@ -582,10 +676,11 @@ export function useInitializeCall() { send: send as SendFn, getSharedSecret: getSharedSecret as GetSharedSecretFn, decryptText: decryptText as DecryptTextFn, + encryptText: encryptText as EncryptTextFn, load: load as LoadFn, getUser: get as GetUserFn, }); - }, [decryptText, get, getSharedSecret, load, navigate, send]); + }, [decryptText, encryptText, get, getSharedSecret, load, navigate, send]); const showCallingScreen = useCallback( async (callId: string, callSecret: string, senderId: number) => { @@ -659,21 +754,7 @@ export function useInitializeCall() { if (invitedUserId != null) { setTimeout(async () => { - void requireRuntime(useCall.getState().runtime) - .send("call_invite", { - receiver_id: invitedUserId, - call_id: useCall.getState().callId!, - call_secret: await encryptText( - await getSharedSecret( - await load("private_key"), - await get(await load("user_id")).then( - (data) => data.public_key, - ), - await get(invitedUserId).then((data) => data.public_key), - ), - useCall.getState().callSecret!, - ), - }) + void sendCallInvite(invitedUserId) .catch((error) => { toast("error", "Failed to send call invite."); log(1, "call", "red", "Failed to send call invite", error); @@ -696,6 +777,20 @@ export function useInitializeCall() { }); }; + const onParticipantConnected = () => { + syncParticipantState(); + }; + + const onParticipantDisconnected = (participant: Participant) => { + const participantId = getParticipantId(participant.identity); + + if (participantId != null) { + stopWatchingStream(participantId); + } + + syncParticipantState(); + }; + const onMediaDeviceFailure = (error: Error, kind?: MediaDeviceKind) => { log(1, "call", "red", "Media device failure", { error, kind }); toast("error", "Media device failure. See console for details."); @@ -712,20 +807,33 @@ export function useInitializeCall() { }; const onTrackSubscribed = (track: RemoteTrack) => { - if (track.kind !== "audio" || !track.sid) { - return; + if (track.kind === "audio" && track.sid) { + attachRemoteAudio(track.sid, track.attach()); } - attachRemoteAudio(track.sid, track.attach()); + syncParticipantState(); }; - const onTrackUnsubscribed = (track: RemoteTrack) => { - if (track.kind !== "audio" || !track.sid) { - return; + const onTrackUnsubscribed = ( + track: RemoteTrack, + _publication: unknown, + participant: Participant, + ) => { + if (track.kind === "audio" && track.sid) { + track.detach(); + detachRemoteAudio(track.sid); } - track.detach(); - detachRemoteAudio(track.sid); + const participantId = getParticipantId(participant.identity); + + if ( + participantId != null && + participant.getTrackPublication(Track.Source.ScreenShare)?.track == null + ) { + stopWatchingStream(participantId); + } + + syncParticipantState(); }; room.on(RoomEvent.Connected, onConnected); @@ -733,6 +841,8 @@ export function useInitializeCall() { room.on(RoomEvent.Disconnected, onDisconnected); room.on(RoomEvent.TrackSubscribed, onTrackSubscribed); room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); + room.on(RoomEvent.ParticipantConnected, onParticipantConnected); + room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.on(RoomEvent.TrackMuted, onParticipantStateChange); room.on(RoomEvent.TrackUnmuted, onParticipantStateChange); room.on(RoomEvent.LocalTrackPublished, onParticipantStateChange); @@ -750,6 +860,8 @@ export function useInitializeCall() { room.off(RoomEvent.Disconnected, onDisconnected); room.off(RoomEvent.TrackSubscribed, onTrackSubscribed); room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); + room.off(RoomEvent.ParticipantConnected, onParticipantConnected); + room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.off(RoomEvent.TrackMuted, onParticipantStateChange); room.off(RoomEvent.TrackUnmuted, onParticipantStateChange); room.off(RoomEvent.LocalTrackPublished, onParticipantStateChange); @@ -762,7 +874,7 @@ export function useInitializeCall() { room.disconnect(); e2eeWorker.terminate(); }; - }, [noiseFilter, encryptText, get, getSharedSecret, load]); + }, [noiseFilter]); // fetch call data for preview page useEffect(() => { diff --git a/packages/call/src/views/main/focused.tsx b/packages/call/src/views/main/focused.tsx index 18bb0b4..5926690 100644 --- a/packages/call/src/views/main/focused.tsx +++ b/packages/call/src/views/main/focused.tsx @@ -1,3 +1,147 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCall } from "../../store"; +import Base from "../../components/modals/base"; + +const TILE_ASPECT_RATIO = 16 / 9; +const SECONDARY_ROW_HEIGHT_PX = 180; +const STACK_GAP_PX = 12; + export default function View() { - return
Focused
; + const room = useCall((state) => state.room); + + const focusedParticipantId = useCall((state) => state.focusedParticipantId); + const activeScreenShareParticipantIds = useCall( + (state) => state.activeScreenShareParticipantIds, + ); + const containerRef = useRef(null); + const [focusedTileSize, setFocusedTileSize] = useState({ width: 0, height: 0 }); + const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false); + + const users = useMemo(() => { + const participants = [ + ...room.remoteParticipants.values(), + room.localParticipant, + ]; + + return participants.filter((participant) => { + const participantId = Number(participant.identity); + return Number.isInteger(participantId) && participantId > 0; + }); + }, [room]); + + const userIds = useMemo( + () => users.map((participant) => Number(participant.identity)), + [users], + ); + + const tiles = useMemo( + () => [ + ...activeScreenShareParticipantIds + .filter((id) => id !== focusedParticipantId) + .map((participantId) => ({ + key: `stream:${participantId}`, + kind: "stream" as const, + participantId, + })), + ...userIds + .filter((id) => id !== focusedParticipantId) + .filter((id) => !activeScreenShareParticipantIds.includes(id)) + .map((participantId) => ({ + key: `user:${participantId}`, + kind: "user" as const, + participantId, + })), + ], + [activeScreenShareParticipantIds, userIds, focusedParticipantId], + ); + + useLayoutEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const syncTileLayout = () => { + const rect = container.getBoundingClientRect(); + const width = Math.max(0, Math.floor(window.innerWidth + 1 - rect.left)); + const height = Math.max(0, Math.floor(container.clientHeight)); + const reservedHeight = + tiles.length > 0 ? SECONDARY_ROW_HEIGHT_PX + STACK_GAP_PX : 0; + const availableHeight = Math.max(0, height - reservedHeight); + const nextWidth = Math.max( + 0, + Math.min(width, availableHeight * TILE_ASPECT_RATIO), + ); + const nextHeight = Math.max(0, nextWidth / TILE_ASPECT_RATIO); + const widthDelta = Math.abs(width - nextWidth); + + setFocusedTileSize((current) => + current.width === nextWidth && current.height === nextHeight + ? current + : { width: nextWidth, height: nextHeight }, + ); + setIsFocusedTileFlush(widthDelta <= 1); + }; + + syncTileLayout(); + + const observer = new ResizeObserver(() => { + requestAnimationFrame(syncTileLayout); + }); + observer.observe(container); + + window.addEventListener("resize", syncTileLayout); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", syncTileLayout); + }; + }, [tiles.length, focusedParticipantId]); + + if (focusedParticipantId == null) { + return null; + } + + const focusedParticipant = room.getParticipantByIdentity( + String(focusedParticipantId), + ); + + return ( +
+
0 ? STACK_GAP_PX : 0 }} + > +
+
+ +
+
+ {tiles.length > 0 && ( +
+ {tiles.map((tile) => ( +
+ +
+ ))} +
+ )} +
+
+ ); } diff --git a/packages/call/src/views/main/grid.tsx b/packages/call/src/views/main/grid.tsx index a0cab25..882d83a 100644 --- a/packages/call/src/views/main/grid.tsx +++ b/packages/call/src/views/main/grid.tsx @@ -1,6 +1,7 @@ import { RoomEvent } from "livekit-client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useCall } from "../../store"; +import Base from "../../components/modals/base"; const TILE_ASPECT_RATIO = 16 / 9; const GRID_GAP = 12; @@ -82,12 +83,13 @@ function calculateOptimalGridLayout( export default function View() { const room = useCall((state) => state.room); + const activeScreenShareParticipantIds = useCall( + (state) => state.activeScreenShareParticipantIds, + ); const containerRef = useRef(null); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const [participantCount, setParticipantCount] = useState( - () => room.remoteParticipants.size + 1, - ); + const [participantVersion, setParticipantVersion] = useState(0); useEffect(() => { const element = containerRef.current; @@ -107,33 +109,53 @@ export default function View() { }, []); useEffect(() => { - const syncParticipantCount = () => { - setParticipantCount(room.remoteParticipants.size + 1); + const syncParticipants = () => { + setParticipantVersion((version) => version + 1); }; - syncParticipantCount(); + syncParticipants(); - room.on(RoomEvent.Connected, syncParticipantCount); - room.on(RoomEvent.Disconnected, syncParticipantCount); - room.on(RoomEvent.ParticipantConnected, syncParticipantCount); - room.on(RoomEvent.ParticipantDisconnected, syncParticipantCount); + room.on(RoomEvent.Connected, syncParticipants); + room.on(RoomEvent.Disconnected, syncParticipants); + room.on(RoomEvent.ParticipantConnected, syncParticipants); + room.on(RoomEvent.ParticipantDisconnected, syncParticipants); return () => { - room.off(RoomEvent.Connected, syncParticipantCount); - room.off(RoomEvent.Disconnected, syncParticipantCount); - room.off(RoomEvent.ParticipantConnected, syncParticipantCount); - room.off(RoomEvent.ParticipantDisconnected, syncParticipantCount); + room.off(RoomEvent.Connected, syncParticipants); + room.off(RoomEvent.Disconnected, syncParticipants); + room.off(RoomEvent.ParticipantConnected, syncParticipants); + room.off(RoomEvent.ParticipantDisconnected, syncParticipants); }; }, [room]); + const users = useMemo(() => { + const participants = [...room.remoteParticipants.values(), room.localParticipant]; + + return participants.filter((participant) => { + const participantId = Number(participant.identity); + return Number.isInteger(participantId) && participantId > 0; + }); + // eslint-disable-next-line + }, [participantVersion, room]); + + const userIds = useMemo( + () => users.map((participant) => Number(participant.identity)), + [users], + ); + const layout = useMemo( () => calculateOptimalGridLayout( containerSize.width, containerSize.height, - participantCount, + activeScreenShareParticipantIds.length + userIds.length, ), - [containerSize.height, containerSize.width, participantCount], + [ + activeScreenShareParticipantIds.length, + containerSize.height, + containerSize.width, + userIds.length, + ], ); const rows = useMemo(() => { @@ -148,31 +170,57 @@ export default function View() { }); }, [layout.rowCounts]); - const users = useMemo(() => { - const participants = [ - ...room.remoteParticipants.values(), - room.localParticipant, - ]; - return participants; - }, [room.localParticipant, room.remoteParticipants]); + const tiles = useMemo( + () => [ + ...activeScreenShareParticipantIds.map((participantId) => ({ + key: `stream:${participantId}`, + kind: "stream" as const, + participantId, + })), + ...userIds.map((participantId) => ({ + key: `user:${participantId}`, + kind: "user" as const, + participantId, + })), + ], + [activeScreenShareParticipantIds, userIds], + ); + + function getParticipantById(participantId: number) { + if (Number(room.localParticipant.identity) === participantId) { + return room.localParticipant; + } + + return room.getParticipantByIdentity(String(participantId)); + } return ( -
+
{rows.map((row) => (
- {row.participants.map((userIndex) => ( -
- Test {users[userIndex]?.identity} -
- ))} + {row.participants.map((userIndex) => { + const tile = tiles[userIndex]; + + if (!tile) { + return null; + } + + return ( +
+ +
+ ); + })}
))}
diff --git a/packages/call/src/views/main/layout.tsx b/packages/call/src/views/main/layout.tsx index fb563ff..b2370c1 100644 --- a/packages/call/src/views/main/layout.tsx +++ b/packages/call/src/views/main/layout.tsx @@ -4,9 +4,15 @@ import TopBar from "../../components/top"; export default function Layout({ children }: { children: React.ReactNode }) { return (
- -
{children}
- +
+ +
+
+ {children} +
+
+ +
); } diff --git a/packages/call/todo.md b/packages/call/todo.md new file mode 100644 index 0000000..b3df696 --- /dev/null +++ b/packages/call/todo.md @@ -0,0 +1,8 @@ +- Speaking indicator +- Overlay for stream modals +- User modals + - Bg based on avatar + - Avatar in the center +- Stream previews + - Buttons + - Preview image