| |
| |
|
|
| |
| 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', |
| |
| showLockDlg: false, |
| lockType: 'image', |
| lockImage: 'image1', |
| lockText: '', |
| lockFColor: '#ffffff', |
| lockBColor: '#000000', |
| timeLimited: false, |
| lockLimitedTipe: 'rdoLockTime', |
| lockTime: '00:05:00', |
| lockMoment: '24:59:59', |
| |
| localStream: null, |
| peerConnections: new Map(), |
| |
| 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; |
| } |
| |
| 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(); |
| |
| 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(); |
| }, |
| |
| |
| 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; |
| |
| 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); |
| |
| |
| 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); |
| }, |
| |
| |
| 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 { |
| |
| 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(); |
| } |
| } |
| }); |