Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 122 additions & 31 deletions app/src/components/call/composables/useVideoLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,32 @@ import {
import { storeToRefs } from 'pinia';
import { computed, watch } from 'vue';

export type Participant = {
isMe: boolean;
did: string;
inCall: boolean;
stream: MediaStream | undefined;
streamReady: boolean;
audioState: MediaState;
videoState: MediaState;
screenShareState: MediaState;
warning: MediaPlayerWarning;
// Distinguishes a camera tile from a screenshare tile so a single user
// can be rendered as two participants when they're sharing their screen
// alongside their webcam.
streamKind: 'camera' | 'screenshare';
};

export function useVideoLayout() {
const appStore = useAppStore();
const mediaDeviceStore = useMediaDevicesStore();
const uiStore = useUiStore();
const webrtcStore = useWebrtcStore();

const { me } = storeToRefs(appStore);
const { stream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId } = storeToRefs(uiStore);
const { stream, screenShareStream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId, selfViewVisible } =
storeToRefs(uiStore);
const { inCall, peerConnections, disconnectedAgents } = storeToRefs(webrtcStore);

const videoLayoutOptions: VideoLayoutOption[] = [
Expand All @@ -37,41 +54,112 @@ export function useVideoLayout() {
if (microphone && microphone.requested && !microphone.granted) warning = 'mic-disabled';
else if (videoEnabled && camera && camera.requested && !camera.granted) warning = 'camera-disabled';

const myAgent = {
isMe: true,
did: me.value.did,
inCall: inCall.value,
stream: stream.value || undefined,
streamReady: true,
audioState: (audioEnabled ? 'on' : 'off') as MediaState,
videoState: (videoEnabled ? 'on' : 'off') as MediaState,
screenShareState: (screenShareEnabled ? 'on' : 'off') as MediaState,
warning,
};

const otherAgents = peers.value.map((peer) => ({
isMe: false,
did: peer.did,
inCall: true,
stream: peer.streams?.[0] || undefined,
streamReady: peer.streamReady,
audioState: peer.audioState,
videoState: peer.videoState,
screenShareState: peer.screenShareState,
warning: '' as MediaPlayerWarning,
}));

return [myAgent, ...otherAgents];
const myParticipants: Participant[] = [];

// Local camera tile. Hidden when the user toggles self-view off so they
// can focus on the other participants while still being in the call.
if (selfViewVisible.value) {
myParticipants.push({
isMe: true,
did: me.value.did,
inCall: inCall.value,
stream: stream.value || undefined,
streamReady: true,
audioState: (audioEnabled ? 'on' : 'off') as MediaState,
videoState: (videoEnabled ? 'on' : 'off') as MediaState,
// Camera tile never shows the screenshare badge — when both are on
// we emit a separate screenshare tile below.
screenShareState: 'off' as MediaState,
warning,
streamKind: 'camera',
});
}

// Local screenshare tile. Emitted as its own participant so the camera
// and screenshare can be rendered side-by-side instead of one replacing
// the other. Always shown to the local user (even when self-view is
// off) because hiding your own screenshare would make it impossible to
// verify what you're broadcasting.
if (screenShareEnabled && screenShareStream.value) {
myParticipants.push({
isMe: true,
did: me.value.did,
inCall: inCall.value,
stream: screenShareStream.value,
streamReady: true,
audioState: 'off' as MediaState,
videoState: 'off' as MediaState,
screenShareState: 'on' as MediaState,
warning: '' as MediaPlayerWarning,
streamKind: 'screenshare',
});
}

const otherAgents: Participant[] = peers.value.flatMap((peer) => {
const streams = peer.streams ?? [];

// Treat a stream as a screenshare when it carries video without audio.
// The camera stream always has at least one audio track (mic), so this
// distinguishes the two without needing extra signalling. Falls back
// to the legacy single-stream rendering when only one stream exists.
const cameraStream = streams.find((s) => s.getAudioTracks().length > 0) ?? streams[0];
const screenShareStreams = streams.filter(
(s) => s !== cameraStream && s.getVideoTracks().length > 0,
);

const cameraEntry: Participant = {
isMe: false,
did: peer.did,
inCall: true,
stream: cameraStream || undefined,
streamReady: peer.streamReady,
audioState: peer.audioState,
videoState: peer.videoState,
screenShareState: screenShareStreams.length > 0 ? 'off' : peer.screenShareState,
warning: '' as MediaPlayerWarning,
streamKind: 'camera',
};

const screenShareEntries: Participant[] = screenShareStreams.map((screenStream) => ({
isMe: false,
did: peer.did,
inCall: true,
stream: screenStream,
streamReady: peer.streamReady,
audioState: 'off' as MediaState,
videoState: 'off' as MediaState,
screenShareState: 'on' as MediaState,
warning: '' as MediaPlayerWarning,
streamKind: 'screenshare',
}));

return [cameraEntry, ...screenShareEntries];
});

return [...myParticipants, ...otherAgents];
});

// Participants may now share a `did` (a camera tile + a screenshare tile
// for the same user), so the focus key is the composite `did:streamKind`.
// Plain DIDs from older state still match — they fall through to the
// first tile we find for that user, which is the camera tile.
function participantKey(p: Participant): string {
return `${p.did}:${p.streamKind}`;
}

function matchesFocus(p: Participant, focusedId: string): boolean {
if (!focusedId) return false;
return participantKey(p) === focusedId || p.did === focusedId;
}

const focusedParticipant = computed(() => {
const focusedId = focusedVideoId.value || me.value.did;
return allParticipants.value.find((p) => p.did === focusedId) || allParticipants.value[0];
return allParticipants.value.find((p) => matchesFocus(p, focusedId)) || allParticipants.value[0];
});

const unfocusedParticipants = computed(() => {
const focusedId = focusedVideoId.value || me.value.did;
return allParticipants.value.filter((p) => p.did !== focusedId);
return allParticipants.value.filter((p) => !matchesFocus(p, focusedId));
});

const numberOfColumns = computed(() => {
Expand All @@ -88,9 +176,11 @@ export function useVideoLayout() {
uiStore.setVideoLayout(layout);
}

function focusOnVideo(did: string) {
function focusOnVideo(key: string) {
if (!inCall.value) return;
uiStore.setFocusedVideoId(did);
// `key` is either a participant key (`did:streamKind`) or a bare DID
// from older callers; both are honoured by `matchesFocus` above.
uiStore.setFocusedVideoId(key);
if (selectedVideoLayout.value.label !== 'Focused') {
uiStore.setVideoLayout(videoLayoutOptions[2]);
}
Expand Down Expand Up @@ -133,5 +223,6 @@ export function useVideoLayout() {
selectVideoLayout,
focusOnVideo,
closeFocusedVideoLayout,
participantKey,
};
}
18 changes: 17 additions & 1 deletion app/src/components/call/controls/MainCallControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@
</j-button>
</j-tooltip>

<j-tooltip
v-if="!isMobile && inCall"
placement="top"
:title="selfViewVisible ? 'Hide my video' : 'Show my video'"
>
<j-button
:variant="selfViewVisible ? '' : 'primary'"
@click="uiStore.toggleSelfViewVisible"
square
circle
:size="isMobile ? 'md' : 'lg'"
>
<j-icon :name="selfViewVisible ? 'eye-slash' : 'eye'" :size="isMobile ? 'sm' : 'md'" />
</j-button>
</j-tooltip>

<j-popover v-if="!isMobile" ref="videoLayoutPopover" placement="top">
<j-tooltip slot="trigger" placement="top" title="Video layout options">
<j-button variant="transparent" square circle :disabled="!inCall" :size="isMobile ? 'md' : 'lg'">
Expand Down Expand Up @@ -153,7 +169,7 @@ const modalStore = useModalStore();
const aiStore = useAiStore();

const { me } = storeToRefs(appStore);
const { callWindowFullscreen, isMobile, isLandscapeMobile } = storeToRefs(uiStore);
const { callWindowFullscreen, isMobile, isLandscapeMobile, selfViewVisible } = storeToRefs(uiStore);
const { mediaSettings, availableDevices } = storeToRefs(mediaDeviceStore);
const { transcriptionEnabled } = storeToRefs(aiStore);
const { inCall, hasCopiedLink } = storeToRefs(webrtcStore);
Expand Down
Loading
Loading