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

This commit is contained in:
Bryan1029384756
2026-02-21 15:48:48 -06:00
parent 84aa458012
commit 948f8c7aa7
9 changed files with 141 additions and 17 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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={{

View File

@@ -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));
} }
} }
}; };