// Firebase 設定は firebase-config.js で初期化済みとする // グローバル: firebase, database // 状態定数 const JoinState = { WAIT: 'wait', CONSENT: 'consent', REFUSAL: 'refusal' }; const P2pState = { UNKNOWN: 'unknown', SUCCESSFUL: 'successful', FAILED: 'failed' }; new Vue({ el: '#app', vuetify: new Vuetify(), data: { teacherEmail: '', className: '授業クラス', // クライアントリスト clients: [], // 表示モード dispMode: 'status', // 'status' or 'desktop' // ロックダイアログ showLockDlg: false, lockType: 'image', lockImage: 'image1', lockText: '', lockFColor: '#ffffff', lockBColor: '#000000', timeLimited: false, lockLimitedTipe: 'rdoLockTime', lockTime: '00:05:00', lockMoment: '24:59:59', // WebRTC関連 localStream: null, // 画面共有用ストリーム peerConnections: new Map(), // mailAddress -> RTCPeerConnection // Firebase参照 dbRef: null, // ルーム情報 roomRef: null, // 自身のシグナリングノードキー mySigKey: null, // 授業中フラグ classActive: false, // その他 selectedCount: 0, canShare: false }, computed: { statusClass() { return (client) => { if (!client.power) return 'status-offline'; if (client.joinState === 'consent') return 'status-online'; if (client.joinState === 'wait') return 'status-wait'; return 'status-offline'; }; }, statusText() { return (client) => { if (!client.power) return 'オフライン'; if (client.joinState === 'consent') return '参加中'; if (client.joinState === 'wait') return '待機中'; if (client.joinState === 'refusal') return '拒否'; return '不明'; }; } }, watch: { dispMode(val) { // モード切替時に各クライアントの画面取得要求を調整 this.clients.forEach(c => { if (c.p2p === 'successful' && c.power && c.joinState === 'consent') { if (val === 'desktop') { this.requestCapStream(c.mailAddress, true); } else { this.requestCapStream(c.mailAddress, false); } } }); } }, methods: { // ========== 初期化・接続 ========== async initializeSystem() { if (!this.teacherEmail || !this.className) { alert('教師メールアドレスとクラス名を入力してください'); return; } // Firebaseパスを生成 const domain = this.teacherEmail.split('@')[1]; const safeDomain = domain.replace(/\./g, '_'); const roomPath = `classes/${safeDomain}/${this.className}`; this.dbRef = firebase.database().ref(); this.roomRef = this.dbRef.child(roomPath); // シグナリングノードを自身用に作成 const myNode = this.roomRef.child('controller').push(); this.mySigKey = myNode.key; await myNode.set({ email: this.teacherEmail, type: 'controller', timestamp: Date.now() }); // 生徒リストの監視 this.roomRef.child('students').on('child_added', (snap) => { const student = snap.val(); this.addClient(student.mail, student.dispName, student.key); }); this.roomRef.child('students').on('child_removed', (snap) => { const mail = snap.val().mail; this.removeClient(mail); }); // シグナリングメッセージの受信 this.roomRef.child('signaling').on('child_added', (snap) => { const msg = snap.val(); if (msg.to === this.teacherEmail) { this.handleSignalingMessage(msg); snap.ref.remove(); // 処理後削除 } }); // 定期的な状態更新要求 setInterval(() => { this.requestAllStatus(); }, 10000); alert('初期化完了。生徒が接続するのを待っています。'); // 既存の生徒がいる場合のために、既存ノードも読み込む const snapshot = await this.roomRef.child('students').once('value'); const students = snapshot.val() || {}; Object.values(students).forEach(s => { if (!this.clients.find(c => c.mailAddress === s.mail)) this.addClient(s.mail, s.dispName, s.key); }); }, addClient(mail, dispName, key) { if (this.clients.find(c => c.mailAddress === mail)) return; const client = { mailAddress: mail, dispName: dispName, key: key, selected: true, power: false, joinState: JoinState.WAIT, p2p: P2pState.UNKNOWN, activeTab: { title: '', favicon: '' }, battery: { level: 0, charging: false }, mouseLock: false, urlLimit: false, pVer: '', cVer: '' }; this.clients.push(client); this.updateSelectedCount(); // WebRTC接続を試みる(生徒が参加状態になったら) this.setupPeerConnection(mail); }, removeClient(mail) { const idx = this.clients.findIndex(c => c.mailAddress === mail); if (idx !== -1) { this.closePeerConnection(mail); this.clients.splice(idx, 1); this.updateSelectedCount(); } }, updateSelectedCount() { this.selectedCount = this.clients.filter(c => c.selected).length; }, toggleSelect(client) { client.selected = !client.selected; this.updateSelectedCount(); }, allSelect() { this.clients.forEach(c => c.selected = true); this.updateSelectedCount(); }, allUnselect() { this.clients.forEach(c => c.selected = false); this.updateSelectedCount(); }, // ========== WebRTC ========== async setupPeerConnection(mail) { const config = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; const pc = new RTCPeerConnection(config); this.peerConnections.set(mail, pc); // ローカルストリームがあれば追加(画面共有時) if (this.localStream) { this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream); }); } pc.ontrack = (event) => { const stream = event.streams[0]; const videoEl = document.getElementById(`video_${mail}`); if (videoEl) videoEl.srcObject = stream; // クライアントのcapResをtrueに const client = this.clients.find(c => c.mailAddress === mail); if (client) client.capRes = true; }; pc.onicecandidate = (event) => { if (event.candidate) { this.sendSignalingMessage(mail, { type: 'candidate', candidate: event.candidate }); } }; pc.onconnectionstatechange = () => { const client = this.clients.find(c => c.mailAddress === mail); if (pc.connectionState === 'connected') { if (client) client.p2p = P2pState.SUCCESSFUL; } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { if (client) client.p2p = P2pState.FAILED; this.closePeerConnection(mail); setTimeout(() => this.setupPeerConnection(mail), 5000); } }; // データチャネル(コマンド送信用) const dc = pc.createDataChannel('command'); dc.onopen = () => console.log('DataChannel open', mail); dc.onmessage = (ev) => this.handleCommandResponse(mail, ev.data); // Offerを作成して送信(controllerからoffer) const offer = await pc.createOffer(); await pc.setLocalDescription(offer); this.sendSignalingMessage(mail, { type: 'offer', sdp: offer.sdp }); }, closePeerConnection(mail) { const pc = this.peerConnections.get(mail); if (pc) { pc.close(); this.peerConnections.delete(mail); } const videoEl = document.getElementById(`video_${mail}`); if (videoEl) videoEl.srcObject = null; }, sendSignalingMessage(toMail, message) { const msg = { from: this.teacherEmail, to: toMail, type: message.type, data: message }; this.roomRef.child('signaling').push(msg); }, async handleSignalingMessage(msg) { const { from, data } = msg; const pc = this.peerConnections.get(from); if (!pc) return; if (data.type === 'offer') { await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: data.sdp })); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); this.sendSignalingMessage(from, { type: 'answer', sdp: answer.sdp }); } else if (data.type === 'answer') { await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: data.sdp })); } else if (data.type === 'candidate') { await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); } }, // ========== 画面共有 ========== async startDesktopShare() { try { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); this.localStream = stream; // 全選択中のクライアントに対してトラックを置き換え for (const client of this.clients.filter(c => c.selected)) { const pc = this.peerConnections.get(client.mailAddress); if (pc && pc.connectionState === 'connected') { const senders = pc.getSenders(); const videoSender = senders.find(s => s.track && s.track.kind === 'video'); if (videoSender) { await videoSender.replaceTrack(stream.getVideoTracks()[0]); } else { stream.getTracks().forEach(track => pc.addTrack(track, stream)); } } } this.canShare = true; // 画面提示開始コマンド送信 this.sendCommandToSelected('start_desktop_share', { enabled: true }); } catch (err) { console.error('画面共有開始エラー:', err); alert('画面共有の開始に失敗しました: ' + err.message); } }, stopDesktopShare() { if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; } this.sendCommandToSelected('stop_desktop_share', { enabled: false }); this.canShare = false; }, requestCapStream(mail, enable) { // 解像度やフレームレートの要求 const constraint = enable ? { rate: 0.5, width: 320 } : { rate: 0, width: 0 }; this.sendCommand(mail, 'set_cap', constraint); }, // ========== コマンド送信 (DataChannel経由) ========== sendCommand(mail, cmd, params) { const pc = this.peerConnections.get(mail); if (!pc) return; const dc = this.getDataChannel(pc); if (dc && dc.readyState === 'open') { dc.send(JSON.stringify({ cmd, params })); } else { // フォールバック: Firebaseシグナリングで送信(簡易) this.sendSignalingMessage(mail, { type: 'command', cmd, params }); } }, sendCommandToSelected(cmd, params) { this.clients.filter(c => c.selected).forEach(c => { this.sendCommand(c.mailAddress, cmd, params); }); }, getDataChannel(pc) { // 簡易実装: 最初のデータチャネルを返す const channels = pc.getDataChannels?.() || []; if (channels.length) return channels[0]; return null; }, handleCommandResponse(mail, data) { try { const msg = JSON.parse(data); if (msg.cmd === 'status') { this.updateClientStatus(mail, msg.params); } else if (msg.cmd === 'lock_ack') { console.log('ロック適用確認', mail); } } catch(e) {} }, updateClientStatus(mail, status) { const client = this.clients.find(c => c.mailAddress === mail); if (!client) return; client.power = status.power === true; client.joinState = status.joinState || JoinState.WAIT; client.activeTab = status.activeTab || { title: '', favicon: '' }; client.battery = status.battery || { level: 0, charging: false }; client.pVer = status.pVer || ''; client.cVer = status.cVer || ''; if (status.p2p) client.p2p = status.p2p; }, async requestAllStatus() { this.sendCommandToSelected('get_status', {}); }, // ========== ロック機能 ========== openLockDialog() { this.showLockDlg = true; }, executeLock() { const lockParams = { type: this.lockType, image: this.lockType === 'image' ? this.lockImage : null, text: this.lockText, font_color: this.lockFColor, bg_color: this.lockBColor, timeLimited: this.timeLimited, limitTime: this.timeLimited ? (this.lockLimitedTipe === 'rdoLockTime' ? this.lockTime : this.lockMoment) : null }; this.sendCommandToSelected('lock', lockParams); this.showLockDlg = false; }, unlock() { this.sendCommandToSelected('unlock', {}); }, // ========== デバイス情報取得 ========== fetchDeviceInfo() { this.sendCommandToSelected('get_device_info', {}); }, refreshStatus() { this.requestAllStatus(); } } });