Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/app/plugins/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,9 @@
"cameraOff": "Camera is off",
"yourVideo": "Your video",
"yourPreview": "Your preview",
"previousPage": "Previous participants",
"nextPage": "Next participants",
"pageRange": "{start}–{end} of {total}",
"observatorMode": "Observer Mode",
"waitingForModeratorToStartSession": "Waiting for moderator to start the session...",
"observeAllFeedsNotice": "You will be able to observe all video feeds without sending your own.",
Expand Down Expand Up @@ -2902,6 +2905,7 @@
"asked": "Asked",
"currentQuestion": "Current question",
"clearQuestion": "Clear question",
"stopShowingQuestion": "Stop showing question",
"guide": "Guide",
"notes": "Notes",
"startTimer": "Start timer",
Expand All @@ -2920,6 +2924,8 @@
"nicknameLabel": "Nickname",
"continueToSession": "Continue to session",
"observerModeHint": "Observer mode — you're following the discussion without taking part.",
"observersWatching": "Observers watching: {count}",
"respondedInTopic": "Has sent a chat message in this topic",
"notAMemberTitle": "You're not part of this session",
"notAMemberHint": "This session is running for a specific group of people, and your account isn't on its list. Check with the facilitator if you think this is a mistake.",
"sessionInfo": "Session",
Expand Down
6 changes: 6 additions & 0 deletions src/app/plugins/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2424,6 +2424,9 @@
"cameraOff": "La camara esta apagada",
"yourVideo": "Tu video",
"yourPreview": "Tu vista previa",
"previousPage": "Participantes anteriores",
"nextPage": "Participantes siguientes",
"pageRange": "{start}–{end} de {total}",
"observatorMode": "Modo observador",
"waitingForModeratorToStartSession": "Esperando a que el moderador inicie la sesion...",
"observeAllFeedsNotice": "Podras observar todas las transmisiones de video sin enviar la tuya.",
Expand Down Expand Up @@ -2810,6 +2813,7 @@
"asked": "Preguntada",
"currentQuestion": "Pregunta actual",
"clearQuestion": "Quitar pregunta",
"stopShowingQuestion": "Dejar de mostrar la pregunta",
"guide": "Guía",
"notes": "Notas",
"startTimer": "Iniciar temporizador",
Expand All @@ -2828,6 +2832,8 @@
"nicknameLabel": "Apodo",
"continueToSession": "Continuar a la sesión",
"observerModeHint": "Modo observador: sigues la discusión sin participar.",
"observersWatching": "Observadores viendo: {count}",
"respondedInTopic": "Ha enviado un mensaje en este tema",
"notAMemberTitle": "No formas parte de esta sesión",
"notAMemberHint": "Esta sesión se está ejecutando para un grupo específico de personas y tu cuenta no está en su lista. Consulta con el facilitador si crees que es un error.",
"sessionInfo": "Sesión",
Expand Down
2 changes: 1 addition & 1 deletion src/ux/FocusGroup/components/session/BreakoutRoomsBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
@click="emit('visit', groupId)"
>
<v-icon
v-if="group.help"
v-if="group.help && visitingGroupId !== groupId"
start
size="16"
class="fg-rooms-bar__help-icon"
Expand Down
2 changes: 1 addition & 1 deletion src/ux/FocusGroup/components/session/ParticipantList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
v-if="respondedIds.includes(entry.id)"
color="success"
size="16"
:title="t('focusGroup.session.discussion')"
:title="t('focusGroup.session.respondedInTopic')"
>
mdi-message-text
</v-icon>
Expand Down
176 changes: 165 additions & 11 deletions src/ux/FocusGroup/components/session/SessionVideoStage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
{{ connectionError }}
</v-alert>

<div v-if="observerCount" class="fg-observer-indicator">
<v-icon size="16">mdi-eye-outline</v-icon>
<span>{{ t('focusGroup.session.observersWatching', { count: observerCount }) }}</span>
</div>

<div class="video-stage">
<!-- Spotlight: focused participant or shared screen (click to release) -->
<div v-if="isFocusMode" class="spotlight-primary">
Expand Down Expand Up @@ -59,7 +64,7 @@
:style="gridStyleVars"
>
<div
v-for="tile in isFocusMode ? otherTiles : tiles"
v-for="tile in visibleGridTiles"
:key="tile.id"
class="video-wrapper tile-clickable"
@click="focusTile(tile.id)"
Expand Down Expand Up @@ -105,13 +110,48 @@
<span>{{ t('videoCall.session.waitingForParticipants') }}</span>
</div>
</div>

