Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bf31c0f
feat(sfu): SFU integration for Flux calls
HexaField Mar 5, 2026
92c3832
fix(sfu): address code review feedback
HexaField Mar 5, 2026
9b2c9e0
feat(sfu): add cascaded multi-node SFU support in UI
HexaField Mar 6, 2026
cf6a730
feat(sfu): wire SFU components into call UI and settings
HexaField Mar 6, 2026
27e0d95
feat(sfu): implement redirect handling, stream mapping, and quality p…
HexaField Mar 6, 2026
999b360
fix(sfu): complete store integration and stream mapping
HexaField Mar 6, 2026
5ea7b31
test(sfu): add SfuManager unit tests
HexaField Mar 6, 2026
b22b76d
test(sfu): add SfuManager unit tests
HexaField Mar 6, 2026
1b83f3e
fix(sfu): call destroySfuManager on leaveRoom to properly clean up SF…
HexaField Mar 10, 2026
cb5dea0
feat(webrtc): handle server-initiated SDP renegotiation in SfuManager
HexaField Mar 10, 2026
0bc2934
fix: hoist neighbourhoodUrl variable to outer scope in joinRoom
HexaField Mar 10, 2026
d7da0f1
fix(ui): add Settings menu item to community sidebar dropdown
HexaField Mar 11, 2026
977adc9
fix: resolve config scoping bug preventing SFU path from executing
HexaField Mar 11, 2026
6a5d60a
test: add regression tests for SFU config scoping and topology mappin…
HexaField Mar 11, 2026
d603c79
fix(sfu): remove communityService.neighbourhood guard from SFU config…
HexaField Mar 11, 2026
f2b4afe
fix(sfu): guard against duplicate joinRoom calls
HexaField Mar 11, 2026
334f0a4
M1: Fetch TURN credentials from SFU config instead of hardcoding
HexaField Mar 12, 2026
08e5c88
fix(webrtc): default sfuMode to 'mesh' instead of 'gateway'
HexaField Mar 23, 2026
465b4da
fix(webrtc): replace 'any' types with proper types in SFU code
HexaField Mar 23, 2026
3a6643c
feat(webrtc): notify user on SFU fallback to mesh
HexaField Mar 23, 2026
3335970
feat(webrtc): mid-call topology switching with config polling
HexaField Mar 25, 2026
8e22db0
fix(webrtc): add explicit type for renegotiation event parameter
HexaField Mar 25, 2026
5ef8e99
feat(e2e): add data-testid attributes for E2E testing
HexaField Mar 25, 2026
f76d3cc
fix: resolve merge conflict in webrtcStore.ts
HexaField Apr 9, 2026
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
19 changes: 17 additions & 2 deletions app/src/components/call/composables/useVideoLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type MediaState,
} from '@/stores';
import { storeToRefs } from 'pinia';
import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';

export function useVideoLayout() {
const appStore = useAppStore();
Expand Down Expand Up @@ -81,10 +81,25 @@ export function useVideoLayout() {
return 2;
else if (userCount > 4 && userCount < 10) return 3;
else if (userCount > 8 && userCount < 17) return 4;
return 5;
else if (userCount > 16 && userCount < 26) return 5;
return 6;
});

// Auto-switch to Focused (speaker view) when >8 participants,
// but only if the user hasn't manually selected a layout this session.
const userSelectedLayout = ref(false);

watch(
() => peers.value.length,
(peerCount) => {
if (!userSelectedLayout.value && peerCount + 1 > 8 && selectedVideoLayout.value.label !== 'Focused') {
uiStore.setVideoLayout(videoLayoutOptions[2]);
}
}
);

function selectVideoLayout(layout: VideoLayoutOption) {
userSelectedLayout.value = true;
uiStore.setVideoLayout(layout);
}

Expand Down
1 change: 1 addition & 0 deletions app/src/components/call/controls/JoinCallControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</j-toggle>

