MWSE/public/studio/Studio.js

701 lines
31 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ü.
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 = null; // mount() içinde oluşturulur
this._devices = { cameras: [], microphones: [] };
this._statusEl = null;
this._notifArea = null;
this._myIPEl = null;
this._myUUIDEl = null;
this._localGrid = null; // "Gönderiyorum" tile grid'i
this._remoteGrid = null; // "Geliyor" tile grid'i
this._streamsPanel = null;
this._styleOK = false;
}
async mount() {
this._el.classList.add('mwse-studio');
this._injectStyle();
this._buildToolbar();
this._buildNotifArea();
// Ana alan: kolonlar (sol) + akış monitörü (sağ)
const mainArea = document.createElement('div');
mainArea.className = 'mwse-studio__main';
this._el.appendChild(mainArea);
this._view = new ColumnView(mainArea);
this._view.mount();
this._streamsPanel = this._buildStreamsPanel(mainArea);
await this._loadDevices();
this._pushRootColumn();
// Sanal IP al
this.mwse.scope(async () => {
try {
const ip = await this.mwse.virtualPressure.allocAPIPAddress();
this.mwse.me.info.set('ip', ip);
if (this._myIPEl) this._myIPEl.textContent = ip;
if (this._myUUIDEl) this._myUUIDEl.textContent = this.mwse.me.socketId.slice(-8);
} catch (_) {}
});
// Gelen eşleme isteği → bildirim banner
this.mwse.me.on('request/pair', peer => this._showPairRequest(peer));
// Eşleşme onaylandı
this.mwse.me.on('accepted/pair', peer => {
this._watchIncoming(peer);
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`);
});
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);
// ID kartı: sadece IP + kısa UUID + kopyala
const idCard = document.createElement('div');
idCard.className = 'mwse-id-card';
idCard.title = 'Socket ID kopyala (bağlantı paylaşımı için)';
this._myIPEl = document.createElement('span');
this._myIPEl.className = 'mwse-id-card__ip';
this._myIPEl.textContent = '●'; // IP gelmeden önce nokta
this._myUUIDEl = document.createElement('span');
this._myUUIDEl.className = 'mwse-id-card__value';
this._myUUIDEl.textContent = '…';
const copyIcon = document.createElement('span');
copyIcon.className = 'mwse-id-card__copy';
copyIcon.textContent = '⎘';
idCard.append(this._myIPEl, this._myUUIDEl, copyIcon);
idCard.addEventListener('click', () => {
const id = this.mwse.me.socketId;
if (!id) return;
navigator.clipboard.writeText(id).then(() => {
copyIcon.textContent = '✓';
idCard.classList.add('mwse-id-card--copied');
setTimeout(() => {
idCard.classList.remove('mwse-id-card--copied');
copyIcon.textContent = '⎘';
}, 2000);
});
});
bar.appendChild(idCard);
this._statusEl = document.createElement('span');
this._statusEl.className = 'mwse-studio__status mwse-studio__status--online';
this._statusEl.innerHTML = '<span class="mi mi-sm">wifi</span> Bağlı';
bar.appendChild(this._statusEl);
// Canlı saat
const clock = document.createElement('span');
clock.className = 'mwse-studio__clock';
const tick = () => {
const now = new Date();
clock.textContent = now.toTimeString().slice(0, 8);
};
tick(); setInterval(tick, 1000);
bar.appendChild(clock);
this._el.insertBefore(bar, this._el.firstChild);
}
// ── Akış monitör paneli ───────────────────────────────────────────────────
_buildStreamsPanel(parent) {
const panel = document.createElement('div');
panel.className = 'mwse-streams-panel';
panel.style.display = 'none'; // akış yokken gizli
// Gönderiyorum bölümü
const outSec = document.createElement('div');
outSec.className = 'mwse-streams-section';
const outTitle = document.createElement('div');
outTitle.className = 'mwse-streams-section__title';
outTitle.textContent = '▲ Gönderiyorum';
this._localGrid = document.createElement('div');
this._localGrid.className = 'mwse-streams-grid';
outSec.append(outTitle, this._localGrid);
// Geliyor bölümü
const inSec = document.createElement('div');
inSec.className = 'mwse-streams-section';
const inTitle = document.createElement('div');
inTitle.className = 'mwse-streams-section__title';
inTitle.textContent = '▼ Geliyor';
this._remoteGrid = document.createElement('div');
this._remoteGrid.className = 'mwse-streams-grid';
inSec.append(inTitle, this._remoteGrid);
panel.append(outSec, inSec);
parent.appendChild(panel);
return panel;
}
// Yerel (gönderilen) akış tile'ı ekle
_addLocalTile(label, stream, peerLabel) {
const hasVideo = stream.getVideoTracks().length > 0;
const tile = this._makeTile(
label,
`${peerLabel}`,
stream,
hasVideo,
true, // muted (geri besleme yok)
() => tile.remove() && this._updatePanelVisibility()
);
this._localGrid.appendChild(tile);
this._updatePanelVisibility();
}
// Uzak (gelen) track tile'ı ekle
_addRemoteTile(track, peerLabel) {
const isVideo = track.kind === 'video';
const stream = new MediaStream([track]);
const tile = this._makeTile(
isVideo ? 'Video' : 'Ses',
`${peerLabel}`,
stream,
isVideo,
!isVideo, // ses tile'ı için muted değil (çalması lazım)
() => tile.remove() && this._updatePanelVisibility()
);
// Ses için görünmez <audio> da ekle
if (!isVideo) {
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = stream;
document.body.appendChild(audio);
}
this._remoteGrid.appendChild(tile);
this._updatePanelVisibility();
// Track bitince tile'ı kaldır
track.addEventListener('ended', () => {
tile.remove();
this._updatePanelVisibility();
});
}
_makeTile(label, peerInfo, stream, isVideo, muted, onClose) {
const tile = document.createElement('div');
tile.className = `mwse-stream-tile${isVideo ? '' : ' mwse-stream-tile--audio'}`;
if (isVideo) {
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = muted;
video.srcObject = stream;
video.className = 'mwse-stream-tile__video';
tile.appendChild(video);
} else {
const icon = document.createElement('div');
icon.className = 'mwse-stream-tile__audio-icon';
icon.textContent = '♪';
tile.appendChild(icon);
}
const info = document.createElement('div');
info.className = 'mwse-stream-tile__info';
const lbl = document.createElement('span');
lbl.className = 'mwse-stream-tile__label';
lbl.textContent = label;
const peer = document.createElement('span');
peer.className = 'mwse-stream-tile__peer';
peer.textContent = peerInfo;
const closeBtn = document.createElement('span');
closeBtn.className = 'mwse-stream-tile__close';
closeBtn.textContent = '✕';
closeBtn.title = 'Kapat';
closeBtn.addEventListener('click', e => { e.stopPropagation(); onClose(); });
info.append(lbl, peer, closeBtn);
tile.appendChild(info);
return tile;
}
_updatePanelVisibility() {
const hasLocal = this._localGrid.childElementCount > 0;
const hasRemote = this._remoteGrid.childElementCount > 0;
this._streamsPanel.style.display = (hasLocal || hasRemote) ? '' : 'none';
}
// ── Gelen istek bildirim alanı ────────────────────────────────────────────
_buildNotifArea() {
this._notifArea = document.createElement('div');
this._notifArea.className = 'mwse-notif-area';
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);
if (this.mwse.virtualPressure.APIPAddress) {
this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress);
}
this._view.popTo(1);
this._pushPeersColumn();
this._setStatus('online', `${this._peerLabel(peer)} 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: 'people', label: 'Eşler',
meta: () => { const n = this.mwse.pairs.size; return n ? `${n} eşleşme` : 'Henüz yok'; },
onSelect: () => { this._view.popTo(1); this._pushPeersColumn(); }
},
{
icon: 'meeting_room', label: 'Odalar',
meta: () => { const n = this.mwse.rooms.size; return n ? `${n} oda` : 'Oda yok'; },
onSelect: () => { this._view.popTo(1); this._pushRoomsColumn(); }
},
{
icon: 'videocam', label: 'Kameralar',
meta: () => `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'video'); }
},
{
icon: 'mic', 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 ? 'sensors' : 'person',
label: this._peerLabel(peer),
meta: () => this._peerMeta(peer),
onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); }
}));
if (!items.length) {
items.push({
icon: 'person_off', label: 'Henüz eş yok',
meta: '"ID ile ara" butonunu kullan', hasChildren: false
});
}
const col = this._view.pushColumn('Eşler', items);
col.addAction('<span class="mi mi-sm">person_search</span> 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 => ok
? this._setStatus('', `${id.slice(-8)}'e istek gönderildi`)
: this._setStatus('error', 'İstek gönderilemedi'))
.catch(e => this._setStatus('error', e.message));
}
});
});
}
_peerLabel(peer) {
return peer.info?.info?.ip || peer.socketId.slice(-8);
}
_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: 'videocam', label: 'Video + Ses', meta: 'Kamera ve mikrofon', onSelect: () => this._call(peer, 'cam+mic') },
{ icon: 'mic', label: 'Sesli Ara', meta: 'Yalnızca mikrofon', onSelect: () => this._call(peer, 'mic') },
{ icon: 'screen_share', label: 'Ekran Paylaş', meta: 'getDisplayMedia', onSelect: () => this._call(peer, 'screen') },
{ icon: 'switch_video', label: 'Kamera Seç', meta: `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); } },
{ icon: 'settings_voice', label: 'Mikrofon Seç', meta: `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); } },
{ icon: 'upload_file', label: 'Dosya Gönder', meta: 'P2P DataChannel', hasChildren: false,
onSelect: () => this._sendFile(peer) }
];
if (streams.length) {
items.push({
icon: 'live_tv', label: `Aktif Akışlar (${streams.length})`,
meta: streams.map(s => s.label).join(' · '),
onSelect: () => { this._view.popTo(3); this._pushStreamsColumn(peer); }
});
}
items.push({ icon: 'link_off', 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(([, room]) => ({
icon: 'meeting_room',
label: room.config?.name ?? room.roomId,
meta: () => `${room.peers.size} üye`,
onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room); }
}));
if (!items.length) items.push({ icon: 'block', label: 'Oda yok', meta: '"Oda Oluştur" butonunu kullan', hasChildren: false });
const col = this._view.pushColumn('Odalar', items);
col.addAction('<span class="mi mi-sm">add</span> 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}" oluşturuldu`);
this._view.popTo(1); this._pushRoomsColumn();
}
});
});
}
_pushRoomMembersColumn(room) {
const myIP = this.mwse.virtualPressure.APIPAddress;
const items = [...room.peers.values()].map(peer => ({
icon: peer.selfSocket ? 'star' : 'person',
label: peer.selfSocket ? (myIP || '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 col = this._view.pushColumn(room.config?.name ?? room.roomId, items);
col.addAction('<span class="mi mi-sm">exit_to_app</span> Çı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' ? 'mic' : 'videocam',
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._addLocalTile(label, stream, this._peerLabel(peer));
this._view.popTo(4); this._pushStreamsColumn(peer);
} else {
this._previewStream(stream, dev.label || title);
}
}
}));
if (!items.length) items.push({ icon: 'perm_media', label: 'Cihaz bulunamadı', meta: '"İzin İste" butonunu kullan', hasChildren: false });
const col = this._view.pushColumn(title, items);
col.addAction('<span class="mi mi-sm">lock_open</span> İ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 / Kalite kolonları ────────────────────────────────────────────
_pushStreamsColumn(peer) {
const srcs = peer.rtc?._streams?.list() ?? [];
const items = srcs.map(src => ({
icon: src.tracks[0]?.kind === 'video' ? 'live_tv' : 'graphic_eq',
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);
}
_pushQualityColumn(peer, label, src) {
const presets = [
{ icon: 'hd', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
{ icon: 'sd', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
{ icon: 'signal_cellular_1_bar', 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
? (track.kind === 'video' ? 'videocam_off' : 'mic_off')
: (track.kind === 'video' ? 'videocam' : 'mic'),
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: 'stop_circle', 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; }
if (peer.rtc._streams?.has(type)) peer.rtc.removeStream(type);
peer.rtc.addStream(type, stream);
this._addLocalTile(type, stream, this._peerLabel(peer));
this._setStatus('online', `${type}${this._peerLabel(peer)}`);
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;
peer.rtc.connect({ polite: this.mwse.me.socketId < peer.socketId });
}
// Gelen track'leri izle → panel'e ekle
_watchIncoming(peer) {
peer.rtc.on('track', track => {
this._addRemoteTile(track, this._peerLabel(peer));
this._setStatus('online', `${this._peerLabel(peer)} ${track.kind}`);
});
}
_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 close = document.createElement('span');
close.textContent = '✕'; close.style.cursor = 'pointer';
close.addEventListener('click', () => { stream.getTracks().forEach(t => t.stop()); wrap.remove(); });
bar.append(document.createTextNode(label), close);
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;
}
}