MWSE/public/studio/Studio.js

309 lines
10 KiB
JavaScript

// MWSE Studio — desktop-first Miller-column UI.
//
// Hierarchy: Network → Groups → Peers → Devices → Streams → Quality
//
// Usage:
// import Studio from '/studio/Studio.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 '/studio/ColumnView.js';
import { MediaSources } from '/sdk/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;
}
}