MWSE/public/studio/Studio.js

511 lines
20 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';
const logo = document.createElement('span');
logo.className = 'mwse-studio__title';
logo.innerHTML = 'MWSE <span style="color:#0078d4">Studio</span>';
bar.appendChild(logo);
const idEl = document.createElement('span');
idEl.className = 'mwse-studio__status';
idEl.style.fontFamily = 'monospace';
idEl.textContent = '…';
this.mwse.me.on('scope', () => { idEl.textContent = this.mwse.me.socketId; });
bar.appendChild(idEl);
this._statusEl = document.createElement('span');
this._statusEl.className = 'mwse-studio__status mwse-studio__status--online';
this._statusEl.textContent = 'Bağlı';
bar.appendChild(this._statusEl);
// Kök'e dönme butonu
const homeBtn = document.createElement('button');
homeBtn.className = 'mwse-btn';
homeBtn.style.cssText = 'margin-left:auto;padding:3px 10px;font-size:11px';
homeBtn.textContent = '⌂ Ana';
homeBtn.addEventListener('click', () => {
this._view.popTo(0);
this._pushRootColumn();
});
bar.appendChild(homeBtn);
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', '', () => {
const id = prompt('Eş socket ID:');
if (!id?.trim()) return;
this.mwse.peer(id.trim()).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: 'room.createRoom() ile oluştur', hasChildren: false
});
}
this._view.pushColumn('Odalar', items);
}
_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;
}
_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;
}
}