MWSE/public/studio/Studio.js

670 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <span style="color:#0078d4">Studio</span>';
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 = `<span class="mwse-notif-bar__dot">●</span>`
+ ` <code>${peer.socketId}</code> 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;
}
}