wib / main.js
izuemon's picture
Create main.js
5ca0b1e verified
Raw
History Blame Contribute Delete
16.1 kB
// 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();
}
}
});