// MWSE Studio — dahili yönetim arayüzü.
// Akış: bağlan → ID'yi kopyala → eşle → WebRTC ara / oda oluştur
import ColumnView from '/studio/ColumnView.js';
import { MediaSources } from '/sdk/webrtc/index.js';
export default class Studio {
constructor(mwse, container) {
this.mwse = mwse;
this._el = typeof container === 'string'
? document.querySelector(container) : container;
this._view = new ColumnView(this._el);
this._devices = { cameras: [], microphones: [] };
this._statusEl = null;
this._notifArea = null;
this._styleOK = false;
this._remoteMedia = [];
}
async mount() {
this._el.classList.add('mwse-studio');
this._injectStyle();
this._buildToolbar();
this._buildNotifArea();
this._view.mount();
await this._loadDevices();
this._pushRootColumn();
// ── Gelen eşleme isteği → bildirim banner'ı ──────────────────────
this.mwse.me.on('request/pair', peer => {
this._showPairRequest(peer);
});
// Eşleşme onaylandı (istek gönderen taraf)
this.mwse.me.on('accepted/pair', peer => {
this._watchIncoming(peer);
this._view.refresh();
this._setStatus('online', `${peer.socketId.slice(-8)} eşleşmesi kuruldu`);
});
// Eşleşme bitti
this.mwse.me.on('end/pair', () => this._view.refresh());
// Oda değişimi
this.mwse.on('room', () => this._view.refresh());
return this;
}
// ── Araç çubuğu ──────────────────────────────────────────────────────────
_buildToolbar() {
const bar = document.createElement('div');
bar.className = 'mwse-studio__toolbar';
// Logo
const logo = document.createElement('span');
logo.className = 'mwse-studio__title';
logo.innerHTML = 'MWSE Studio';
bar.appendChild(logo);
// Benim ID kartı
const idCard = document.createElement('div');
idCard.className = 'mwse-id-card';
idCard.title = 'Tıkla → Kopyala';
const idLabel = document.createElement('span');
idLabel.className = 'mwse-id-card__label';
idLabel.textContent = 'Kimliğim';
const idValue = document.createElement('span');
idValue.className = 'mwse-id-card__value';
idValue.textContent = '…';
const idCopy = document.createElement('span');
idCopy.className = 'mwse-id-card__copy';
idCopy.textContent = '⎘';
idCard.append(idLabel, idValue, idCopy);
idCard.addEventListener('click', () => {
const id = this.mwse.me.socketId;
if (!id || id === '…') return;
navigator.clipboard.writeText(id).then(() => {
idCopy.textContent = '✓';
idCard.classList.add('mwse-id-card--copied');
setTimeout(() => {
idCard.classList.remove('mwse-id-card--copied');
idCopy.textContent = '⎘';
}, 2000);
});
});
this.mwse.me.on('scope', () => { idValue.textContent = this.mwse.me.socketId; });
bar.appendChild(idCard);
// Durum
this._statusEl = document.createElement('span');
this._statusEl.className = 'mwse-studio__status mwse-studio__status--online';
this._statusEl.textContent = 'Bağlı';
bar.appendChild(this._statusEl);
this._el.insertBefore(bar, this._el.firstChild);
}
// ── Gelen istek bildirim alanı ────────────────────────────────────────────
_buildNotifArea() {
this._notifArea = document.createElement('div');
this._notifArea.className = 'mwse-notif-area';
// Araç çubuğundan hemen sonra
const toolbar = this._el.querySelector('.mwse-studio__toolbar');
toolbar.insertAdjacentElement('afterend', this._notifArea);
}
_showPairRequest(peer) {
const bar = document.createElement('div');
bar.className = 'mwse-notif-bar';
const msg = document.createElement('div');
msg.className = 'mwse-notif-bar__msg';
msg.innerHTML = `●`
+ ` ${peer.socketId} bağlanmak istiyor`;
const actions = document.createElement('div');
actions.className = 'mwse-notif-bar__actions';
const rejectBtn = document.createElement('button');
rejectBtn.className = 'mwse-btn mwse-btn--danger';
rejectBtn.textContent = 'Reddet';
rejectBtn.addEventListener('click', async () => {
await peer.rejectPair().catch(() => {});
bar.remove();
this._setStatus('', `${peer.socketId.slice(-8)} reddedildi`);
});
const acceptBtn = document.createElement('button');
acceptBtn.className = 'mwse-btn mwse-btn--primary';
acceptBtn.textContent = 'Kabul Et';
acceptBtn.addEventListener('click', async () => {
acceptBtn.textContent = '…';
acceptBtn.disabled = true;
const ok = await peer.acceptPair().catch(() => false);
bar.remove();
if (ok) {
this._watchIncoming(peer);
this._view.refresh();
this._setStatus('online', `${peer.socketId.slice(-8)} kabul edildi`);
} else {
this._setStatus('error', 'Eşleşme kurulamadı');
}
});
actions.append(rejectBtn, acceptBtn);
bar.append(msg, actions);
this._notifArea.appendChild(bar);
}
// ── Kök kolon ─────────────────────────────────────────────────────────────
_pushRootColumn() {
const items = [
{
icon: '●',
label: 'Eşler',
meta: () => {
const n = this.mwse.pairs.size;
return n ? `${n} aktif eşleşme` : 'Henüz eş yok';
},
onSelect: () => { this._view.popTo(1); this._pushPeersColumn(); }
},
{
icon: '#',
label: 'Odalar',
meta: () => {
const n = this.mwse.rooms.size;
return n ? `${n} oda` : 'Oda yok';
},
onSelect: () => { this._view.popTo(1); this._pushRoomsColumn(); }
},
{
icon: '◎',
label: 'Kameralar',
meta: () => `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'video'); }
},
{
icon: '♪',
label: 'Mikrofonlar',
meta: () => `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'audio'); }
}
];
this._view.pushColumn('Studio', items, { searchable: false });
}
// ── Eşler kolonu ──────────────────────────────────────────────────────────
_pushPeersColumn() {
const pairs = [...this.mwse.pairs.values()];
const items = pairs.map(peer => ({
icon: peer.rtc?.active ? '◉' : '●',
label: peer.socketId,
meta: () => this._peerMeta(peer),
onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); }
}));
if (!items.length) {
items.push({
icon: '—', label: 'Henüz eş yok',
meta: '"ID ile ara" → eş socket ID\'sini gir', hasChildren: false
});
}
const col = this._view.pushColumn('Eşler', items);
col.addAction('ID ile ara', '', () => {
this._showModal({
title: 'Eşe bağlan',
fields: [
{ key: 'id', label: 'Socket ID', placeholder: 'xxxxxxxx-xxxx-…' }
],
confirm: 'Bağlan',
onConfirm: ({ id }) => {
if (!id) return;
this.mwse.peer(id).requestPair()
.then(ok => {
if (ok) this._setStatus('', `${id.slice(-8)}'e istek gönderildi — kabul bekleniyor`);
else this._setStatus('error', 'İstek gönderilemedi');
})
.catch(e => this._setStatus('error', e.message));
}
});
});
}
_peerMeta(peer) {
const streams = peer.rtc?._streams?.list() ?? [];
if (streams.length) return `p2p · ${streams.length} akış`;
if (peer.rtc?.active) return `p2p · ${peer.rtc.connectionStatus}`;
return 'websocket';
}
// ── Eş eylem kolonu ───────────────────────────────────────────────────────
_pushPeerColumn(peer) {
const streams = peer.rtc?._streams?.list() ?? [];
const items = [
{
icon: '▶', label: 'Video + Ses', meta: 'Kamera ve mikrofon',
onSelect: () => this._call(peer, 'cam+mic')
},
{
icon: '♪', label: 'Sesli Ara', meta: 'Yalnızca mikrofon',
onSelect: () => this._call(peer, 'mic')
},
{
icon: '⬜', label: 'Ekran Paylaş', meta: 'getDisplayMedia',
onSelect: () => this._call(peer, 'screen')
},
{
icon: '◎', label: 'Kamera Seç',
meta: `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); }
},
{
icon: '♪', label: 'Mikrofon Seç',
meta: `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); }
},
{
icon: '↗', label: 'Dosya Gönder', meta: 'P2P DataChannel',
hasChildren: false,
onSelect: () => this._sendFile(peer)
}
];
if (streams.length) {
items.push({
icon: '◉',
label: `Aktif Akışlar (${streams.length})`,
meta: streams.map(s => s.label).join(' · '),
onSelect: () => { this._view.popTo(3); this._pushStreamsColumn(peer); }
});
}
items.push({
icon: '⊘', label: 'Eşleşmeyi Bitir', meta: '', hasChildren: false,
onSelect: async () => {
await peer.endPair().catch(() => {});
this._view.popTo(1);
this._pushPeersColumn();
}
});
this._view.pushColumn(peer.socketId.slice(-12), items, { searchable: false });
}
// ── Odalar kolonu ─────────────────────────────────────────────────────────
_pushRoomsColumn() {
const items = [...this.mwse.rooms.entries()].map(([id, room]) => ({
icon: '#',
label: room.config?.name ?? id,
meta: () => `${room.peers.size} üye`,
onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room); }
}));
if (!items.length) {
items.push({
icon: '—', label: 'Oda yok', meta: '"Oda Oluştur" ile başla', hasChildren: false
});
}
const col = this._view.pushColumn('Odalar', items);
col.addAction('Oda Oluştur', 'mwse-btn--primary', () => {
this._showModal({
title: 'Yeni Oda',
fields: [
{ key: 'name', label: 'Oda adı', placeholder: 'genel' },
{ key: 'desc', label: 'Açıklama (opsiyonel)', placeholder: 'Genel sohbet odası' },
{ key: 'pass', label: 'Şifre (opsiyonel)', placeholder: 'boş = şifresiz' }
],
confirm: 'Oluştur',
onConfirm: async ({ name, desc, pass }) => {
if (!name) return;
const room = this.mwse.room({
name,
description: desc || name,
joinType: pass ? 'password' : 'free',
accessType: 'public',
ifexistsJoin: true,
notifyActionJoined: true,
notifyActionEjected: true,
notifyActionInvite: false,
...(pass ? { password: pass } : {})
});
await room.createRoom();
this._setStatus('online', `"${name}" odası oluşturuldu`);
this._view.popTo(1);
this._pushRoomsColumn();
}
});
});
}
_pushRoomMembersColumn(room) {
const items = [...room.peers.values()].map(peer => ({
icon: peer.selfSocket ? '★' : '●',
label: peer.socketId,
meta: () => this._peerMeta(peer),
onSelect: () => { this._view.popTo(3); this._pushPeerColumn(peer); }
}));
if (!items.length) {
items.push({ icon: '—', label: 'Üye yok', meta: '', hasChildren: false });
}
const roomName = room.config?.name ?? room.roomId;
const col = this._view.pushColumn(roomName, items);
col.addAction('Odadan Çık', 'mwse-btn--danger', async () => {
await room.eject().catch(() => {});
this._view.popTo(1);
this._pushRoomsColumn();
});
}
// ── Cihazlar kolonu ───────────────────────────────────────────────────────
_pushDevicesColumn(peer, kind) {
const list = kind === 'audio' ? this._devices.microphones : this._devices.cameras;
const title = kind === 'audio' ? 'Mikrofonlar' : 'Kameralar';
const items = list.map(dev => ({
icon: kind === 'audio' ? '♪' : '◎',
label: dev.label || `Cihaz ${dev.deviceId.slice(-6)}`,
meta: dev.deviceId.slice(-8),
onSelect: async () => {
const constraints = kind === 'audio'
? { audio: { deviceId: { exact: dev.deviceId } } }
: { video: { deviceId: { exact: dev.deviceId } } };
const stream = await (kind === 'audio'
? MediaSources.microphone(constraints)
: MediaSources.camera(constraints)
).catch(e => { this._setStatus('error', e.message); return null; });
if (!stream) return;
if (peer) {
this._ensureRTC(peer);
peer.rtc.addStream(dev.label || dev.deviceId.slice(-6), stream);
this._view.popTo(4);
this._pushStreamsColumn(peer);
} else {
this._previewStream(stream, dev.label || title);
}
}
}));
if (!items.length) {
items.push({
icon: '—', label: 'Cihaz bulunamadı',
meta: '"İzin İste" butonunu dene', hasChildren: false
});
}
const col = this._view.pushColumn(title, items);
col.addAction('İzin İste', '', async () => {
await navigator.mediaDevices.getUserMedia(
kind === 'audio' ? { audio: true } : { video: true }
).catch(() => {});
await this._loadDevices();
this._view.popTo(this._view.depth - 1);
this._pushDevicesColumn(peer, kind);
});
}
// ── Akışlar kolonu ────────────────────────────────────────────────────────
_pushStreamsColumn(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}${t.enabled ? '' : ' (sessiz)'}`).join(' + '),
onSelect: () => { this._view.popTo(4); this._pushQualityColumn(peer, src.label, src); }
}));
if (!items.length) {
items.push({ icon: '—', label: 'Akış yok', meta: '', hasChildren: false });
}
this._view.pushColumn('Akışlar', items);
}
// ── Kalite / kontrol ─────────────────────────────────────────────────────
_pushQualityColumn(peer, label, src) {
const presets = [
{ icon: '↑', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
{ icon: '—', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
{ icon: '↓', label: 'Düşük', 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)
}));
for (const track of (src.tracks ?? [])) {
items.push({
icon: track.enabled ? '⊙' : '○',
label: `${track.kind === 'video' ? 'Video' : 'Ses'} ${track.enabled ? 'Sustur' : 'Aç'}`,
meta: '', hasChildren: false,
onSelect: () => { peer.rtc?.setEnabled(label, track.kind, !track.enabled); this._view.refresh(); }
});
}
items.push({
icon: '✕', label: 'Akışı Durdur', meta: '', hasChildren: false,
onSelect: () => { peer.rtc?.removeStream(label); this._view.popTo(3); this._pushStreamsColumn(peer); }
});
this._view.pushColumn('Kalite', items, { searchable: false });
}
// ── WebRTC yardımcıları ───────────────────────────────────────────────────
async _call(peer, type) {
this._ensureRTC(peer);
let stream;
try {
if (type === 'cam+mic') stream = await MediaSources.cameraAndMic();
else if (type === 'mic') stream = await MediaSources.microphone();
else if (type === 'screen') stream = await MediaSources.screen();
} catch (e) {
this._setStatus('error', e.message);
return;
}
peer.rtc.addStream(type, stream);
this._setStatus('online', `${type} → ${peer.socketId.slice(-8)}`);
this._view.popTo(3);
this._pushStreamsColumn(peer);
}
_sendFile(peer) {
this._ensureRTC(peer);
const input = document.createElement('input');
input.type = 'file';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
this._setStatus('', `${file.name} gönderiliyor…`);
peer.rtc.files.on('progress', ({ sent, total }) =>
this._setStatus('', `${file.name} %${Math.round(sent / total * 100)}`)
);
await peer.rtc.sendFile(file).catch(e => this._setStatus('error', e.message));
this._setStatus('online', `${file.name} gönderildi`);
});
input.click();
}
_ensureRTC(peer) {
if (peer.rtc?._pc) return;
const polite = this.mwse.me.socketId < peer.socketId;
peer.rtc.connect({ polite });
}
_watchIncoming(peer) {
peer.rtc.on('track', track => {
if (track.kind === 'audio') {
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = new MediaStream([track]);
document.body.appendChild(audio);
this._remoteMedia.push(audio);
}
this._setStatus('online', `← ${peer.socketId.slice(-8)} ${track.kind} gönderdi`);
});
}
_previewStream(stream, label) {
document.getElementById('mwse-preview')?.remove();
const wrap = document.createElement('div');
wrap.id = 'mwse-preview';
Object.assign(wrap.style, {
position: 'fixed', bottom: '12px', right: '12px', width: '220px',
background: '#111', border: '1px solid #333', borderRadius: '6px',
overflow: 'hidden', zIndex: '9000'
});
const bar = document.createElement('div');
Object.assign(bar.style, {
padding: '4px 8px', fontSize: '11px', color: '#aaa', background: '#1a1a1a',
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
});
const closeBtn = document.createElement('span');
closeBtn.textContent = '✕'; closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', () => { stream.getTracks().forEach(t => t.stop()); wrap.remove(); });
bar.append(document.createTextNode(label), closeBtn);
const video = document.createElement('video');
video.autoplay = true; video.muted = true; video.playsInline = true;
video.srcObject = stream;
video.style.cssText = 'width:100%;display:block;background:#000';
wrap.append(bar, video);
document.body.appendChild(wrap);
}
// ── Modal ─────────────────────────────────────────────────────────────────
_showModal({ title, fields = [], confirm = 'Tamam', onConfirm }) {
const overlay = document.createElement('div');
overlay.className = 'mwse-modal-overlay';
const modal = document.createElement('div');
modal.className = 'mwse-modal';
const header = document.createElement('div');
header.className = 'mwse-modal__header';
const titleEl = document.createElement('span');
titleEl.textContent = title;
const closeX = document.createElement('span');
closeX.className = 'mwse-modal__close';
closeX.textContent = '✕';
closeX.addEventListener('click', () => overlay.remove());
header.append(titleEl, closeX);
const body = document.createElement('div');
body.className = 'mwse-modal__body';
const inputs = {};
for (const f of fields) {
const wrap = document.createElement('div');
wrap.className = 'mwse-modal__field';
const lbl = document.createElement('label');
lbl.textContent = f.label;
const inp = document.createElement('input');
inp.className = 'mwse-modal__input';
inp.placeholder = f.placeholder ?? '';
inp.type = f.type ?? 'text';
inputs[f.key] = inp;
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmBtn.click();
if (e.key === 'Escape') overlay.remove();
});
wrap.append(lbl, inp);
body.appendChild(wrap);
}
const footer = document.createElement('div');
footer.className = 'mwse-modal__footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'mwse-btn';
cancelBtn.textContent = 'İptal';
cancelBtn.addEventListener('click', () => overlay.remove());
const confirmBtn = document.createElement('button');
confirmBtn.className = 'mwse-btn mwse-btn--primary';
confirmBtn.textContent = confirm;
confirmBtn.addEventListener('click', () => {
const values = {};
for (const [k, el] of Object.entries(inputs)) values[k] = el.value.trim();
overlay.remove();
onConfirm(values);
});
footer.append(cancelBtn, confirmBtn);
modal.append(header, body, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
setTimeout(() => Object.values(inputs)[0]?.focus(), 50);
}
// ── Yardımcılar ──────────────────────────────────────────────────────────
async _loadDevices() {
try { this._devices = await MediaSources.devices(); }
catch (_) { this._devices = { cameras: [], microphones: [] }; }
}
_setStatus(cls, text) {
if (!this._statusEl) return;
this._statusEl.className = [
'mwse-studio__status',
cls ? `mwse-studio__status--${cls}` : ''
].join(' ').trim();
this._statusEl.textContent = text;
}
_injectStyle() {
if (this._styleOK) return;
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/studio/style.css';
document.head.appendChild(link);
this._styleOK = true;
}
}