<j-button
data-testid="join-call"
variant="primary"
size="lg"
:disabled="audioDisabled"
Expand Down
12 changes: 12 additions & 0 deletions app/src/components/call/controls/MainCallControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div class="call-controls" :class="{ mobile: isMobile, 'landscape-mobile': isLandscapeMobile }">
<j-tooltip placement="top" :title="mediaSettings.audioEnabled ? 'Mute microphone' : 'Unmute microphone'">
<j-button
data-testid="toggle-audio"
:variant="mediaSettings.audioEnabled ? '' : 'primary'"
@click="mediaDeviceStore.toggleAudio"
square
Expand All @@ -14,6 +15,7 @@

<j-tooltip placement="top" :title="mediaSettings.videoEnabled ? 'Disable camera' : 'Enable camera'">
<j-button
data-testid="toggle-video"
:variant="mediaSettings.videoEnabled ? '' : 'primary'"
@click="mediaDeviceStore.toggleVideo"
square
Expand All @@ -30,6 +32,7 @@

<j-tooltip placement="top" :title="mediaSettings.screenShareEnabled ? 'Stop sharing' : 'Share screen'">
<j-button
data-testid="screen-share"
:variant="mediaSettings.screenShareEnabled ? 'primary' : ''"
@click="mediaDeviceStore.toggleScreenShare"
square
Expand All @@ -41,6 +44,8 @@
</j-button>
</j-tooltip>

<QualitySelector v-if="!isMobile" :showSelector="inCall" @quality-change="onQualityChange" />

<j-tooltip v-if="!isMobile" placement="top" :title="`${transcriptionEnabled ? 'Disable' : 'Enable'} transcription`">
<j-button
:variant="transcriptionEnabled ? '' : 'primary'"
Expand Down Expand Up @@ -117,6 +122,7 @@

<j-tooltip placement="top" title="Leave call">
<j-button
data-testid="leave-call"
variant="danger"
@click="webrtcStore.leaveRoom"
square
Expand All @@ -132,6 +138,7 @@

