SkyWayが新しくなったとのことでサンプルコードを元にkintoneに組み込んでみました。
kintoneでビデオ会議は需要あると思うんですけどどうなんでしょう。
ちなみにkintoneの開発者ライセンスは無料なので持ってない方は是非。
ほぼ以下のサンプルコードです。kintone用に少しだけ改変しました。
https://github.com/skyway/js-sdk/tree/main/examples/auto-subscribe
あらかじめSkyWayのアプリケーションIDとシークレットキーはご用意ください。
まずはkintoneのアプリにSkyWayのコントロール要素を全てぶち込むために「video-container」という要素IDのスペースを作成してください。
今回はskywayのライブラリをCDNで以下を読み込みます
https://cdn.jsdelivr.net/npm/@skyway-sdk/room/dist/skyway_room-latest.js
コード解説はchatGPTにお願いしました。
script.js
const { nowInSec, SkyWayAuthToken, SkyWayContext, SkyWayRoom, SkyWayStreamFactory, uuidV4 } = skyway_room;
const token = new SkyWayAuthToken({
jti: uuidV4(),
iat: nowInSec(),
exp: nowInSec() + 60 * 60 * 24,
scope: {
app: {
id: 'アプリケーションIDをここに入力',
turn: true,
actions: ['read'],
channels: [
{
id: '*',
name: '*',
actions: ['write'],
members: [
{
id: '*',
name: '*',
actions: ['write'],
publication: {
actions: ['write'],
},
subscription: {
actions: ['write'],
},
},
],
sfuBots: [
{
actions: ['write'],
forwardings: [{ actions: ['write'] }],
},
],
},
],
},
},
}).encode('シークレットキーをここに入力');
kintone.events.on('app.record.detail.show', (event) => {
(async () => {
let videoContainer = kintone.app.record.getSpaceElement('video-container');
const recordId = kintone.app.record.getId();
const user = kintone.getLoginUser();
// joinTrigger
const joinTrigger = document.createElement('div');
joinTrigger.id = 'js-join-trigger';
joinTrigger.innerText = 'ルームに参加';
videoContainer.appendChild(joinTrigger);
// leaveTrigger
const leaveTrigger = document.createElement('div');
leaveTrigger.id = 'js-leave-trigger';
leaveTrigger.innerText = 'ルームを退出';
videoContainer.appendChild(leaveTrigger);
// channelName
const channelName = document.createElement('div');
channelName.id = 'js-channel-name';
videoContainer.appendChild(channelName);
// roomMode
const roomMode = document.createElement('div');
roomMode.id = 'js-room-type';
// select要素を作成
const selectElement = document.createElement("select");
// option要素を作成してselect要素に追加
const option1 = document.createElement("option");
option1.text = "sfu";
option1.value = "sfu";
selectElement.add(option1);
const option2 = document.createElement("option");
option2.text = "p2p";
option2.value = "p2p";
selectElement.add(option2);
roomMode.appendChild(selectElement);
videoContainer.appendChild(roomMode);
// messages
const messages = document.createElement('div');
messages.id = 'js-messages';
videoContainer.appendChild(messages);
// localStream
const localStream = document.createElement('video');
localStream.id = 'js-local-stream';
videoContainer.appendChild(localStream);
// remoteVideos
const remoteVideos = document.createElement('div');
remoteVideos.id = 'js-remote-streams';
videoContainer.appendChild(remoteVideos);
// 初期値を設定
let selectedValue = "sfu";
// イベントリスナーを追加して選択された値を取得
selectElement.addEventListener("change", function() {
selectedValue = selectElement.value;
});
const localVideo = document.getElementById('js-local-stream');
const { audio, video } =
//マイクとカメラからのオーディオとビデオのストリームを作成します。
//戻り値は、オーディオとビデオのストリームが含まれたオブジェクトです。
await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
//ビデオ要素の音声をミュートに設定しています。
localVideo.muted = true;
//ビデオをインライン再生するための設定です。
localVideo.playsInline = true;
//取得したビデオストリームを video 要素にアタッチします。
video.attach(localVideo);
//ビデオの再生を開始しています。
await localVideo.play();
//コンテキスト(制御情報)の作成
const context = await SkyWayContext.Create(token, {
log: { level: 'warn', format: 'object' },
});
let room;
// Register join handler
joinTrigger.addEventListener('click', async () => {
if (room) {
return;
}
//コンテキストを使用してルームを検索し、存在しない場合は新しいルームを作成します。
room = await SkyWayRoom.FindOrCreate(context, {
name: 'room' + recordId,
type: selectedValue,
});
//作成または検索されたルームに参加します。
const member = await room.join();
messages.textContent += '=== You joined ===\n';
//メンバーがルームに参加したときに実行される関数
room.onMemberJoined.add((e) => {
messages.textContent += `=== ${e.member.id.slice(0, 5)} joined ===\n`;
});
const userVideo = {};
//このMemberがStreamをSubscribeしたときに発火するイベント
member.onPublicationSubscribed.add(async ({ stream, subscription }) => {
//データチャネルの場合は、処理を終了します。
if (stream.contentType === 'data') return;
//パブリッシャー(ストリームを公開したメンバー)のIDを取得
const publisherId = subscription.publication.publisher.id;
//パブリッシャーのIDが存在しない場合は、処理を終了します。
if (!userVideo[publisherId]) {
//新しいvideo要素を作成します。
const newVideo = document.createElement('video');
//ビデオをインライン再生するための設定です。
newVideo.playsInline = true;
//ビデオの再生を開始しています。
newVideo.autoplay = true;
//パブリッシャーのIDをdata-member-id属性に設定しています。
newVideo.setAttribute(
'data-member-id',
subscription.publication.publisher.id
);
//新しいvideo要素をremoteVideosに追加します。
remoteVideos.append(newVideo);
//userVideoにパブリッシャーのIDをキーとして、新しいvideo要素を設定します。
userVideo[publisherId] = newVideo;
}
//userVideoに設定された新しいvideo要素を取得します。
const newVideo = userVideo[publisherId];
//取得したストリームを新しいvideo要素にアタッチします。
stream.attach(newVideo);
//subscription.contentTypeが 'video'であり、room.typeが 'sfu'である場合
if (subscription.contentType === 'video' && room.type === 'sfu') {
//新しいvideo要素をクリックしたときに実行される関数
newVideo.onclick = () => {
if (subscription.preferredEncoding === 'low') {
subscription.changePreferredEncoding('high');
} else {
subscription.changePreferredEncoding('low');
}
};
}
});
//のストリームを購読するための関数
const subscribe = async (publication) => {
//自身が公開したストリームを購読することを避ける
if (publication.publisher.id === member.id) return;
//購読する
await member.subscribe(publication.id);
};
//room.onStreamPublishedに購読する関数を追加します。
room.onStreamPublished.add((e) => subscribe(e.publication));
//room.publicationsに購読する関数を追加します。
room.publications.forEach(subscribe);
//メンバーがルームから離脱したときに実行される関数
await member.publish(audio);
//room.typeが 'sfu'である場合
if (room.type === 'sfu') {
//メンバーがルームに参加したときに実行される関数
await member.publish(video, {
//エンコーディングの設定
encodings: [
//最大ビットレートが 10,000 であるエンコーディングを設定します。
{ maxBitrate: 10_000, id: 'low' },
//最大ビットレートが 800,000 であるエンコーディングを設定します。
{ maxBitrate: 800_000, id: 'high' },
],
});
} else {
//メンバーがルームに参加したときに実行される関数
await member.publish(video);
}
//ビデオ要素を破棄するための関数
const disposeVideoElement = (remoteVideo) => {
//メディアストリームを取得
const stream = remoteVideo.srcObject;
//メディアストリームのトラックを停止
stream.getTracks().forEach((track) => track.stop());
//メディアストリームを破棄
remoteVideo.srcObject = null;
//ビデオ要素を削除
remoteVideo.remove();
};
//メンバーがルームから離脱したときに実行される関数
room.onMemberLeft.add((e) => {
//自身がルームから離脱した場合は、処理を終了します。
if (e.member.id === member.id) return;
//remoteVideosからdata-member-id属性がe.member.idの要素を取得します。
const remoteVideo = remoteVideos.querySelector(
`[data-member-id="${e.member.id}"]`
);
//remoteVideoが存在しない場合は、処理を終了します。
disposeVideoElement(remoteVideo);
//メッセージを表示します。
messages.textContent += `=== ${e.member.id.slice(0, 5)} left ===\n`;
});
//メンバーがルームから離脱したときに実行される関数
member.onLeft.once(() => {
//remoteVideosの子要素を取得します。
Array.from(remoteVideos.children).forEach((element) => {
//ビデオ要素を破棄する
disposeVideoElement(element);
});
//メッセージを表示します。
messages.textContent += '== You left ===\n';
//ルームを破棄します。
room.dispose();
//roomをundefinedに設定します。
room = undefined;
});
//メンバーがルームから離脱したときに実行される関数
leaveTrigger.addEventListener('click', () => member.leave(), {
once: true,
});
});
})();
});
CSSも少しだけ
style.css
#js-join-trigger {
width: 100px;
background-color: #000;
color: #fff;
text-align: center;
padding: 10px;
display: inline-block;
cursor: pointer;
margin: 0px 10px 20px 0px;
}
#js-leave-trigger {
width: 100px;
background-color: #000;
color: #fff;
text-align: center;
padding: 10px;
display: inline-block;
cursor: pointer;
margin: 0px 10px 20px 0px;
}
#js-room-type {
margin: 0px 10px 20px 0px;
}
#js-local-stream {
width: 400px;
}
#js-local-stream {
width: 400px;
}
#js-remote-streams {
width: 400px;
}
#js-messages {
white-space: pre-wrap;
}
動いた!ばんざーい