This commit is contained in:
parent
3117116d12
commit
5f92356a9a
4 changed files with 208 additions and 13 deletions
121
static/app.js
121
static/app.js
|
|
@ -15,9 +15,11 @@ const state = {
|
|||
joinedChannelId: null,
|
||||
localStream: null,
|
||||
rawStream: null,
|
||||
videoStream: null,
|
||||
audioContext: null,
|
||||
peerConnections: new Map(),
|
||||
muted: false,
|
||||
sharingVideo: false,
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
},
|
||||
voicePresencePollId: null,
|
||||
|
|
@ -59,8 +61,10 @@ const el = {
|
|||
// Voice Connection
|
||||
voiceConnection: document.getElementById("voice-connection"),
|
||||
vcChannelName: document.getElementById("vc-channel-name"),
|
||||
voiceVideoBtn: document.getElementById("voice-video-btn"),
|
||||
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
||||
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
||||
videoGrid: document.getElementById("video-grid"),
|
||||
|
||||
// Members
|
||||
memberList: document.getElementById("member-list"),
|
||||
|
|
@ -553,6 +557,41 @@ function stopAndClearAudioPipeline() {
|
|||
state.voice.audioContext = null;
|
||||
}
|
||||
|
||||
function stopAndClearVideoPipeline() {
|
||||
if (state.voice.videoStream) {
|
||||
for (const track of state.voice.videoStream.getTracks()) track.stop();
|
||||
}
|
||||
state.voice.videoStream = null;
|
||||
state.voice.sharingVideo = false;
|
||||
document.getElementById(`video-${state.me.id}`)?.parentElement?.remove();
|
||||
updateVideoGridVisibility();
|
||||
}
|
||||
|
||||
function updateVideoGridVisibility() {
|
||||
const hasVideos = el.videoGrid.children.length > 0;
|
||||
if (hasVideos) {
|
||||
el.videoGrid.classList.remove("hidden");
|
||||
} else {
|
||||
el.videoGrid.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function renderVideo(peerId, displayName, stream) {
|
||||
let videoEl = document.getElementById(`video-${peerId}`);
|
||||
if (!videoEl) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "video-item";
|
||||
container.innerHTML = `
|
||||
<video id="video-${peerId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
|
||||
<div class="video-label">${escapeHtml(displayName)}</div>
|
||||
`;
|
||||
el.videoGrid.appendChild(container);
|
||||
videoEl = container.querySelector("video");
|
||||
}
|
||||
videoEl.srcObject = stream;
|
||||
updateVideoGridVisibility();
|
||||
}
|
||||
|
||||
async function buildAudioPipeline(rawStream) {
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(rawStream);
|
||||
|
|
@ -601,6 +640,12 @@ function ensurePeerConnection(peerId) {
|
|||
}
|
||||
}
|
||||
|
||||
if (state.voice.videoStream) {
|
||||
for (const track of state.voice.videoStream.getTracks()) {
|
||||
pc.addTrack(track, state.voice.videoStream);
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (!event.candidate || !state.voice.ws) return;
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
|
|
@ -612,15 +657,30 @@ function ensurePeerConnection(peerId) {
|
|||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
let audio = document.getElementById(`audio-${peerId}`);
|
||||
if (!audio) {
|
||||
audio = document.createElement("audio");
|
||||
audio.id = `audio-${peerId}`;
|
||||
audio.autoplay = true;
|
||||
audio.playsInline = true;
|
||||
document.body.appendChild(audio);
|
||||
if (event.track.kind === "audio") {
|
||||
let audio = document.getElementById(`audio-${peerId}`);
|
||||
if (!audio) {
|
||||
audio = document.createElement("audio");
|
||||
audio.id = `audio-${peerId}`;
|
||||
audio.autoplay = true;
|
||||
audio.playsInline = true;
|
||||
document.body.appendChild(audio);
|
||||
}
|
||||
audio.srcObject = event.streams[0];
|
||||
} else if (event.track.kind === "video") {
|
||||
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
||||
renderVideo(peerId, peer.display_name, event.streams[0]);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
if (shouldInitiateOffer(peerId)) {
|
||||
await sendOffer(peerId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("negotiation failed", err);
|
||||
}
|
||||
audio.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
state.voice.peerConnections.set(peerId, pc);
|
||||
|
|
@ -707,6 +767,11 @@ async function joinVoice() {
|
|||
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||
} else if (msg.type === "signal") {
|
||||
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||
} else if (msg.type === "video_status_changed") {
|
||||
if (!msg.is_sharing_video) {
|
||||
document.getElementById(`video-${msg.user_id}`)?.parentElement?.remove();
|
||||
updateVideoGridVisibility();
|
||||
}
|
||||
}
|
||||
refreshVoicePresence().catch(() => { });
|
||||
};
|
||||
|
|
@ -738,6 +803,45 @@ function toggleMute() {
|
|||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function toggleVideo() {
|
||||
if (state.voice.sharingVideo) {
|
||||
stopAndClearVideoPipeline();
|
||||
if (state.voice.ws) {
|
||||
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
|
||||
}
|
||||
for (const pc of state.voice.peerConnections.values()) {
|
||||
const senders = pc.getSenders();
|
||||
const videoSender = senders.find(s => s.track && s.track.kind === "video");
|
||||
if (videoSender) pc.removeTrack(videoSender);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
state.voice.videoStream = stream;
|
||||
state.voice.sharingVideo = true;
|
||||
renderVideo(state.me.id, state.me.display_name, stream);
|
||||
|
||||
if (state.voice.ws) {
|
||||
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
|
||||
}
|
||||
|
||||
for (const pc of state.voice.peerConnections.values()) {
|
||||
for (const track of stream.getTracks()) {
|
||||
pc.addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("camera denied", err);
|
||||
alert("Could not access camera.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '<i data-lucide="video"></i>' : '<i data-lucide="video-off"></i>';
|
||||
el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// --- Mobile Logic ---
|
||||
|
||||
function toggleMobileMenu() {
|
||||
|
|
@ -911,6 +1015,7 @@ async function init() {
|
|||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.voiceVideoBtn.onclick = toggleVideo;
|
||||
el.voiceMuteBtn.onclick = toggleMute;
|
||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@
|
|||
<span id="vc-channel-name" class="vc-name">General</span>
|
||||
</div>
|
||||
<div class="vc-actions">
|
||||
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
|
||||
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||
</div>
|
||||
|
|
@ -121,6 +122,7 @@
|
|||
</button>
|
||||
</header>
|
||||
|
||||
<div id="video-grid" class="video-grid hidden"></div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
|
|
|
|||
|
|
@ -524,6 +524,47 @@ select {
|
|||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* Video Grid */
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.video-grid.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-item video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-item .video-label {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Chat Pane */
|
||||
.chat-pane {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue