// 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(); // Scope sonrası sanal IP al ve kendime ata. this.mwse.scope(async () => { try { const ip = await this.mwse.virtualPressure.allocAPIPAddress(); // PeerInfo.set → auth/info → mevcut tüm pairlere yayımlar. this.mwse.me.info.set('ip', ip); this._updateMyIP(ip); } catch (_) {} }); // ── 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); // Yeni paire kendi IP'mizi paylaş. if (this.mwse.virtualPressure.APIPAddress) { this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress); } this._view.refresh(); this._setStatus('online', `${this._peerLabel(peer)} eşleşmesi kuruldu`); }); // Pair info değişince (karşı tarafın IP'si gelince) kolonları yenile. this.mwse.me.on('accepted/pair', () => this._view.refresh()); this.mwse.on('room', () => this._view.refresh()); this.mwse.me.on('end/pair', () => this._view.refresh()); return this; } // Toolbar'daki IP/ID kartını güncelle. _updateMyIP(ip) { if (this._myIPEl) this._myIPEl.textContent = ip; } // Bir peer için görünen ad: sanal IP varsa onu, yoksa kısa UUID. _peerLabel(peer) { return peer.info?.info?.ip || peer.socketId.slice(-8); } // ── 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 IP + ID kartı (tıkla → UUID kopyalanır) const idCard = document.createElement('div'); idCard.className = 'mwse-id-card'; idCard.title = 'Socket ID\'yi kopyala (paylaşmak için)'; const idLabel = document.createElement('span'); idLabel.className = 'mwse-id-card__label'; idLabel.textContent = 'Kimliğim'; // Sanal IP (önce boş, allocAPIPAddress sonrası dolar) this._myIPEl = document.createElement('span'); this._myIPEl.className = 'mwse-id-card__ip'; this._myIPEl.textContent = ''; // Kısa UUID (ID paylaşımı için) 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, this._myIPEl, 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.slice(-8); }); 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); // Kabul eden tarafta Eşler kolonuna yönlendir. this._view.popTo(1); this._pushPeersColumn(); 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: this._peerLabel(peer), 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() ?? []; const id = peer.socketId.slice(-8); if (streams.length) return `${id} · p2p · ${streams.length} akış`; if (peer.rtc?.active) return `${id} · p2p`; return `${id} · 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(this._peerLabel(peer), 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.selfSocket ? (this.mwse.virtualPressure.APIPAddress || 'Ben') : this._peerLabel(peer), 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); const label = dev.label || dev.deviceId.slice(-6); if (peer.rtc._streams?.has(label)) peer.rtc.removeStream(label); peer.rtc.addStream(label, 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; } // Aynı label zaten varsa önce durdur (kullanıcı ikinci kez tıkladı). if (peer.rtc._streams?.has(type)) peer.rtc.removeStream(type); 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; } }