feat: Add voice and video stage functionality with multi-platform project setup.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.32"
|
versionName "1.0.33"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.32",
|
"version": "1.0.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.32",
|
"version": "1.0.33",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.32",
|
"version": "1.0.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const RELEASES_DIR = '/app/releases';
|
|||||||
const LOADING_HTML = readFileSync(join(__dirname, 'loading.html'), 'utf-8');
|
const LOADING_HTML = readFileSync(join(__dirname, 'loading.html'), 'utf-8');
|
||||||
|
|
||||||
let currentDistPath = null;
|
let currentDistPath = null;
|
||||||
|
let currentCommitHash = null;
|
||||||
let building = false;
|
let building = false;
|
||||||
let lastBuildStatus = { status: 'idle', timestamp: null, error: null };
|
let lastBuildStatus = { status: 'idle', timestamp: null, error: null };
|
||||||
|
|
||||||
@@ -39,8 +40,15 @@ function run(cmd, opts = {}) {
|
|||||||
execSync(cmd, { stdio: 'inherit', timeout: 600_000, ...opts });
|
execSync(cmd, { stdio: 'inherit', timeout: 600_000, ...opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerBuild() {
|
async function triggerBuild(webhookCommit) {
|
||||||
if (building) return;
|
if (building) return;
|
||||||
|
|
||||||
|
// Fast path: skip clone entirely if webhook provides the commit and it matches
|
||||||
|
if (webhookCommit && webhookCommit === currentCommitHash) {
|
||||||
|
log(`Commit ${webhookCommit} is already deployed, skipping build (webhook fast path).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
building = true;
|
building = true;
|
||||||
lastBuildStatus = { status: 'building', timestamp: Date.now(), error: null };
|
lastBuildStatus = { status: 'building', timestamp: Date.now(), error: null };
|
||||||
|
|
||||||
@@ -54,6 +62,17 @@ async function triggerBuild() {
|
|||||||
// Clone
|
// Clone
|
||||||
run(`git clone --depth 1 --branch ${GIT_BRANCH} ${GIT_REPO_URL} ${buildDir}`);
|
run(`git clone --depth 1 --branch ${GIT_BRANCH} ${GIT_REPO_URL} ${buildDir}`);
|
||||||
|
|
||||||
|
// Read the cloned commit hash
|
||||||
|
const clonedHash = execSync('git rev-parse HEAD', { cwd: buildDir, encoding: 'utf-8' }).trim();
|
||||||
|
log(`Cloned commit: ${clonedHash}`);
|
||||||
|
|
||||||
|
// Skip if this commit is already deployed
|
||||||
|
if (clonedHash === currentCommitHash) {
|
||||||
|
log(`Commit ${clonedHash} is already deployed, skipping build.`);
|
||||||
|
lastBuildStatus = { status: 'skipped', timestamp: Date.now(), error: null };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Install deps (web workspaces only)
|
// Install deps (web workspaces only)
|
||||||
run(
|
run(
|
||||||
'npm ci --workspace=apps/web --workspace=packages/shared --workspace=packages/platform-web --include-workspace-root',
|
'npm ci --workspace=apps/web --workspace=packages/shared --workspace=packages/platform-web --include-workspace-root',
|
||||||
@@ -73,7 +92,8 @@ async function triggerBuild() {
|
|||||||
// Swap — instant, zero downtime
|
// Swap — instant, zero downtime
|
||||||
const oldDist = currentDistPath;
|
const oldDist = currentDistPath;
|
||||||
currentDistPath = releaseDir;
|
currentDistPath = releaseDir;
|
||||||
log(`Swapped to new release: ${releaseDir}`);
|
currentCommitHash = clonedHash;
|
||||||
|
log(`Swapped to new release: ${releaseDir} (commit ${clonedHash})`);
|
||||||
|
|
||||||
// Clean up old release
|
// Clean up old release
|
||||||
if (oldDist && existsSync(oldDist) && oldDist !== releaseDir) {
|
if (oldDist && existsSync(oldDist) && oldDist !== releaseDir) {
|
||||||
@@ -105,13 +125,16 @@ app.use(compression());
|
|||||||
app.post('/api/webhook', express.json(), (req, res) => {
|
app.post('/api/webhook', express.json(), (req, res) => {
|
||||||
if (building) return res.status(409).json({ error: 'Build already in progress' });
|
if (building) return res.status(409).json({ error: 'Build already in progress' });
|
||||||
|
|
||||||
triggerBuild();
|
// Extract commit SHA from Gitea/GitHub push webhook payload
|
||||||
|
const webhookCommit = req.body?.after || null;
|
||||||
|
|
||||||
|
triggerBuild(webhookCommit);
|
||||||
res.json({ message: 'Build triggered' });
|
res.json({ message: 'Build triggered' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Status endpoint
|
// Status endpoint
|
||||||
app.get('/api/status', (_req, res) => {
|
app.get('/api/status', (_req, res) => {
|
||||||
res.json({ building, ...lastBuildStatus });
|
res.json({ building, commitHash: currentCommitHash, ...lastBuildStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static file serving + SPA fallback
|
// Static file serving + SPA fallback
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.32",
|
"version": "1.0.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ const liveBadgeStyle = {
|
|||||||
marginRight: '4px'
|
marginRight: '4px'
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526, 41.485%, 44.902%), inset 0 0 0 2px hsl(134.526, 41.485%, 44.902%), inset 0 0 0 3px hsl(240, 7.143%, 10.98%)';
|
const ACTIVE_SPEAKER_SHADOW = 'rgb(67, 162, 90) 0px 0px 0px 2px, rgb(67, 162, 90) 0px 0px 0px 20px inset, rgb(26, 26, 30) 0px 0px 0px 20px inset';
|
||||||
const VOICE_ACTIVE_COLOR = 'hsl(132.809, 34.902%, 50%)';
|
const VOICE_ACTIVE_COLOR = 'hsl(132.809, 34.902%, 50%)';
|
||||||
|
|
||||||
async function encryptKeyForUsers(convex, channelId, keyHex, crypto) {
|
async function encryptKeyForUsers(convex, channelId, keyHex, crypto) {
|
||||||
@@ -1135,7 +1135,8 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
size={24}
|
size={24}
|
||||||
style={{
|
style={{
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
|
||||||
|
transition: 'box-shadow 0.15s ease',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.displayName || user.username}</span>
|
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.displayName || user.username}</span>
|
||||||
@@ -1182,6 +1183,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
style={{
|
style={{
|
||||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
|
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
transition: 'box-shadow 0.15s ease',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const getUserColor = (username) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Style constants
|
// Style constants
|
||||||
|
const ACTIVE_SPEAKER_SHADOW = 'rgb(67, 162, 90) 0px 0px 0px 2px, rgb(67, 162, 90) 0px 0px 0px 20px inset, rgb(26, 26, 30) 0px 0px 0px 20px inset';
|
||||||
|
|
||||||
const LIVE_BADGE_STYLE = {
|
const LIVE_BADGE_STYLE = {
|
||||||
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
|
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
|
||||||
color: 'white', fontSize: '11px', fontWeight: 'bold',
|
color: 'white', fontSize: '11px', fontWeight: 'bold',
|
||||||
@@ -87,7 +89,7 @@ const ConnectionQualityIcon = ({ quality }) => {
|
|||||||
|
|
||||||
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
||||||
const cameraTrack = useParticipantTrack(participant, 'camera');
|
const cameraTrack = useParticipantTrack(participant, 'camera');
|
||||||
const { isPersonallyMuted, voiceStates, connectionQualities } = useVoice();
|
const { isPersonallyMuted, voiceStates, connectionQualities, activeSpeakers } = useVoice();
|
||||||
const isMicEnabled = participant.isMicrophoneEnabled;
|
const isMicEnabled = participant.isMicrophoneEnabled;
|
||||||
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
||||||
const displayName = username || participant.identity;
|
const displayName = username || participant.identity;
|
||||||
@@ -109,6 +111,8 @@ const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
aspectRatio: '16/9',
|
aspectRatio: '16/9',
|
||||||
|
boxShadow: activeSpeakers.has(participant.identity) ? ACTIVE_SPEAKER_SHADOW : 'none',
|
||||||
|
transition: 'box-shadow 0.15s ease',
|
||||||
}}>
|
}}>
|
||||||
{cameraTrack ? (
|
{cameraTrack ? (
|
||||||
<VideoRenderer track={cameraTrack} />
|
<VideoRenderer track={cameraTrack} />
|
||||||
@@ -617,6 +621,32 @@ const FocusedStreamView = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PreviewParticipantTile = ({ username, displayName, avatarUrl }) => {
|
||||||
|
const name = displayName || username;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '88px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<Avatar username={name} avatarUrl={avatarUrl} size={56} />
|
||||||
|
<span style={{
|
||||||
|
color: '#b9bbbe',
|
||||||
|
fontSize: '12px',
|
||||||
|
maxWidth: '88px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
|
|
||||||
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||||
@@ -890,8 +920,31 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
marginBottom: '24px'
|
marginBottom: '24px'
|
||||||
}}>
|
}}>
|
||||||
No one is currently in voice
|
{voiceUsers.length === 0
|
||||||
|
? 'No one is currently in voice'
|
||||||
|
: voiceUsers.length === 1
|
||||||
|
? '1 person is in voice'
|
||||||
|
: `${voiceUsers.length} people are in voice`}
|
||||||
</p>
|
</p>
|
||||||
|
{voiceUsers.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
maxWidth: '440px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}>
|
||||||
|
{voiceUsers.map(u => (
|
||||||
|
<PreviewParticipantTile
|
||||||
|
key={u.userId}
|
||||||
|
username={u.username}
|
||||||
|
displayName={u.displayName}
|
||||||
|
avatarUrl={u.avatarUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => connectToVoice(channelId, channelName, localStorage.getItem('userId'))}
|
onClick={() => connectToVoice(channelId, channelName, localStorage.getItem('userId'))}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const [room, setRoom] = useState(null);
|
const [room, setRoom] = useState(null);
|
||||||
const [token, setToken] = useState(null);
|
const [token, setToken] = useState(null);
|
||||||
const [activeSpeakers, setActiveSpeakers] = useState(new Set());
|
const [activeSpeakers, setActiveSpeakers] = useState(new Set());
|
||||||
|
const speakerRemovalTimers = useRef(new Map());
|
||||||
|
const clearSpeakerTimers = useCallback(() => {
|
||||||
|
for (const timer of speakerRemovalTimers.current.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
speakerRemovalTimers.current.clear();
|
||||||
|
}, []);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isDeafened, setIsDeafened] = useState(false);
|
const [isDeafened, setIsDeafened] = useState(false);
|
||||||
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
||||||
@@ -121,7 +128,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// Apply volume to LiveKit participant (factoring in global output volume)
|
// Apply volume to LiveKit participant (factoring in global output volume)
|
||||||
const participant = room?.remoteParticipants?.get(userId);
|
const participant = room?.remoteParticipants?.get(userId);
|
||||||
const globalVol = globalOutputVolume / 100;
|
const globalVol = globalOutputVolume / 100;
|
||||||
if (participant) participant.setVolume((volume / 100) * globalVol);
|
if (participant) participant.setVolume(Math.min(2, (volume / 100) * globalVol));
|
||||||
// Sync personal mute state
|
// Sync personal mute state
|
||||||
if (volume === 0) {
|
if (volume === 0) {
|
||||||
setPersonallyMutedUsers(prev => {
|
setPersonallyMutedUsers(prev => {
|
||||||
@@ -155,7 +162,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const vol = userVolumes[userId] ?? 100;
|
const vol = userVolumes[userId] ?? 100;
|
||||||
const restoreVol = vol === 0 ? 100 : vol;
|
const restoreVol = vol === 0 ? 100 : vol;
|
||||||
const participant = room?.remoteParticipants?.get(userId);
|
const participant = room?.remoteParticipants?.get(userId);
|
||||||
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
|
if (participant) participant.setVolume(Math.min(2, (restoreVol / 100) * globalVol));
|
||||||
// Update stored volume if it was 0
|
// Update stored volume if it was 0
|
||||||
if (vol === 0) {
|
if (vol === 0) {
|
||||||
setUserVolumes(p => {
|
setUserVolumes(p => {
|
||||||
@@ -269,6 +276,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||||
|
|
||||||
const newRoom = new Room({
|
const newRoom = new Room({
|
||||||
|
webAudioMix: true,
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
autoSubscribe: true,
|
autoSubscribe: true,
|
||||||
@@ -347,6 +355,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
if (isMovingRef.current) {
|
if (isMovingRef.current) {
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
clearSpeakerTimers();
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
return;
|
return;
|
||||||
@@ -357,6 +366,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
console.log('Token expired, auto-reconnecting...');
|
console.log('Token expired, auto-reconnecting...');
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
clearSpeakerTimers();
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
try {
|
try {
|
||||||
@@ -373,6 +383,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setActiveChannelId(null);
|
setActiveChannelId(null);
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
clearSpeakerTimers();
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
|
|
||||||
@@ -384,7 +395,42 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
|
const currentSpeakerIds = new Set(speakers.map(p => p.identity));
|
||||||
|
|
||||||
|
// Cancel pending removal timers for anyone who is speaking again
|
||||||
|
for (const id of currentSpeakerIds) {
|
||||||
|
const timer = speakerRemovalTimers.current.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
speakerRemovalTimers.current.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSpeakers(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
|
||||||
|
// Add new speakers immediately
|
||||||
|
for (const id of currentSpeakerIds) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule delayed removal for speakers no longer in the event
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!currentSpeakerIds.has(id) && !speakerRemovalTimers.current.has(id)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
speakerRemovalTimers.current.delete(id);
|
||||||
|
setActiveSpeakers(s => {
|
||||||
|
const updated = new Set(s);
|
||||||
|
updated.delete(id);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
speakerRemovalTimers.current.set(id, timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
newRoom.on(RoomEvent.Reconnecting, () => {
|
newRoom.on(RoomEvent.Reconnecting, () => {
|
||||||
@@ -507,7 +553,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
participant.setVolume(0);
|
participant.setVolume(0);
|
||||||
} else {
|
} else {
|
||||||
const userVol = (userVolumes[identity] ?? 100) / 100;
|
const userVol = (userVolumes[identity] ?? 100) / 100;
|
||||||
participant.setVolume(userVol * globalVol);
|
participant.setVolume(Math.min(2, userVol * globalVol));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user