SkyWayが新しくなったとのことで。
JavaScript SDKのチュートリアルも公開されているので、さっそくReactバージョンを作ってみるとしましょう。
使用したライブラリ・フレームワークとバージョン
- npm: v8.19.2
- react: v18.2.0
- typescript: v4.9.5
- create-react-app で入ってきたバージョン
- @skyway-sdk/room: 1.4.1
アカウントとアプリケーションの準備
SkyWayのアカウントを作成する。
旧SkyWayのアカウントではログインできなかったので、どうやらアカウント管理が別のようです。
アカウントを作ったらコンソールにログインし、
- 新規プロジェクトを作成
- プロジェクト内に、新規アプリケーションを作成
- アプリケーションIDとシークレットキーをコピーしておく
Reactプロジェクト準備
create-react-appを使います。もちろんTypeScriptです。
$ npx create-react-app skyway-react-sample --template typescript
作成された skyway-react-sample
フォルダに移動して npm run start
し、http://localhost:3000/
でサイトが起動することを確認しておく。
skywayのライブラリをインストール。
$ npm i @skyway-sdk/room
これでプロジェクト準備は完了。
画面の準備
適当な名前のコンポーネント MainContent.tsx
を作成。
export const MainContent = () => {
return (
<div>main!</div>
);
}
App.tsx
と App.css
の中身をごそっと消して、 App.tsx
には MainContent
だけ書いておく。
import './App.css';
import { MainContent } from './MainContent';
function App() {
return (
<MainContent />
);
}
export default App;
起動。
あとは先ほどの SkyWay のアプリケーションIDとシークレットキーを環境変数として読み込めるようにしておきましょう。
プロジェクトルートに .env
ファイルを作成。
REACT_APP_SKYWAY_APP_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
REACT_APP_SKYWAY_SECRET_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
MainContent
コンポーネント内の先頭で読ませておきましょう。
const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);
これで準備OK。
あとはチュートリアルに沿って画面を作っていきましょう。
画面作成
SkyWay Auth Token 生成
MainContent の最初で useMemo
でtokenを作っておく。
const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);
const token = useMemo(() => {
if (appId == null || secretKey == null) return undefined;
return new SkyWayAuthToken({
jti: uuidV4(),
iat: nowInSec(),
exp: nowInSec() + 60 * 60 * 24,
scope: {
app: {
id: appId,
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(secretKey);
}, [appId, secretKey]);
画面レイアウト
MainContent
からreturnするDOMを以下に置きかえる。
return (
<div>
<p>ID: </p>
<div>
room name: <input type="text" />
<button>join</button>
</div>
<video ref={localVideo} width="400px" muted playsInline></video>
<div>{/* TODO ここに他の人のメディア */}</div>
</div>
);
自分の映像を映す video
要素を参照するために useRef
を作っておく。
先ほどの token
作ったとこの下に。
const localVideo = useRef<HTMLVideoElement>(null);
自分の映像を映す
ページがロードされて、token
が生成されたら、自分のカメラ映像&音声を取得して video
に映します。
// ローカスストリームをここに保持する
const [localStream, setLocalStream] = useState<{
audio: LocalAudioStream;
video: LocalVideoStream;
}>();
// tokenとvideo要素の参照ができたら実行
useEffect(() => {
const initialize = async () => {
if (token == null || localVideo.current == null) return;
const stream =
await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
stream.video.attach(localVideo.current);
await localVideo.current.play();
setLocalStream(stream);
};
initialize();
}, [token, localVideo]);
ここまでで起動すると、画面に自分のカメラ映像が映るはずです。
ルームに参加
ルーム名を入力して参加ボタンをクリックすることで、ルームを作成 or ルームに参加できるようにしましょう。
以下のStateを作っておきます。
// ルーム名
const [ roomName, setRoomName ] = useState("");
// 自分自身の参加者情報
const [ me, setMe ] = useState<LocalP2PRoomMember>();
続いて、参加ボタンが押せるかどうかの状態。
const canJoin = useMemo(() => {
return roomName !== "" && localStream != null && me == null;
}, [roomName, localStream, me]);
最後に、参加ボタンを押したときの処理です。
const onJoinClick = useCallback(async () => {
// canJoinまでにチェックされるので普通は起きない
// assertionメソッドにしてもいい
if (localStream == null || token == null) return;
const context = await SkyWayContext.Create(token);
// ルームを取得、または新規作成
const room = await SkyWayRoom.FindOrCreate(context, {
type: 'p2p',
name: roomName,
});
const me = await room.join();
setMe(me);
// 映像と音声を配信
await me.publish(localStream.video);
await me.publish(localStream.audio);
// TODO 他の参加者の購読
}, [roomName, token, localStream]);
これらをDOMに組み込みます。
return (
<div>
<p>ID: {me?.id ?? ""}</p>
<div>
room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
<button onClick={onJoinClick} disabled={!canJoin}>join</button>
</div>
<video ref={localVideo} width="400px" muted playsInline></video>
<div>{/* TODO ここに他の人のメディア */}</div>
</div>
);
ルーム名を入れて参加ボタンを押し、ID欄にIDっぽいものが表示されればOK。
他の参加者の音声・映像を取得
ルームに参加したら、その時点でルームに参加している他のメンバーの情報と、その後に参加してきたメンバーの情報を取得する必要がある。
まずはそれを入れておくためのStateを用意。
const [ otherUserPublications, setOtherUserPublications ] = useState<RoomPublication<LocalStream>[]>([]);
先ほど「TODO 他の参加者の購読」としておいたところで、この処理を追加。
// 自分以外の参加者情報を取得
setOtherUserPublications(room.publications.filter(p => p.publisher.id !== me.id));
// その後に参加してきた人の情報を取得
room.onStreamPublished.add((e) => {
if (e.publication.publisher.id !== me.id) {
setOtherUserPublications(pre => [ ...pre, e.publication ]);
}
});
この参加者情報を使って他ユーザの映像・音声を表示するためのコンポーネントを作っておきましょう。
export const RemoteMedia = (props: {
me: LocalP2PRoomMember,
publication: RoomPublication<LocalStream>
}) => {
// TODO
return <div>{props.publication.id}</div>;
};
publicationの数だけこれをMainContentに配置。
return (
<div>
<p>ID: {me?.id ?? ""}</p>
<div>
room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
<button onClick={onJoinClick} disabled={!canJoin}>join</button>
</div>
<video ref={localVideo} width="400px" muted playsInline></video>
<div>
{
me != null && otherUserPublications.map(p => (
<RemoteMedia key={p.id} me={me} publication={p} />
))
}
</div>
</div>
);
続いて RemoteMedia
の中身です。
まずは購読したstreamを入れるためのStateを作っておきます。
const [ stream, setStream ] = useState<RemoteVideoStream | RemoteAudioStream>();
購読する前は購読ボタンを表示する。
if (stream == null) {
return (
<div>
<button onClick={onSubscribeClick}>
{props.publication.publisher.id}: {props.publication.contentType}
</button>
</div>
)
}
クリック時のcallbackでstreamを生成。
const onSubscribeClick = useCallback(async () => {
const { stream } = await props.me.subscribe(props.publication.id);
// video または audio であることを確認
if (!("track" in stream)) return;
setStream(stream);
}, [ props.publication, props.me ]);
このとき、subscribe で取得される stream
がTypeScriptの型付け的には RemoteVideoStream | RemoteAudioStream | RemoteDataStream
になっていて、 RemoteDataStream
はこの場合想定しない(と思う)ので、チェックしておきます。
たぶん本来はunsub処理とか入れるべきだと思う。
streamの種類によって、video
または audio
要素を表示する。
useRef を作っておいて。
const refVideo = useRef<HTMLVideoElement>(null);
const refAudio = useRef<HTMLAudioElement>(null);
DOMで指定。
// 映像のとき
if (stream.contentType === "video") {
return <video width="400px" playsInline={true} autoPlay={true} ref={refVideo} />;
}
// 音声のとき
return <audio controls={true} autoPlay={true} ref={refAudio} />;
stream にこの video/audio をアタッチします。
useEffect(() => {
if (stream == null) return;
if (refVideo.current != null) {
stream.attach(refVideo.current);
} else if (refAudio.current != null) {
stream.attach(refAudio.current);
}
}, [stream, refVideo, refAudio]);
複数ブラウザを立ち上げて、同じルーム名で入室すれば、映像と音声のsubscribeボタンが表示されます。
クリックで映像と音声のコントロールが表示されればOK!
ここまででチュートリアルの内容としては終わりです。
コード全体
import {
LocalAudioStream,
LocalP2PRoomMember,
LocalStream,
LocalVideoStream,
RoomPublication,
SkyWayAuthToken,
SkyWayContext,
SkyWayRoom,
SkyWayStreamFactory,
nowInSec,
uuidV4,
} from "@skyway-sdk/room";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RemoteMedia } from "./RemoteMedia";
export const MainContent = () => {
const [localStream, setLocalStream] = useState<{
audio: LocalAudioStream;
video: LocalVideoStream;
}>();
const [roomName, setRoomName] = useState("");
const [me, setMe] = useState<LocalP2PRoomMember>();
const [ otherUserPublications, setOtherUserPublications ] = useState<RoomPublication<LocalStream>[]>([]);
const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);
const token = useMemo(() => {
if (appId == null || secretKey == null) return undefined;
return new SkyWayAuthToken({
jti: uuidV4(),
iat: nowInSec(),
exp: nowInSec() + 60 * 60 * 24,
scope: {
app: {
id: appId,
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(secretKey);
}, [appId, secretKey]);
const localVideo = useRef<HTMLVideoElement>(null);
useEffect(() => {
const initialize = async () => {
if (token == null || localVideo.current == null) return;
const stream = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
stream.video.attach(localVideo.current);
await localVideo.current.play();
setLocalStream(stream);
};
initialize();
}, [token, localVideo]);
const canJoin = useMemo(() => {
return roomName !== "" && localStream != null && me == null;
}, [roomName, localStream, me]);
const onJoinClick = useCallback(async () => {
if (localStream == null || token == null) return;
const context = await SkyWayContext.Create(token);
const room = await SkyWayRoom.FindOrCreate(context, {
type: "p2p",
name: roomName,
});
const me = await room.join();
setMe(me);
await me.publish(localStream.video);
await me.publish(localStream.audio);
setOtherUserPublications(room.publications.filter(p => p.publisher.id !== me.id));
room.onStreamPublished.add((e) => {
if (e.publication.publisher.id !== me.id) {
setOtherUserPublications(pre => [ ...pre, e.publication ]);
}
});
}, [roomName, token, localStream]);
return (
<div>
<p>ID: {me?.id ?? ""}</p>
<div>
room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
<button onClick={onJoinClick} disabled={!canJoin}>join</button>
</div>
<video ref={localVideo} width="400px" muted playsInline></video>
<div>
{
me != null && otherUserPublications.map(p => (
<RemoteMedia key={p.id} me={me} publication={p} />
))
}
</div>
</div>
);
};
import { LocalP2PRoomMember, LocalStream, RemoteAudioStream, RemoteVideoStream, RoomPublication } from "@skyway-sdk/room";
import { useCallback, useEffect, useRef, useState } from "react";
export const RemoteMedia = (props: {
me: LocalP2PRoomMember,
publication: RoomPublication<LocalStream>
}) => {
const [ stream, setStream ] = useState<RemoteVideoStream | RemoteAudioStream>();
const refVideo = useRef<HTMLVideoElement>(null);
const refAudio = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (stream == null) return;
if (refVideo.current != null) {
stream.attach(refVideo.current);
} else if (refAudio.current != null) {
stream.attach(refAudio.current);
}
}, [stream, refVideo, refAudio]);
const onSubscribeClick = useCallback(async () => {
const { stream } = await props.me.subscribe(props.publication.id);
// video または audio であることを確認
if (!("track" in stream)) return;
setStream(stream);
}, [ props.publication, props.me ]);
if (stream == null) {
return (
<div>
<button onClick={onSubscribeClick}>
{props.publication.publisher.id}: {props.publication.contentType}
</button>
</div>
)
}
// 映像のとき
if (stream.contentType === "video") {
return <video width="400px" playsInline={true} autoPlay={true} ref={refVideo} />;
}
// 音声のとき
return <audio controls={true} autoPlay={true} ref={refAudio} />;
};
以上
ここまではけっこう直感的で簡単ですね。
実際のアプリケーションに組み込む場合には、退室時の処理とかネットワーク切断時とか、もう少しケアの必要な部分があると思いますが、開発者ドキュメントは充実してそうです。
個人的にももうちょっと遊んでみたいかも!