新しくなったSkyWayをkintoneで試してみた

SkyWayが新しくなったとのことでサンプルコードを元にkintoneに組み込んでみました。

kintoneでビデオ会議は需要あると思うんですけどどうなんでしょう。
ちなみにkintoneの開発者ライセンスは無料なので持ってない方は是非。

ほぼ以下のサンプルコードです。kintone用に少しだけ改変しました。
https://github.com/skyway/js-sdk/tree/main/examples/auto-subscribe

あらかじめSkyWayのアプリケーションIDとシークレットキーはご用意ください。

まずはkintoneのアプリにSkyWayのコントロール要素を全てぶち込むために「video-container」という要素IDのスペースを作成してください。
![スクリーンショット 2023-07-15 061333.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22714/e7f92224-8eaa-0347-1959-07a59f836b82.jpeg)


今回はskywayのライブラリをCDNで以下を読み込みます
https://cdn.jsdelivr.net/npm/@skyway-sdk/room/dist/skyway_room-latest.js

![スクリーンショット 2023-07-15 061206.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22714/0bf98ee6-0271-f3b6-fabf-c81c83f0d3e0.jpeg)

コード解説はchatGPTにお願いしました。

```javascript: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も少しだけ

```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;
}


```
![スクリーンショット 2023-07-17 200312.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22714/78bfe937-ac95-50a5-23c0-7b32c05beb61.jpeg)

動いた！ばんざーい