|
| 1 | +/** |
| 2 | + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 3 | + * SPDX-License-Identifier: AGPL-3.0-or-later |
| 4 | + */ |
| 5 | + |
| 6 | +/** |
| 7 | + * Development helper: fakes a conversation between six participants so the |
| 8 | + * multi-speaker layout can be exercised without a room full of people. |
| 9 | + * |
| 10 | + * NOT MEANT TO BE MERGED. Delete this file and its call in `CallView.vue` |
| 11 | + * before opening a pull request. |
| 12 | + * |
| 13 | + * Automatically starts when joining the call of `SIMULATION_TOKEN`, and can be |
| 14 | + * driven by hand from the console: |
| 15 | + * |
| 16 | + * OCA.Talk.speakerSimulation.start() // (re)start the timeline |
| 17 | + * OCA.Talk.speakerSimulation.start(4) // 4x faster |
| 18 | + * OCA.Talk.speakerSimulation.stop() |
| 19 | + * OCA.Talk.speakerSimulation.speak(2, 5) // make participant 2 speak 5s |
| 20 | + */ |
| 21 | + |
| 22 | +import { reactive } from 'vue' |
| 23 | +import { callParticipantCollection } from '../../utils/webrtc/index.js' |
| 24 | +import { ConnectionState } from '../../utils/webrtc/models/CallParticipantModel.js' |
| 25 | +import { placeholderName } from './Grid/gridPlaceholders.ts' |
| 26 | + |
| 27 | +/** Conversation the simulation starts in on its own. */ |
| 28 | +export const SIMULATION_TOKEN = 'mmhc6xg4' |
| 29 | + |
| 30 | +const PARTICIPANT_COUNT = 6 |
| 31 | + |
| 32 | +/** |
| 33 | + * One turn of speech: who speaks, when they start (in seconds from the |
| 34 | + * beginning of the timeline) and for how long. |
| 35 | + * |
| 36 | + * The timeline is built to exercise every rule of the layout, and loops. |
| 37 | + */ |
| 38 | +const TIMELINE: { speaker: number, at: number, duration: number }[] = [ |
| 39 | + // A holds the floor: promoted after 3s, alone in the main area |
| 40 | + { speaker: 0, at: 0, duration: 20 }, |
| 41 | + // B asks a question: the main area splits in two |
| 42 | + { speaker: 1, at: 6, duration: 6 }, |
| 43 | + // Too short to count, the layout must not move |
| 44 | + { speaker: 2, at: 14, duration: 2 }, |
| 45 | + // C answers properly: three tiles, the last one centered |
| 46 | + { speaker: 2, at: 18, duration: 8 }, |
| 47 | + // D joins in: the main area is now full |
| 48 | + { speaker: 3, at: 24, duration: 10 }, |
| 49 | + // E speaks while all four spots are taken: queued, not promoted |
| 50 | + { speaker: 4, at: 38, duration: 8 }, |
| 51 | + // F too, behind E in the queue |
| 52 | + { speaker: 5, at: 48, duration: 6 }, |
| 53 | + // Nobody says anything for a while, so the spots are given up one by one: |
| 54 | + // B was silent from 12s and is demoted at 72s, which lets E in, then A |
| 55 | + // (silent from 20s) is demoted at 80s and lets F in |
| 56 | + { speaker: 1, at: 100, duration: 6 }, |
| 57 | + { speaker: 4, at: 108, duration: 10 }, |
| 58 | +] |
| 59 | + |
| 60 | +/** Length of a full run, in seconds. */ |
| 61 | +const TIMELINE_DURATION = 130 |
| 62 | + |
| 63 | +type SimulatedModel = ReturnType<typeof createModel> |
| 64 | + |
| 65 | +/** |
| 66 | + * Build an object with the surface of a `CallParticipantModel` that the call |
| 67 | + * view actually reads. No peer connection is involved, so the tiles show the |
| 68 | + * participant name over an avatar rather than a video. |
| 69 | + * |
| 70 | + * @param index - position of the participant in the simulation |
| 71 | + */ |
| 72 | +function createModel(index: number) { |
| 73 | + return { |
| 74 | + attributes: reactive({ |
| 75 | + peerId: `simulated-peer-${index}`, |
| 76 | + nextcloudSessionId: `simulated-session-${index}`, |
| 77 | + peer: null, |
| 78 | + screenPeer: null, |
| 79 | + actorType: 'users', |
| 80 | + actorId: `simulated-user-${index}`, |
| 81 | + userId: `simulated-user-${index}`, |
| 82 | + name: placeholderName(index), |
| 83 | + internal: false, |
| 84 | + connectionState: ConnectionState.CONNECTED, |
| 85 | + negotiating: false, |
| 86 | + connecting: false, |
| 87 | + initialConnection: false, |
| 88 | + connectedAtLeastOnce: true, |
| 89 | + stream: null, |
| 90 | + audioAvailable: true, |
| 91 | + speaking: false, |
| 92 | + videoBlocked: false, |
| 93 | + videoAvailable: false, |
| 94 | + screen: null, |
| 95 | + raisedHand: { state: false, timestamp: null }, |
| 96 | + }), |
| 97 | + // Methods the call view calls on a participant model |
| 98 | + on: () => {}, |
| 99 | + off: () => {}, |
| 100 | + forceMute: () => {}, |
| 101 | + setVideoBlocked: () => {}, |
| 102 | + setSimulcastVideoQuality: () => {}, |
| 103 | + getWebRtc: () => ({ connection: { getSendVideoIfAvailable: () => {} } }), |
| 104 | + destroy: () => {}, |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +let models: SimulatedModel[] = [] |
| 109 | +let timers: ReturnType<typeof setTimeout>[] = [] |
| 110 | + |
| 111 | +/** |
| 112 | + * Remove the simulated participants and cancel the timeline. |
| 113 | + */ |
| 114 | +function stop() { |
| 115 | + timers.forEach((timer) => clearTimeout(timer)) |
| 116 | + timers = [] |
| 117 | + |
| 118 | + models.forEach((model) => { |
| 119 | + const index = callParticipantCollection.callParticipantModels.indexOf(model as never) |
| 120 | + if (index !== -1) { |
| 121 | + callParticipantCollection.callParticipantModels.splice(index, 1) |
| 122 | + } |
| 123 | + }) |
| 124 | + models = [] |
| 125 | + |
| 126 | + console.info('[speaker simulation] stopped') |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * Make one of the simulated participants speak. |
| 131 | + * |
| 132 | + * @param index - position of the participant in the simulation |
| 133 | + * @param duration - how long they speak, in seconds |
| 134 | + */ |
| 135 | +function speak(index: number, duration: number) { |
| 136 | + const model = models[index] |
| 137 | + if (!model) { |
| 138 | + console.warn('[speaker simulation] no participant', index) |
| 139 | + return |
| 140 | + } |
| 141 | + |
| 142 | + model.attributes.speaking = true |
| 143 | + timers.push(setTimeout(() => { |
| 144 | + model.attributes.speaking = false |
| 145 | + }, duration * 1000)) |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * Add the simulated participants and play the timeline in a loop. |
| 150 | + * |
| 151 | + * @param speed - how much faster than real time to play, 1 by default. The |
| 152 | + * promotion and demotion delays are NOT scaled, so a high speed shows what |
| 153 | + * the layout does when everybody talks over each other. |
| 154 | + */ |
| 155 | +function start(speed = 1) { |
| 156 | + stop() |
| 157 | + |
| 158 | + models = Array.from({ length: PARTICIPANT_COUNT }, (_, index) => createModel(index)) |
| 159 | + callParticipantCollection.callParticipantModels.push(...(models as never[])) |
| 160 | + |
| 161 | + const scheduleRun = () => { |
| 162 | + TIMELINE.forEach(({ speaker, at, duration }) => { |
| 163 | + timers.push(setTimeout(() => speak(speaker, duration / speed), (at / speed) * 1000)) |
| 164 | + }) |
| 165 | + timers.push(setTimeout(scheduleRun, (TIMELINE_DURATION / speed) * 1000)) |
| 166 | + } |
| 167 | + scheduleRun() |
| 168 | + |
| 169 | + console.info(`[speaker simulation] started with ${PARTICIPANT_COUNT} participants at ${speed}x`) |
| 170 | +} |
| 171 | + |
| 172 | +/** |
| 173 | + * Expose the controls and start the simulation if the call is the one it was |
| 174 | + * written for. |
| 175 | + * |
| 176 | + * @param token - token of the conversation being joined |
| 177 | + */ |
| 178 | +export function setUpSpeakerSimulation(token: string) { |
| 179 | + if (window.OCA?.Talk) { |
| 180 | + // @ts-expect-error: OCA is not typed |
| 181 | + window.OCA.Talk.speakerSimulation = { start, stop, speak } |
| 182 | + } |
| 183 | + |
| 184 | + if (token === SIMULATION_TOKEN) { |
| 185 | + start() |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Counterpart of {@link setUpSpeakerSimulation}, to be called when the call |
| 191 | + * view goes away. |
| 192 | + */ |
| 193 | +export function tearDownSpeakerSimulation() { |
| 194 | + stop() |
| 195 | + // @ts-expect-error: OCA is not typed |
| 196 | + delete window.OCA?.Talk?.speakerSimulation |
| 197 | +} |
0 commit comments