<script setup lang="ts">
import TranscriptionIcon from '@/components/icons/TranscriptionIcon.vue';
import QualitySelector from './QualitySelector.vue';
import {
useAiStore,
useAppStore,
Expand Down Expand Up @@ -173,6 +180,11 @@ function onEmojiClick(e: CustomEvent<{ native?: string }>) {
webrtcStore.displayEmoji(emoji, did);
emojiPopover.value?.removeAttribute('open');
}

function onQualityChange(quality: import('@coasys/flux-webrtc').QualityPreference) {
// Will call sfuManager.setQualityPreference() when SFU manager is active
console.log('Quality preference changed:', quality);
}
</script>

<style scoped lang="scss">
Expand Down
81 changes: 81 additions & 0 deletions app/src/components/call/controls/QualitySelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div class="quality-selector" v-if="showSelector" ref="rootRef">
<button class="quality-button" @click="isOpen = !isOpen" :title="`Video quality: ${selectedLabel}`">
<span>{{ qualityIcon }}</span>
<span class="quality-label">{{ selectedLabel }}</span>
</button>
<div v-if="isOpen" class="quality-dropdown">
<button
v-for="option in options"
:key="option.value"
class="quality-option"
:class="{ active: selected === option.value }"
@click="selectQuality(option.value)"
>
<span>{{ option.icon }}</span>
<span>{{ option.label }}</span>
<span class="option-desc">{{ option.description }}</span>
</button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { QualityPreference } from '@coasys/flux-webrtc';

defineProps<{ showSelector: boolean }>();
const emit = defineEmits<{ (e: 'quality-change', value: QualityPreference): void }>();

const selected = ref<QualityPreference>('auto');
const isOpen = ref(false);
const rootRef = ref<HTMLElement | null>(null);

const options = [
{ value: 'auto' as const, label: 'Auto', icon: '🔄', description: 'Adapts to bandwidth' },
{ value: 'high' as const, label: 'High', icon: '🟢', description: '720p' },
{ value: 'medium' as const, label: 'Medium', icon: '🟡', description: '360p' },
{ value: 'low' as const, label: 'Low', icon: '🔴', description: '180p' },
];

const selectedLabel = computed(() => options.find(o => o.value === selected.value)?.label ?? 'Auto');
const qualityIcon = computed(() => options.find(o => o.value === selected.value)?.icon ?? '🔄');

function selectQuality(quality: QualityPreference) {
selected.value = quality;
isOpen.value = false;
emit('quality-change', quality);
}

function handleClickOutside(event: MouseEvent) {
if (rootRef.value && !rootRef.value.contains(event.target as Node)) {
isOpen.value = false;
}
}

onMounted(() => document.addEventListener('click', handleClickOutside));
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
</script>

<style scoped>
.quality-selector { position: relative; }
.quality-button {
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
border: 1px solid rgba(255,255,255,0.2); border-radius: 8px;
background: rgba(0,0,0,0.3); color: white; cursor: pointer; font-size: 12px;
}
.quality-button:hover { background: rgba(0,0,0,0.5); }
.quality-dropdown {
position: absolute; bottom: calc(100% + 4px); right: 0;
display: flex; flex-direction: column;
background: rgba(30,30,30,0.95); border-radius: 8px;
overflow: hidden; min-width: 180px; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.quality-option {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
border: none; background: transparent; color: white; cursor: pointer; font-size: 12px;
}
.quality-option:hover { background: rgba(255,255,255,0.1); }
.quality-option.active { background: rgba(99,102,241,0.2); }
.option-desc { margin-left: auto; opacity: 0.5; font-size: 11px; }
</style>
77 changes: 77 additions & 0 deletions app/src/components/call/widgets/SfuIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<template>
<div
data-testid="call-indicator"
class="sfu-indicator"
:class="{
'sfu-active': topology === 'sfu' && !switching,
'mesh-active': topology === 'mesh' && !switching,
'cascaded-active': topology === 'cascaded' && !switching,
'switching': switching
}"
:title="tooltipText"
>
<span class="indicator-icon">{{ icon }}</span>
<span class="indicator-label" data-testid="sfu-mode-text">{{ label }}</span>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { SfuTopology } from '@coasys/flux-webrtc';

const props = defineProps<{
topology: SfuTopology;
sfuPeerDid?: string | null;
participantCount: number;
cascadeNodeCount?: number;
connectedNodeDid?: string | null;
switching?: boolean;
switchMessage?: string | null;
}>();

const icon = computed(() => {
if (props.switching) return '🔄';
if (props.topology === 'cascaded') return '📡';
if (props.topology === 'sfu') return '📡';
return '🕸️';
});

const label = computed(() => {
if (props.switching) return props.switchMessage || 'Switching...';
if (props.topology === 'cascaded') return `Cascaded SFU (${props.cascadeNodeCount ?? 0} nodes)`;
if (props.topology === 'sfu') return 'SFU';
return 'Mesh';
});

const tooltipText = computed(() => {
if (props.topology === 'cascaded') {
const node = props.connectedNodeDid ? `Connected to: ${props.connectedNodeDid.slice(0, 16)}...` : 'Cascaded SFU';
return `${node} · ${props.cascadeNodeCount ?? 0} SFU nodes · ${props.participantCount} participants`;
}
if (props.topology === 'sfu') {
const peer = props.sfuPeerDid ? `SFU peer: ${props.sfuPeerDid.slice(0, 16)}...` : 'SFU relay active';
return `${peer} · ${props.participantCount} participants`;
}
return `Direct mesh · ${props.participantCount} participants`;
});
</script>

<style scoped>
.sfu-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
user-select: none;
}
.sfu-active { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.mesh-active { background: rgba(139, 92, 246, 0.15); color: #8b5cf6; }
.cascaded-active { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.switching { background: rgba(59, 130, 246, 0.15); color: #3b82f6; animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.indicator-icon { font-size: 12px; }
.indicator-label { text-transform: uppercase; letter-spacing: 0.5px; }
</style>
Loading
Loading