// MWSE Studio — desktop-first Miller-column UI. // // Hierarchy: Network → Groups → Peers → Devices → Streams → Quality // // Usage: // import Studio from '/sdk/studio/index.js'; // const studio = new Studio(mwse, '#app'); // studio.mount(); // // The Studio is purely additive: it renders into the given container and // never modifies the MWSE SDK state except through the public SDK API. import ColumnView from './ColumnView.js'; import { MediaSources } from '../webrtc/index.js'; export default class Studio { constructor(mwse, container) { this.mwse = mwse; this._container = typeof container === 'string' ? document.querySelector(container) : container; this._view = new ColumnView(this._container); this._styleInjected = false; } // Mount the Studio UI inside the container element. mount() { this._injectStyle(); this._container.classList.add('mwse-studio'); // Toolbar const toolbar = document.createElement('div'); toolbar.className = 'mwse-studio__toolbar'; const title = document.createElement('span'); title.className = 'mwse-studio__title'; title.textContent = 'MWSE Studio'; toolbar.appendChild(title); this._statusEl = document.createElement('span'); this._statusEl.className = 'mwse-studio__status'; toolbar.appendChild(this._statusEl); this._container.appendChild(toolbar); this.mwse.on('scope', () => this._setStatus('online', 'Connected')); this.mwse.on('close', () => this._setStatus('', 'Disconnected')); this.mwse.on('error', e => this._setStatus('error', e.message)); this._view.mount(); this._pushNetworkColumn(); return this; } // ---- Column builders ------------------------------------------------ _pushNetworkColumn() { const items = [{ icon: '◉', label: 'Network', meta: () => `${this.mwse.peers.size} peers online`, onSelect: () => { this._view.popTo(1); this._pushGroupsColumn(); } }]; this._view.pushColumn('Studio', items, { searchable: false }); } _pushGroupsColumn() { const items = []; for (const [id, room] of this.mwse.rooms) { items.push({ icon: '#', label: id, meta: () => `${room.peers.size} peers`, onSelect: () => { this._view.popTo(2); this._pushPeersColumn(room); } }); } if (items.length === 0) { items.push({ icon: '—', label: 'No groups', meta: 'Join a room first', hasChildren: false }); } this._view.pushColumn('Groups', items); } _pushPeersColumn(room) { const items = []; for (const [, peer] of room.peers) { items.push({ icon: peer.selfSocket ? '★' : '●', label: peer.socketId, meta: () => peer.peerConnection ? 'p2p' : 'ws', onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer); } }); } if (items.length === 0) { items.push({ icon: '—', label: 'No peers in room', meta: '', hasChildren: false }); } this._view.pushColumn('Peers', items); } _pushDevicesColumn(peer) { const items = [ { icon: '◎', label: 'Camera', meta: 'Capture video', onSelect: () => this._openCamera(peer) }, { icon: '●', label: 'Camera + Mic', meta: 'Video + audio', onSelect: () => this._openCameraAndMic(peer) }, { icon: '♪', label: 'Microphone', meta: 'Audio only', onSelect: () => this._openMicrophone(peer) }, { icon: '⬜', label: 'Screen', meta: 'Share display', onSelect: () => this._openScreen(peer) }, { icon: '↗', label: 'Send file', meta: 'DataChannel transfer', hasChildren: false, onSelect: () => this._openFilePicker(peer) } ]; const col = this._view.pushColumn('Devices', items); // Show already-active streams at the bottom of devices. if (peer.rtc?._streams) { const active = peer.rtc._streams.list(); if (active.length) { col.addAction('View streams', '', () => { this._view.popTo(4); this._pushActiveStreamsColumn(peer); }); } } } _pushActiveStreamsColumn(peer) { const srcs = peer.rtc?._streams?.list() ?? []; const items = srcs.map(src => ({ icon: src.tracks[0]?.kind === 'video' ? '▶' : '♪', label: src.label, meta: src.tracks.map(t => t.kind).join(' + '), onSelect: () => { this._view.popTo(5); this._pushQualityColumn(peer, src.label, src); } })); this._view.pushColumn('Streams', items); } _pushQualityColumn(peer, label, src) { const presets = [ { icon: '↑', label: 'High', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } }, { icon: '—', label: 'Medium', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } }, { icon: '↓', label: 'Low', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } }, ]; const items = presets.map(p => ({ icon: p.icon, label: p.label, meta: p.meta, hasChildren: false, onSelect: () => peer.rtc?.setEncodings(label, 'video', p.params) })); // Mute / stop controls. for (const track of (src.tracks ?? [])) { items.push({ icon: track.enabled ? '⊙' : '○', label: `${track.enabled ? 'Mute' : 'Unmute'} ${track.kind}`, meta: '', hasChildren: false, onSelect: () => { peer.rtc?.setEnabled(label, track.kind, !track.enabled); this._view.refresh(); } }); } items.push({ icon: '✕', label: 'Stop stream', meta: '', hasChildren: false, onSelect: () => { peer.rtc?.removeStream(label); this._view.popTo(4); } }); this._view.pushColumn('Quality', items); } // ---- Device helpers ------------------------------------------------- async _openCamera(peer) { const stream = await MediaSources.camera().catch(e => { this._setStatus('error', e.message); return null; }); if (!stream) return; this._ensureRTC(peer); peer.rtc.addStream('camera', stream); this._view.popTo(4); this._pushActiveStreamsColumn(peer); } async _openCameraAndMic(peer) { const stream = await MediaSources.cameraAndMic().catch(e => { this._setStatus('error', e.message); return null; }); if (!stream) return; this._ensureRTC(peer); peer.rtc.addStream('cam+mic', stream); this._view.popTo(4); this._pushActiveStreamsColumn(peer); } async _openMicrophone(peer) { const stream = await MediaSources.microphone().catch(e => { this._setStatus('error', e.message); return null; }); if (!stream) return; this._ensureRTC(peer); peer.rtc.addStream('mic', stream); this._view.popTo(4); this._pushActiveStreamsColumn(peer); } async _openScreen(peer) { const stream = await MediaSources.screen().catch(e => { this._setStatus('error', e.message); return null; }); if (!stream) return; this._ensureRTC(peer); peer.rtc.addStream('screen', stream); this._view.popTo(4); this._pushActiveStreamsColumn(peer); } _openFilePicker(peer) { const input = document.createElement('input'); input.type = 'file'; input.addEventListener('change', async () => { const file = input.files?.[0]; if (!file) return; this._ensureRTC(peer); this._setStatus('', `Sending ${file.name}…`); peer.rtc.files.on('progress', ({ sent, total }) => { const pct = Math.round(sent / total * 100); this._setStatus('', `${file.name} — ${pct}%`); }); await peer.rtc.sendFile(file).catch(e => this._setStatus('error', e.message)); this._setStatus('online', `${file.name} sent`); }); input.click(); } // ---- RTCEngine management ------------------------------------------- // Ensure the peer has an RTCEngine connected (creates one if absent). _ensureRTC(peer) { if (peer.rtc.active) return; // Determine politeness by lexicographic socket ID comparison. const myId = this.mwse.me.socketId; const polite = myId < peer.socketId; peer.rtc.connect({ polite }); } // ---- Helpers -------------------------------------------------------- _setStatus(cls, text) { if (!this._statusEl) return; this._statusEl.className = `mwse-studio__status${cls ? ` mwse-studio__status--${cls}` : ''}`; this._statusEl.textContent = text; } _injectStyle() { if (this._styleInjected) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = new URL('./style.css', import.meta.url).href; document.head.appendChild(link); this._styleInjected = true; } }