<div
v-if="pageCount > 1"
class="fg-video-pagination"
style="position: absolute; right: 20px; bottom: 20px; z-index: 1000; isolation: isolate; color: #052b47; background: #fff; opacity: 1; filter: none;"
>
<span>
{{
t('videoCall.session.pageRange', {
start: pageStart + 1,
end: pageEnd,
total: orderedTiles.length,
})
}}
</span>
<v-btn
icon="mdi-chevron-left"
size="x-small"
variant="outlined"
style="color: #052b47; background-color: #e6f0f8;"
:aria-label="t('videoCall.session.previousPage')"
:disabled="page === 0"
@click="page -= 1"
/>
<v-btn
icon="mdi-chevron-right"
size="x-small"
variant="outlined"
style="color: #052b47; background-color: #e6f0f8;"
:aria-label="t('videoCall.session.nextPage')"
:disabled="page >= pageCount - 1"
@click="page += 1"
/>
</div>
</div>
</div>
</template>

<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isObserverAccessLevel } from '@/shared/utils/accessLevel'
import { useVideoFocus } from '@/shared/components/videoCall/composables/useVideoFocus'

const { t } = useI18n()
Expand All @@ -124,12 +164,25 @@
// FG presence map (identity -> { role }), used for tile role labels because
// the facilitator is the testAdmin and so is absent from cooperators.
presenceRoles: { type: Object, default: () => ({}) },
recentSpeakerIds: { type: Array, default: () => [] },
pageSize: { type: Number, default: 4 },
setLocalVideo: { type: Function, required: true },
setRemoteVideo: { type: Function, required: true },
setScreenVideo: { type: Function, required: true },
})

const roleFor = (identity) => props.presenceRoles?.[identity]?.role ?? ''
const isObserver = (identity) =>
isObserverAccessLevel(props.presenceRoles?.[identity]?.accessLevel)

const observerCount = computed(
() =>
Object.values(props.presenceRoles).filter(
(presence) =>
presence?.connected === true &&
isObserverAccessLevel(presence?.accessLevel),
).length,
)

// Unified tile list: local camera, remote cameras, then screen shares.
// Mirrors the moderated VideoCallLiveKit tile model.
Expand All @@ -141,6 +194,7 @@
id: 'local-camera',
type: 'camera',
kind: 'local',
identity: props.localState.identity,
label: `${t('videoCall.session.yourVideo')} (${props.localState.name})`,
hasCamera: props.localState.isCameraEnabled,
hasMicrophone: props.localState.isMicrophoneEnabled,
Expand All @@ -149,6 +203,10 @@
}

props.remoteParticipants.forEach((participant) => {
// Observers are present and visible in the roster, but have no camera or
// microphone controls. Showing a permanently muted/off video tile is
// confusing, so represent them with the observer indicator above instead.
if (isObserver(participant.identity)) return
const role = roleFor(participant.identity)
list.push({
id: `camera:${participant.identity}`,
Expand All @@ -172,30 +230,68 @@
})
})

return list
const speakerOrder = new Map(
props.recentSpeakerIds.map((identity, index) => [identity, index]),
)
return list.sort((a, b) => {
if (a.type === 'screen' || b.type === 'screen') {
return a.type === b.type ? 0 : a.type === 'screen' ? -1 : 1

Check warning on line 238 in src/ux/FocusGroup/components/session/SessionVideoStage.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ruxailab_RUXAILAB&issues=AaDth2AauB_5UwR0j4Yc&open=AaDth2AauB_5UwR0j4Yc&pullRequest=2398
}
return (
(speakerOrder.get(a.identity) ?? Number.MAX_SAFE_INTEGER) -
(speakerOrder.get(b.identity) ?? Number.MAX_SAFE_INTEGER)
)
})
})

// Spotlight/filmstrip behaviour, shared with the moderated call: click a tile to
// enlarge it; a new screen share auto-grabs the spotlight (stimulus presentation).
const { focusedTile, otherTiles, isFocusMode, focusTile, clearFocus } =
useVideoFocus(tiles)
const { focusedTile, isFocusMode, focusTile, clearFocus } = useVideoFocus(tiles)

// Four camera tiles at a time keeps each person legible. Recent speakers are
// ordered first, and the arrows let the group view everyone else.
const page = ref(0)
const pageSize = computed(() => Math.max(1, props.pageSize))
const orderedTiles = computed(() => tiles.value)
const pageCount = computed(() =>
Math.ceil(orderedTiles.value.length / pageSize.value),
)
const pageStart = computed(() => page.value * pageSize.value)
const pageEnd = computed(() =>
Math.min(pageStart.value + pageSize.value, orderedTiles.value.length),
)
const pageTiles = computed(() =>
orderedTiles.value.slice(pageStart.value, pageEnd.value),
)
const visibleGridTiles = computed(() =>
isFocusMode.value
? pageTiles.value.filter((tile) => tile.id !== focusedTile.value?.id)
: pageTiles.value,
)

watch(pageCount, (count) => {
if (page.value >= count) page.value = Math.max(0, count - 1)
})
watch(
() => props.screenShareFeeds.length,
() => {
page.value = 0
},
)

