From 57b5576a1a3c0d228d4c03f03dec47dd7af41ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:32:58 +0000 Subject: [PATCH] =?UTF-8?q?Add=20screen=20sharing=20=E2=80=94=20present=20?= =?UTF-8?q?on=20a=20board=20beside=20your=20avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P key or the new 🖥️ self-view button starts getDisplayMedia capture (capped at 720p / 15 fps to be kind to uplinks); pressing again or the browser's own stop-share bar ends it - The share travels as a second, one-way PeerJS call tagged with metadata {type:'screen'}: people already in voice range get it immediately, anyone walking into range is dialed by the proximity loop, and boards follow the same hangup-with-hysteresis range rules as voice - Viewers see a 16:9 easel board standing to the presenter's right, facing the way they face (audience in front sees presenter + slides); the board adopts the stream's real aspect ratio once known - Cleanup on leave/disconnect via the existing hangUp/dispose paths https://claude.ai/code/session_01PucZtRi8jZbvQS1y11NKdb --- README.md | 3 ++ src/client3d/avatar.js | 65 +++++++++++++++++++++++ src/client3d/index.html | 2 + src/client3d/main.js | 43 +++++++++++++++ src/client3d/rtc.js | 115 ++++++++++++++++++++++++++++++++++++++-- src/client3d/style.css | 1 + 6 files changed, 225 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c692dc0..03a7b03 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ voxel engine from [Fable5-mc](https://github.com/souramoo/Fable5-mc)): - **Minimap** — a north-up radar in the corner with height-shaded terrain, your view arrow, and a colored dot per player (clamped to the edge when they're far) so you can always find people. +- **Screen sharing** — press `P` (or 🖥️) to present: your screen appears on a + slideshow board standing next to your avatar, visible to everyone in voice + range, like presenting at a real meetup. - **A full voxel sandbox** — infinite procedural terrain with biomes, caves and ores, a day/night cycle (shared per room), mining and placing with 35 block types, falling sand, TNT (synchronized explosions!), swimming, sprinting, diff --git a/src/client3d/avatar.js b/src/client3d/avatar.js index f9495cd..745d341 100644 --- a/src/client3d/avatar.js +++ b/src/client3d/avatar.js @@ -165,6 +165,9 @@ class Avatar { this.bubble = null; this._chatTimer = 0; + // ---- presentation board (screen share) ---- + this.screen = null; + // ---- motion state ---- this.target = new THREE.Vector3(); this.targetYaw = 0; @@ -215,6 +218,65 @@ class Avatar { this.faceMat.needsUpdate = true; } + /** + * Show a screen share as a slideshow board standing beside the avatar + * (to their right, facing the same way they face — audience in front + * of the presenter sees both them and the slides). + */ + setScreen(videoEl) { + this.clearScreen(); + const W = 2.4, H = 1.35; // 16:9-ish board + + const tex = new THREE.VideoTexture(videoEl); + tex.colorSpace = THREE.SRGBColorSpace; + + const group = new THREE.Group(); + const frameMat = new THREE.MeshBasicMaterial({ color: 0x2b2620 }); + const centerY = 1.45; + + const frame = new THREE.Mesh(new THREE.BoxGeometry(W + 0.14, H + 0.14, 0.07), frameMat); + frame.position.y = centerY; + group.add(frame); + + const screenMat = new THREE.MeshBasicMaterial({ map: tex }); + const plane = new THREE.Mesh(new THREE.PlaneGeometry(W, H), screenMat); + plane.position.set(0, centerY, -0.04); + plane.rotation.y = Math.PI; // face -Z, same way the avatar faces + group.add(plane); + + // easel legs down to the ground + const legH = centerY - H / 2; + for (const side of [-1, 1]) { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.07, legH + 0.05, 0.07), frameMat); + leg.position.set(side * (W / 2 - 0.1), legH / 2, 0); + group.add(leg); + } + + group.position.set(1.9, 0, 0); // beside the avatar (their right) + this.group.add(group); + this.screen = { group, tex, screenMat, frameMat }; + + // match the real aspect ratio once the stream reports it + videoEl.addEventListener('loadedmetadata', () => { + if (this.screen?.tex !== tex) return; + if (!videoEl.videoWidth || !videoEl.videoHeight) return; + const aspect = videoEl.videoWidth / videoEl.videoHeight; + const sx = Math.min(1.25, Math.max(0.6, aspect / (W / H))); + plane.scale.x = sx; + frame.scale.x = sx; + }, { once: true }); + } + + clearScreen() { + if (!this.screen) return; + this.group.remove(this.screen.group); + this.screen.tex.dispose(); + this.screen.screenMat.dispose(); + this.screen.frameMat.dispose(); + this.screen.group.traverse((o) => o.geometry?.dispose()); + this.screen = null; + } + setChat(text) { this.clearChat(); this.bubble = chatSprite(text); @@ -253,6 +315,7 @@ class Avatar { dispose() { this.clearVideo(); this.clearChat(); + this.clearScreen(); this.group.parent?.remove(this.group); } } @@ -286,6 +349,8 @@ export class Avatars { setVideo(id, videoEl) { this.map.get(id)?.setVideo(videoEl); } clearVideo(id) { this.map.get(id)?.clearVideo(); } setChat(id, text) { this.map.get(id)?.setChat(text); } + setScreen(id, videoEl) { this.map.get(id)?.setScreen(videoEl); } + clearScreen(id) { this.map.get(id)?.clearScreen(); } remove(id) { const a = this.map.get(id); diff --git a/src/client3d/index.html b/src/client3d/index.html index d3f1e34..f693cae 100644 --- a/src/client3d/index.html +++ b/src/client3d/index.html @@ -50,6 +50,7 @@ + @@ -140,6 +141,7 @@ Text chatT or ENTER Mute mic / cameraM / V Switch cameraC + Present screenP Jump / Swim upSPACE Sneak (edge-safe)SHIFT SprintCTRL or double-tap W diff --git a/src/client3d/main.js b/src/client3d/main.js index d472e68..f69191e 100644 --- a/src/client3d/main.js +++ b/src/client3d/main.js @@ -300,6 +300,43 @@ class Game { this.camBtn.addEventListener('click', () => this.toggleCam()); this.switchingCam = false; document.getElementById('btn-switch-cam').addEventListener('click', () => this.switchCamera()); + this.screenBtn = document.getElementById('btn-screen'); + this.screenStream = null; + this.screenBtn.addEventListener('click', () => this.toggleScreenShare()); + } + + /** Present your screen on a board next to your avatar. */ + async toggleScreenShare() { + if (this.screenStream) { this.stopScreenShare(); return; } + if (this.state !== 'playing' && this.state !== 'paused') return; + if (!navigator.mediaDevices?.getDisplayMedia) { + this.ui.showToast('Screen sharing is not supported in this browser'); + return; + } + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { width: { max: 1280 }, height: { max: 720 }, frameRate: { max: 15 } }, + audio: false, + }); + this.screenStream = stream; + // the browser's own "stop sharing" bar ends the track + stream.getVideoTracks()[0].addEventListener('ended', () => this.stopScreenShare()); + this.rtc?.startScreenShare(stream); + this.screenBtn.classList.add('live'); + this.ui.showToast('Presenting — people nearby can see your screen'); + } catch (err) { + console.warn('[media] screen share', err); + this.ui.showToast('Screen share cancelled'); + } + } + + stopScreenShare() { + if (!this.screenStream) return; + for (const t of this.screenStream.getTracks()) t.stop(); + this.screenStream = null; + this.rtc?.stopScreenShare(); + this.screenBtn.classList.remove('live'); + this.ui.showToast('Stopped presenting'); } /** Cycle to the next video input device (front/back camera, webcams…). */ @@ -462,7 +499,10 @@ class Game { this.rtc = new Rtc(this.audio.ctx); this.rtc.onVideo = (id, video) => this.avatars.setVideo(id, video); this.rtc.onVideoEnd = (id) => this.avatars.clearVideo(id); + this.rtc.onScreen = (id, video) => this.avatars.setScreen(id, video); + this.rtc.onScreenEnd = (id) => this.avatars.clearScreen(id); this.rtc.init(this.net.id, this.localStream); + if (this.screenStream) this.rtc.startScreenShare(this.screenStream); } sendStateNow() { @@ -724,6 +764,9 @@ class Game { case 'KeyC': this.switchCamera(); break; + case 'KeyP': + this.toggleScreenShare(); + break; case 'KeyT': case 'Enter': e.preventDefault(); diff --git a/src/client3d/rtc.js b/src/client3d/rtc.js index 29b84c2..68b80c6 100644 --- a/src/client3d/rtc.js +++ b/src/client3d/rtc.js @@ -33,6 +33,12 @@ export class Rtc { this.calls = new Map(); // socketId -> { call, video, source, panner, since } this.pending = new Map(); // socketId -> when we started dialing them + // screen sharing: outgoing one-way "screen" calls when presenting, + // and boards we're receiving from presenters in range + this.screenStream = null; + this.screenCalls = new Map(); // socketId -> outgoing screen call + this.remoteScreens = new Map(); // socketId -> { call, video } + this.voiceGain = ctx.createGain(); this.voiceGain.gain.value = 1; this.voiceGain.connect(ctx.destination); @@ -42,8 +48,10 @@ export class Rtc { this.mediaHolder.style.cssText = 'position:fixed;width:0;height:0;overflow:hidden;'; document.body.appendChild(this.mediaHolder); - this.onVideo = null; // (socketId, videoElement) - this.onVideoEnd = null; // (socketId) + this.onVideo = null; // (socketId, videoElement) + this.onVideoEnd = null; // (socketId) + this.onScreen = null; // (socketId, videoElement) — they started presenting + this.onScreenEnd = null; // (socketId) } /** @param {MediaStream|null} stream local cam/mic (null = spectator) */ @@ -54,6 +62,11 @@ export class Rtc { config: { iceServers: ICE_SERVERS }, }); this.peer.on('call', (call) => { + if (call.metadata && call.metadata.type === 'screen') { + call.answer(); // one-way: we just watch + this._wireIncomingScreen(call); + return; + } call.answer(this.myStream); this._wireCall(call); }); @@ -139,6 +152,13 @@ export class Rtc { hangUp(socketId) { this.pending.delete(socketId); + // drop any presentation traffic with this peer too + const screenCall = this.screenCalls.get(socketId); + if (screenCall) { + this.screenCalls.delete(socketId); + try { screenCall.close(); } catch { /* already closed */ } + } + this._closeRemoteScreen(socketId); const entry = this.calls.get(socketId); if (!entry) return; this.calls.delete(socketId); @@ -170,8 +190,12 @@ export class Rtc { const seen = new Set(); for (const { id, distance } of others) { seen.add(id); - if (distance < CALL_DISTANCE) this.call(id); - else if (distance > HANGUP_DISTANCE) this.hangUp(id); + if (distance < CALL_DISTANCE) { + this.call(id); + if (this.screenStream) this._dialScreen(id); + } else if (distance > HANGUP_DISTANCE) { + this.hangUp(id); + } } for (const id of [...this.calls.keys()]) { if (!seen.has(id)) this.hangUp(id); @@ -179,6 +203,19 @@ export class Rtc { for (const id of [...this.pending.keys()]) { if (!seen.has(id)) this.pending.delete(id); } + + // presentation boards follow the same range rules as voice + for (const [id, call] of [...this.screenCalls]) { + const o = others.find((m) => m.id === id); + if (!o || o.distance > HANGUP_DISTANCE) { + try { call.close(); } catch { /* already closed */ } + this.screenCalls.delete(id); + } + } + for (const id of [...this.remoteScreens.keys()]) { + const o = others.find((m) => m.id === id); + if (!o || o.distance > HANGUP_DISTANCE) this._closeRemoteScreen(id); + } } /** Per-frame: move the WebAudio listener to the camera. */ @@ -209,6 +246,74 @@ export class Rtc { } } + // ---------------------------------------------------------- + // Screen sharing ("presenting") + // ---------------------------------------------------------- + + startScreenShare(stream) { + this.screenStream = stream; + // people already in a voice call see the board immediately; + // anyone walking into range later is dialed by updateProximity + for (const id of this.calls.keys()) this._dialScreen(id); + } + + stopScreenShare() { + this.screenStream = null; + for (const call of this.screenCalls.values()) { + try { call.close(); } catch { /* already closed */ } + } + this.screenCalls.clear(); + } + + _dialScreen(socketId) { + if (!this.peer || !this.screenStream || this.screenCalls.has(socketId)) return; + const call = this.peer.call(PEER_PREFIX + socketId, this.screenStream, { + metadata: { type: 'screen' }, + }); + if (!call) return; + this.screenCalls.set(socketId, call); + call.on('close', () => this.screenCalls.delete(socketId)); + call.on('error', () => this.screenCalls.delete(socketId)); + } + + _wireIncomingScreen(call) { + const id = this._socketIdOf(call); + this._closeRemoteScreen(id); // a fresh share replaces the old board + const entry = { call, video: null }; + this.remoteScreens.set(id, entry); + + call.on('stream', (remote) => { + if (this.remoteScreens.get(id) !== entry || entry.video) return; + const video = document.createElement('video'); + video.muted = true; + video.autoplay = true; + video.playsInline = true; + video.setAttribute('playsinline', ''); + video.srcObject = remote; + this.mediaHolder.appendChild(video); + video.play().catch(() => {}); + entry.video = video; + this.onScreen?.(id, video); + }); + const end = () => { + if (this.remoteScreens.get(id) === entry) this._closeRemoteScreen(id); + }; + call.on('close', end); + call.on('error', end); + } + + _closeRemoteScreen(id) { + const entry = this.remoteScreens.get(id); + if (!entry) return; + this.remoteScreens.delete(id); + try { entry.call.close(); } catch { /* already closed */ } + if (entry.video) { + entry.video.srcObject = null; + entry.video.remove(); + } + this.onScreenEnd?.(id); + } + /** Swap the outgoing video track on every live call (camera switch). */ replaceVideoTrack(track) { for (const { call } of this.calls.values()) { @@ -223,6 +328,8 @@ export class Rtc { } dispose() { + this.stopScreenShare(); + for (const id of [...this.remoteScreens.keys()]) this._closeRemoteScreen(id); for (const id of [...this.calls.keys()]) this.hangUp(id); this.peer?.destroy(); } diff --git a/src/client3d/style.css b/src/client3d/style.css index 99236d3..78baab2 100644 --- a/src/client3d/style.css +++ b/src/client3d/style.css @@ -526,6 +526,7 @@ html, body { } .av-button:hover { background: rgba(80, 80, 110, 0.9); } .av-button.off { background: rgba(140, 40, 30, 0.9); } +.av-button.live { background: rgba(60, 140, 50, 0.9); } #room-status { position: absolute;