309 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|