const showWaiting = computed(
() =>
!isFocusMode.value &&
props.remoteParticipants.length === 0 &&
tiles.value.filter((tile) => tile.type === 'camera').length === 0 &&
props.screenShareFeeds.length === 0,
)

// Grid columns scale with the number of camera tiles (local + remotes).
const cameraCount = computed(
() =>
(props.localState.isObservator ? 0 : 1) + props.remoteParticipants.length,
() => visibleGridTiles.value.filter((tile) => tile.type === 'camera').length,
)

const gridStyleVars = computed(() => {
const count = cameraCount.value
const cols = count <= 1 ? 1 : count <= 4 ? 2 : count <= 9 ? 3 : 4
const cols = count <= 1 ? 1 : 2
return { '--grid-cols': cols }
})

Expand All @@ -221,6 +317,7 @@
of participants) rather than being capped to a fixed height. -->
<style scoped>
.fg-video-stage {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
Expand Down Expand Up @@ -252,6 +349,63 @@
align-items: stretch;
}

.fg-video-stage .videos-grid {
gap: 12px;
padding: 12px !important;
}

.fg-observer-indicator {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
align-self: flex-start;
padding: 5px 10px;
margin: 8px 12px 0;
border-radius: 999px;
color: rgba(var(--v-theme-on-surface), 0.72);
background: rgba(var(--v-theme-on-surface), 0.06);
font-size: 0.78rem;
}

.fg-video-pagination {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 1000;
isolation: isolate;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px 4px 12px;
border: 1px solid rgba(5, 43, 71, 0.2);
border-radius: 999px;
color: #052b47 !important;
background: #fff !important;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.3);
font-size: 0.78rem;
}

.fg-video-pagination :deep(.v-btn) {
color: #052b47 !important;
background: #e6f0f8 !important;
}

.fg-video-pagination :deep(.v-btn:disabled) {
color: #6c7f8f !important;
background: #f1f4f6 !important;

Check warning on line 396 in src/ux/FocusGroup/components/session/SessionVideoStage.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Text does not meet the minimal contrast requirement with its background.

See more on https://sonarcloud.io/project/issues?id=ruxailab_RUXAILAB&issues=AaDth2AauB_5UwR0j4Yd&open=AaDth2AauB_5UwR0j4Yd&pullRequest=2398
opacity: 1;
}

.fg-video-pagination :deep(.v-btn .v-icon) {
color: #052b47 !important;
}

.fg-video-stage :deep(.videos-grid .video-container) {
border-radius: 12px;
overflow: hidden;
}

.fg-video-stage .videos-grid:not(.videos-single) .video-container {
width: 100%;
height: 100%;
Expand Down
11 changes: 10 additions & 1 deletion src/ux/FocusGroup/composables/useFocusGroupSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,17 @@ export function useFocusGroupSession(roomId) {
role: role ?? '',
accessLevel: accessLevel ?? null,
connected: true,
breakoutGroupId: null,
joinedAt: serverTimestamp(),
})
onDisconnect(presenceRef).update({ connected: false })
onDisconnect(presenceRef).update({ connected: false, breakoutGroupId: null })
}

/** Share which breakout room a staff member is currently visiting. */
async function setPresenceBreakoutGroup({ userId, groupId }) {
if (!userId) return
const presenceRef = dbRef(database, `${rootPath}/participants/${userId}`)
await update(presenceRef, { breakoutGroupId: groupId ?? null })
}

async function leavePresence(userId) {
Expand Down Expand Up @@ -420,6 +428,7 @@ export function useFocusGroupSession(roomId) {
goToTopic,
endSession,
joinPresence,
setPresenceBreakoutGroup,
leavePresence,
recordConsent,
askPrompt,
Expand Down
10 changes: 8 additions & 2 deletions src/ux/FocusGroup/composables/useSpeakingTime.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ import { applyActiveSpeakersChange } from '@/ux/FocusGroup/utils/speakingTime'
*/
export function useSpeakingTime(roomRef) {
const speakingMs = ref({})
const recentSpeakerIds = ref([])
let activeSince = {}
let attachedRoom = null

function handleActiveSpeakersChanged(speakers) {
const activeIds = speakers.map((participant) => participant.identity)
recentSpeakerIds.value = [
...activeIds,
...recentSpeakerIds.value.filter((id) => !activeIds.includes(id)),
]
const result = applyActiveSpeakersChange({
accumulatedMs: speakingMs.value,
activeSince,
speakingIdentities: speakers.map((participant) => participant.identity),
speakingIdentities: activeIds,
now: Date.now(),
})
speakingMs.value = result.accumulatedMs
Expand Down Expand Up @@ -52,5 +58,5 @@ export function useSpeakingTime(roomRef) {

onBeforeUnmount(detach)

return { speakingMs }
return { speakingMs, recentSpeakerIds }
}
Loading
Loading