MWSE/public/studio/Studio.js

645 lines
25 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ü.
// Miller kolonlar: Giriş → Eşler / Odalar / Cihazlar → Eylemler → Akışlar → Kalite
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._styleOK = false;
this._remoteMedia = [];
}
async mount() {
this._el.classList.add('mwse-studio');
this._injectStyle();
this._buildToolbar();
this._view.mount();
// Cihazları yükle
await this._loadDevices();
// Kök kolon
this._pushRootColumn();
// Gelen eşleme isteği bildirimi
this.mwse.me.on('request/pair', peer => {
this._setStatus('', `${peer.socketId.slice(-8)}… bağlanmak istiyor`);
});
// Yeni eşleşme → gelen akışları dinle + kolon yenile
this.mwse.me.on('accepted/pair', peer => {
this._watchIncoming(peer);
this._view.refresh();
});
this.mwse.me.on('end/pair', () => this._view.refresh());
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 <span style="color:#0078d4">Studio</span>';
bar.appendChild(logo);
// Benim ID kartı — kopyalanabilir
const idCard = document.createElement('div');
idCard.className = 'mwse-id-card';
idCard.title = 'Socket ID\'yi kopyala';
const idLabel = document.createElement('span');
idLabel.className = 'mwse-id-card__label';
idLabel.textContent = 'ID';
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 = '⎘';
}, 1800);
});
});
this.mwse.me.on('scope', () => {
idValue.textContent = this.mwse.me.socketId;
});
bar.appendChild(idCard);
// Durum mesajı
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);
}
// ── 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 ─────────────────────────────────────────────────────────────────
_pushPeersColumn() {
const pairs = [...this.mwse.pairs.values()];
const items = pairs.map(peer => ({
icon: peer.peerConnection ? '◉' : '●',
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: 'Başka bir istemci bağlanınca görünür', 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(() => this._setStatus('', `${id.slice(-8)}'e istek gönderildi`))
.catch(e => this._setStatus('error', e.message));
}
});
});
}
_peerMeta(peer) {
const streams = peer.rtc?._streams?.list() ?? [];
if (streams.length) return `p2p · ${streams.length} akış aktif`;
if (peer.rtc?.active) return `p2p · ${peer.rtc.connectionStatus}`;
return 'websocket';
}
// ── Eş eylem kolonu ───────────────────────────────────────────────────────
_pushPeerColumn(peer) {
const streams = peer.rtc?._streams?.list() ?? [];
const rtcOn = peer.rtc?.active;
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); }
});
}
if (rtcOn) {
items.push({
icon: '↺',
label: 'WebRTC Yeniden Başlat',
meta: '',
hasChildren: false,
onSelect: () => { peer.rtc.destroy(); this._ensureRTC(peer); this._view.refresh(); }
});
}
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 ───────────────────────────────────────────────────────────────
_pushRoomsColumn() {
const items = [...this.mwse.rooms.entries()].map(([id, room]) => ({
icon: '#',
label: id,
meta: () => `${room.peers.size} üye`,
onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room, id); }
}));
if (!items.length) {
items.push({
icon: '—', label: 'Oda yok', meta: 'Aşağıdan oluştur', 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: 'pass', label: 'Şifre (opsiyonel)', placeholder: 'boş bırak = şifresiz' }
],
confirm: 'Oluştur',
onConfirm: async ({ name, pass }) => {
if (!name) return;
const room = this.mwse.room({
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, roomId) {
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 });
}
this._view.pushColumn(roomId, items);
}
// ── Cihazlar ─────────────────────────────────────────────────────────────
_pushDevicesColumn(peer, kind) {
const list = kind === 'audio'
? this._devices.microphones
: this._devices.cameras;
const colTitle = 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 getter = kind === 'audio'
? MediaSources.microphone(constraints)
: MediaSources.camera(constraints);
const stream = await getter.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);
peer.rtc.addStream(label, stream);
this._view.popTo(4);
this._pushStreamsColumn(peer);
} else {
this._previewStream(stream, dev.label || colTitle);
}
}
}));
if (!items.length) {
items.push({
icon: '—', label: 'Cihaz bulunamadı',
meta: 'İzin ver ve sayfayı yenile', hasChildren: false
});
}
const col = this._view.pushColumn(colTitle, items);
// İzin isteme butonu
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 ──────────────────────────────────────────────────────────────
_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 });
}
// Gelen track'leri otomatik çal
_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)} ses gönderdi`);
} else {
this._setStatus('online', `${peer.socketId.slice(-8)} video gönderdi`);
}
});
}
// Cihaz test önizlemesi (floating video)
_previewStream(stream, label) {
const old = document.getElementById('mwse-preview');
if (old) old.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);
}
// ── Cihaz yükleme ─────────────────────────────────────────────────────────
async _loadDevices() {
try {
this._devices = await MediaSources.devices();
} catch (_) {
this._devices = { cameras: [], microphones: [] };
}
}
// ── Durum çubuğu ─────────────────────────────────────────────────────────
_setStatus(cls, text) {
if (!this._statusEl) return;
this._statusEl.className = [
'mwse-studio__status',
cls ? `mwse-studio__status--${cls}` : ''
].join(' ').trim();
this._statusEl.textContent = text;
}
// ── Modal ─────────────────────────────────────────────────────────────────
// Kullanım: this._showModal({ title, fields:[{key,label,placeholder}], confirm, onConfirm })
_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';
// Başlık
const header = document.createElement('div');
header.className = 'mwse-modal__header';
const titleEl = document.createElement('span');
titleEl.textContent = title;
const closeBtn = document.createElement('span');
closeBtn.className = 'mwse-modal__close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', () => overlay.remove());
header.append(titleEl, closeBtn);
// Gövde — alanlar
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);
}
// Alt butonlar
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);
// Arka plana tıkla → kapat
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
// İlk alana odaklan
const first = Object.values(inputs)[0];
if (first) setTimeout(() => first.focus(), 50);
}
_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;
}
}