はじめに
LINE Developersから利用できるLIFF(LINE Front-end Framework)を活用すれば、LINEアプリのユーザーを対象に、独自のサービススペースを開設できます。LINEアプリ内でウェブサービスがそのまま実行されるため、LINE公式アカウント(LINE Official Account、以下LINE OA)(参考)は、単なる通知チャンネルにとどまらず、顧客と直接つながるプラットフォームとなります。
私たちはその可能性を実際に検証してみました。LINE PlanetチームのPMであるDeokbeom JeongとAndroidエンジニアのDaekyoung Kang、この2人がウェブエンジニアなしで、LINE OA上で動作するグループビデオ通話サービスを作りました。
この記事は、そのプロセスの全体的な構成と実装上の重要なポイントを、ステップバイステップで実践できるようにまとめた記録です。
LINE OAとLIFFを組み合わせることで、どのようなサービスが作れるか?
LINE OAはユーザーとの接点を、LIFFはその中で動作するウェブ画面を担当します。すでにLINE OAを運用している場合や、LINEアプリのユーザーを対象に新しいサービスの導入を検討している場合は、以下のようなサービスをLINEアプリ内ですぐに開始できます。
- 専門相談:弁護士、ファイナンシャルプランナー、心理カウンセラーなどの専門家が、LINE OAを通じて顧客と1対1のビデオ相談を行うことができます。顧客は、別のアプリをインストールすることなく、LINEアプリ内から直接相談ルームに参加します。
- オンライン授業:講師と生徒がLINE OAを通じて授業を予約し、指定された時間にオンライン授業ルームに直接参加できます。複数の講師が同じLINE OAを使用しても、それぞれ独立した通話が同時に行われます。さらに画面共有機能を活用すれば、資料や問題の解説も一緒に確認できます。
- リアルタイム配信:LINE OAのフォロワーを対象に、LINEアプリ内でリアルタイム配信を行うことができます。基本500人から最大1万人まで同時参加が可能で、小規模なファンミーティングから大規模なライブイベントまで、同じ仕組みで運営できます。Clubhouseのように、聴衆がパネリストとして登壇し、モデレーターとリアルタイムで対話する双方向のコミュニケーション形式も実現できます。そのため、一方的な配信ではなく、コミュニティ中心の対話の場を作り出すことができます。
- ゲーム内のボイスチャット:ゲームプレイ中にチームメンバーと音声でリアルタイムにコミュニケーションをとることができます。LINEの友だちと一緒にゲームを楽しみながら、別のアプリに切り替えることなく、すぐに会話できます。
LINE OAとLIFFで作るグループビデオ通話サービスの概要
グループビデオ通話サービスと聞くと複雑に感じるかもしれませんが、実際に自分で設定する必要があるのは、次の2つのみです。
- ウェブアプリ:LIFF上で動作する、LINE Planet SDKを使用したグループビデオ通話のウェブアプリ
- アプリサーバー:LINE Planetアクセストークンの発行
LINE OA、LIFF、LINE Planetがそれぞれ、最も難しい部分であるLINE認証、WebRTCメディア処理、グローバルネットワークインフラなどを解決してくれます。私たちがやるべきことは、接続の作業のみです。アプリサーバーは、Cloud Functions for Firebaseを活用することで、サーバーインフラの設定なしで素早く構築できます(詳しくは、LINE Planet Docsサイトに掲載されたFirebaseでアプリサーバーを実装するを参照してください)。
各コンポーネントの役割は以下のとおりです。
| コンポーネント | 役割 |
|---|---|
| LINE OA | ユーザーのエントリーポイントを担当、タブからLIFFアプリを起動 |
| LIFF | LINEアプリ内のウェブビューを担当し、LINEログイントークンとユーザープロフィールを渡す |
| ウェブアプリ(ReactとVite) | UIおよびビジネスロジックの担当、通話ルームの 作成・参加、およびメディア制御 |
| アプリサーバー | LINE Planetアクセストークン発行のエンドポイント |
| LINE Planet | WebRTCベースのリアルタイム通信インフラ |
LIFFを活用する理由は明確です。LIFFを活用すれば、LINEアプリがウェブビューを管理し、LINEのログイン情報(userId、displayNameなど)まで自動的に渡してくれます。別の認証サーバーを用意することなく、LINEユーザーを識別できます。Androidでウェブビューを使用してアプリ内でウェブページを表示するのと、似たような概念だと理解すると分かりやすいでしょう。
グループビデオ通話サービスの全体的な構成は以下のとおりです。この中で3番のウェブアプリレイヤー(Web App Layer)と、4番のサーバーレイヤー(Server Layer)のアプリサーバー(App Server)は、自前で構築する必要があります。ウェブアプリレイヤーについては、以下の「開発」セクションで詳しく説明しますが、アプリサーバーについては、この記事では直接取り上げず、参考資料を紹介します。後日、改めて詳しく説明します。

シーケンス図は以下のとおりです。

開発開始前の準備事項
開発を始める前に、いくつか必要な準備があります。
各自で準備する事項
開発環境の確認
この記事のサンプルコードは、Node.js 20 LTS以上の環境でnpmを使用することを前提に作成されています。また、LIFFの特性上、HTTPSによるデプロイ環境が必要であり、ローカルで開発する場合はngrokに置き換えることができます。
LINE DevelopersコンソールおよびLINE OAで必要な事項の準備
LINE OAとLIFFを組み合わせて使用するには、LINE DevelopersコンソールとLINE OA Managerでいくつかの準備が必要です(この記事ではその手順を簡単に紹介しますが、詳しくはLINE Developersドキュメントを参照してください)。
まず、LINE DevelopersコンソールにログインするためにBusiness IDを準備し、ログイン後に開発者アカウントを登録して、今回の作業で使用するプロバイダーを作成します。
その後、LINE OAを作成し、LINE OA ManagerでMessaging APIの利用を有効にします。その際、先ほど作成したプロバイダーを選択します。Messaging APIを有効にすると、そのプロバイダーの下にLINE OAと連携されたMessaging APIチャネルが作成されます。
次に、同じプロバイダーでLINEログインのチャネルを作成し、そのチャネルのLIFFタブで以下のようにアプリを登録します。
| 項目 | 値 |
|---|---|
| LIFF app name | LIFF Call |
| Size | Full |
| Endpoint URL | https://your-app.example.com(仮のURLでWebデプロイを完了した後、実際のURLに更新する) |
| Scope | profile, openid |
| Share Target Picker | ON |
ここでは、以下の2点に注意が必要です。
- 発行されたLIFF ID(例:1234567890-abcdefgh)は、以降のすべての初期化に使用されるため、必ずメモしておいてください。
- その後の開発段階において、LINEの友だち招待(
shareTargetPickerAPI)を動作させるには、LINEログインのチャネルがPublished状態である必要があります。
LINE Planetチームへの依頼事項
次に、LINE Planet ConsoleのアカウントとサービスIDが必要です。これら2つは、LINE Planetチーム(dl_planet_help@linecorp.com)に依頼して取得できます。
開発
事前準備が完了したら、本格的にウェブアプリレイヤーの開発を開始します。ちなみに、この記事ではすべての画面のコードを網羅しているわけではありません。LIFFでLINE Planet SDKを利用してグループビデオ通話ウェブアプリを作成する際に、必ず確認すべき重要な流れと注意点を中心に、コードを提示して解説します。通話のセットアップ、プレビュー、通話画面のUIを含むその他の詳細については、自分で構成する必要があります。
また、添付したコードはデモ目的で作成されたものです。実際のプロダクションに適用する際には、セキュリティやエラー処理、パフォーマンスの最適化についても検討する必要があります。
ステップ1:通話セットアップ時に必要なルームIDの設計と作成
一般的な設定プロセスでは、ユーザーIDやその他の情報をアプリサーバーに別途登録する手順が必要です。一方、LIFFアプリでは、通話前の設定に必要な情報をLIFFが収集し、それをアプリ内部からアプリサーバーに登録できます。そのため、事前のセットアップ手順を簡素化でき、ユーザーは通話前に別途設定することなく、ルームIDを入力するだけで通話が可能になります。
通話ルームを作成・参加する際に使用するルームIDは、さまざまな方法で設計できます。このデモでは、以下のコードのようにランダムな文字列を使用しましたが、サービスの目的に応じて、興味関心に基づいた固定のルームを提供したり、ユーザーグループごとにルームを自動生成したりする方式へと拡張することも可能です。
// 16桁の英数字のルームID生成の例
const generateRoomId = (): string =>
crypto.randomUUID().replace(/-/g, '').slice(0, 16);
// 招待リンクから入った場合、query stringからroomIdを復元
const params = new URLSearchParams(window.location.search);
const roomId = params.get('roomId') ?? generateRoomId();
ステップ2:プレビュー画面の実装
プレビュー画面は、通話ルームに入る前に、カメラとマイクの状態を事前に確認するための画面です。
プレビューページの中核となる実装
一般的なウェブメディアの実装では、getUserMediaを使用しますが、この例ではPlanetKit SDKのMediaStreamManager(以下MSM)として扱います。1つのMSMインスタンスがプレビューからグループ通話(conference)までそのまま継続されるため、ページを切り替える際にカメラやマイクの権限を再リクエストすることはありません。また、マイクのトグル(オン/オフ)は、既存のオーディオトラックのenabledフラグのみを調整するため、モバイルウェブビューで権限要求のプロンプトが再度表示されません(コード内のuseIsMobileDeviceとresolveFacingModeDeviceIdについては次のセクションで説明します)。なお、この例では分かりやすさのため、MSMをコンポーネントローカルのrefに保持しています。実際に複数のページで1つのインスタンスを再利用する場合は、後述の「メディアストリーム管理API」セクションで示すように、シングルトンとして保持してください。
import { useEffect, useRef, useState } from 'react';
import * as PlanetKit from '@line/planet-kit';
import { useIsMobileDevice } from '../hooks/useIsMobileDevice';
import { resolveFacingModeDeviceId } from '../utils/resolveFacingModeDeviceId';
export default function Preview({ onEnter }: { onEnter: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const msmRef = useRef<PlanetKit.MediaStreamManager | null>(null);
const [isReady, setIsReady] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(true);
const [isMicOn, setIsMicOn] = useState(true);
const [facingMode, setFacingMode] = useState<'front' | 'back'>('front');
const isMobileDevice = useIsMobileDevice();
// マウント時にMediaStreamManagerを1回生成
useEffect(() => {
msmRef.current = new PlanetKit.MediaStreamManager();
setIsReady(true);
}, []);
// ビデオon/カメラ切り替え時にMSM経由でストリームを更新
useEffect(() => {
if (!isReady || !isVideoOn) return;
const msm = msmRef.current!;
(async () => {
const videoInputDeviceId = isMobileDevice
? await resolveFacingModeDeviceId(facingMode)
: undefined;
// ストリームがある場合はビデオトラックのみ置き換え、なければ新規作成
const stream = msm.hasVideoStream()
? await msm.changeVideoInputDevice(videoInputDeviceId!)
: await msm.createMediaStream({
videoInputDeviceId,
videoElement: videoRef.current ?? undefined,
});
if (videoRef.current) videoRef.current.srcObject = stream;
})();
}, [isReady, isVideoOn, facingMode, isMobileDevice]);
// マイクのトグルはトラックのenabledのみを調整(権限の再リクエストを防ぐ)
useEffect(() => {
const stream = msmRef.current?.getMediaStream();
stream?.getAudioTracks().forEach((t) => (t.enabled = isMicOn));
}, [isMicOn]);
const flipCamera = () =>
setFacingMode((f) => (f === 'front' ? 'back' : 'front'));
return (
<div className="preview">
<video ref={videoRef} autoPlay playsInline muted />
<div className="controls">
<button onClick={() => setIsVideoOn((v) => !v)}>
{isVideoOn ? "カメラオフ" : "カメラオン"}
</button>
<button onClick={() => setIsMicOn((m) => !m)}>
{isMicOn ? "マイクオフ" : "マイクオン"}
</button>
{/* モバイルデバイスでのみ前面/背面切り替えボタンを表示 */}
{isMobileDevice && (
<button onClick={flipCamera} disabled={!isVideoOn}>
前面/背面切り替え
</button>
)}
<button onClick={onEnter}>参加する</button>
</div>
</div>
);
}
モバイルデバイスでのみ、前面/背面カメラの切り替えボタンを表示する
モバイル版では、前面カメラと背面カメラを切り替えるボタンも提供しています。このとき、ビューポート(viewport)のサイズでモバイルデバイスかどうかを判別すると、デスクトップの小さいウィンドウでも、モバイルデバイスでのみ有効な前面/背面の切り替えボタンが表示されてしまいます。そのため、ビューポートではなく、User Agent(以下UA)に基づいてモバイルデバイスかどうかを判別します。
import { useState, useEffect } from 'react';
export function useIsMobileDevice() {
const [isMobileDevice, setIsMobileDevice] = useState(false);
useEffect(() => {
const ua = navigator.userAgent;
setIsMobileDevice(/Android|iPhone|iPad|iPod/i.test(ua));
}, []);
return isMobileDevice;
}
前面/背面(facingMode)に適したカメラdeviceIdを検索する
PlanetKit SDKのMediaStreamManagerは、facingModeオプションを受け取らずにvideoInputDeviceIdのみを受け取るため、enumerateDevices()のラベルをマッチングしてdeviceIdを逆算します。ラベルはカメラへのアクセス権限が許可された後にのみ設定されるため、最初の呼び出し時にはundefinedを返し、SDKのデフォルトカメラ(通常は前面)に処理を任せます。
export async function resolveFacingModeDeviceId(
facingMode: 'front' | 'back'
): Promise<string | undefined> {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
const backPattern = /back|rear|environment/i;
const frontPattern = /front|user|facetime/i;
// backを先にマッチング — "back user facing"のようなラベルがfrontと誤認識されるのを防ぐ
const matched =
facingMode === 'back'
? videoInputs.find((d) => backPattern.test(d.label))
: videoInputs.find(
(d) => !backPattern.test(d.label) && frontPattern.test(d.label)
);
return matched?.deviceId;
}
ステップ3:LINE Planet SDKとの連携
ここからが重要です。LINE Planet SDKはWebRTCを抽象化して提供するため、開発者はメディア処理やネットワークトラバーサル(network address translation traversal)を直接実装する必要がありません。
このステップは、さらに以下の3つの段階に分けられます。
- LIFFでのユーザー情報の取得
- アプリサーバーからアクセストークンの取得
- LINE Planet SDKを使ってグループ通話に参加
LIFFでのユーザー情報の取得
LIFF SDKを初期化すると、LINEユーザー識別子(userId)と表示名(displayName)を取得できます。これら2つの値は、PlanetKit通話に参加する際、myIdとdisplayNameでそのまま使用します。
import liff from '@line/liff';
interface LiffUser {
userId: string;
displayName: string;
}
export async function initializeAndLogin(liffId: string): Promise<LiffUser | null> {
await liff.init({ liffId });
// LINEアプリではなく、外部のブラウザから入ってきた場合、LINEログインにリダイレクト
if (!liff.isLoggedIn()) {
liff.login();
return null; // リダイレクト直後であるため、呼び出し元は再試行
}
const profile = await liff.getProfile();
return {
userId: profile.userId,
displayName: profile.displayName,
};
}
アプリサーバーからアクセストークンの取得
LINE Planetの通話に参加するには、アクセストークンが必要です。このトークンは、アプリサーバーから取得します。
アプリサーバーは独自に構築する必要があ ります。Firebase Cloud Functionsを活用すれば、サーバーインフラの設定なしで迅速に構築できます。詳しくは、LINE Planet Documentationサイトのブログ記事Firebaseでアプリサーバーを実装するとFirebase公式ガイドを参照してください。
以下は、クライアントがアクセストークンを取得するフローの例です。
// アプリサーバーからアクセストークンを取得する例のフロー
// 実際の実装はアプリサーバーの構築方法によって異なります。
const getAccessToken = async (userId: string, serviceId: string) => {
// アプリサーバーにアクセストークンをリクエストする
const response = await fetch('/api/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, serviceId })
});
const { accessToken } = await response.json();
return accessToken;
};
LINE Planet SDKを利用してグループ通話に参加する
ユーザー情報とアクセストークンの準備ができたら、グループ通話(Conference)インスタンスを作成し、joinConferenceを呼び出します。このとき、ステップ2で作成したMediaStreamManagerをそのまま渡すと、権限の再リクエストなしで、プレビュー画面で使用していたカメラ/マイクのストリームがそのまま通話に引き継がれます。
import * as PlanetKit from '@line/planet-kit';
interface JoinParams {
roomId: string;
serviceId: string; // LINE Planet Consoleから取得
user: { userId: string; displayName: string };
accessToken: string;
mediaStreamManager: PlanetKit.MediaStreamManager;
myVideoElement: HTMLVideoElement;
roomAudioElement: HTMLAudioElement;
}
export async function joinConference(p: JoinParams) {
const conference = new PlanetKit.Conference({ logLevel: 'info' });
await conference.joinConference({
roomId: p.roomId,
myId: p.user.userId,
displayName: p.user.displayName,
myServiceId: p.serviceId,
roomServiceId: p.serviceId,
accessToken: p.accessToken,
mediaType: 'audiovideo',
mediaStreamManager: p.mediaStreamManager,
mediaHtmlElement: {
myVideo: p.myVideoElement,
roomAudio: p.roomAudioElement,
},
delegate: {
evtConnected: () => console.log('[Conference] connected'),
evtDisconnected: (reason) => console.log('[Conference] disconnected', reason),
evtPeerListUpdated: (peers) => console.log('[Conference] peers updated', peers),
// ...必要なイベントハンドラーを追加
},
});
return conference;
}
呼び出し側のフローは、以下のように簡素化されます。
// 1) LIFFでユーザーを識別
const user = await initializeAndLogin(LIFF_ID);
if (!user) return; // ログインリダイレクト中
// 2) アプリサーバーにアクセストークンをリクエスト
const accessToken = await getAccessToken(user.userId, PLANET_SERVICE_ID);
// 3) PlanetKit通話に参加
const conference = await joinConference({
roomId,
serviceId: PLANET_SERVICE_ID,
user,
accessToken,
mediaStreamManager: msm, // ステップ2で作成したインスタンス
myVideoElement: myVideoRef.current!,
roomAudioElement: roomAudioRef.current!,
});
delegateに登録したコールバックを通じて、通話中に発生するイベントを受け取ることができます。上記の例では、イベントをevtConnected(参加済み)、evtDisconnected(終了)、evtPeerListUpdated(他の参加者の参加または退出時に呼び出す)3つのみ用意しましたが、ConferenceDelegateには、マイクやカメラの状態変更、発言状態の変更など、さまざまなイベントが定義されているので、サービスに必要なものを選んで登録すれば良いです。特に、evtPeerListUpdatedは、次のステップ4でグリッドを動的に構成する際にも活用します。
ステップ4:通話画面を動的なグリッドで構成する
グループ通話では、誰かが参加したり退出したりするたびに、グリッドのセル数が変わります。WebPlanetKitはこうした変化をコールバックで通知してくれるため、イベントが発生するたびにレイアウトと映像解像度を同時に調整すれば良いです。ステップ3でjoinConference呼び出し時に登録したevtPeerListUpdatedコールバックがその役割を担います。参加者リストが変更されるたびに呼び出されるため、ここで現在の参加者数を基にグリッドを再配置し、各参加者の映像解像度を再リクエストするのが一般的なパターンです。
モバイルデバイスは画面が小さく、一度に多くのセルを表示することが難しいため、一定の人数を超えた場合は、直近の発言者を優先して表示する方が見やすくなります。例えば、以下のように構成できます。
| 参加者数 | レイアウト例 | 映像解像度 |
|---|---|---|
| 1人 | 全画面表示 | N/A |
| 2人 | 2分割 | VGA |
| 3人 | 上段2名、下段1名は全幅で表示 | VGA |
| 4人 | 2×2のグリッド | VGA |
| 5人以上 | 2×2グリッドを維持(直近の発言者2名、本人、先に表示した3名を除く残りの参加者の情報) | VGA |
セルサイズに合わせて解像度を下げれば、帯域幅とデコードの負荷を同時に軽減できます(推奨解像度と詳しい使用方法については、WebPlanetKitの公式ドキュメントを参照してください)。なお、上記の表は一般的なビデオ会議を想定した例であり、サービスの特性に合わせてUIを自由に構成できます。
ステップ5:LINEの友だち招待機能を使う
他の参加者が対象のルームに直接参加できるよう、招待を送ることができます。招待リンクをクリックすると、LIFFアプリが開き、そのルームIDで自動的に参加することになります。このとき、shareTargetPickerが動作するには、ステップ1で作成したチャネルがPublished状態である必要があります。
以下は、通話を行うために作成したルームにLINEの友だちを招待する機能を使用するコードです。
const inviteFriends = async (roomId: string, liffId: string, displayName: string) => {
// ShareTargetPicker APIの使用可否を確認する
if (!liff.isApiAvailable('shareTargetPicker')) {
alert('この環境では友だち招待機能を使用できません。');
return;
}
// LIFF URLの作成(roomIdを含む)
const shareUrl = `https://liff.line.me/${liffId}?roomId=${encodeURIComponent(roomId)}`;
const shareMessage = `🎥 ${displayName}さんがビデオ通話に招待しました!\n\nルーム: ${roomId}\n\nリンクをタップして参加してください:\n${shareUrl}`;
// ShareTargetPickerの実行
const result = await liff.shareTargetPicker(
[{ type: 'text', text: shareMessage }],
{ isMultiple: true } // 複数人への同時送信が可能
);
if (result) {
console.log('招待メッセージの送信完了');
} else {
console.log('ユーザーによるキャンセル');
}
};
PlanetKitの主なAPIの紹介
前の例で使用したPlanetKit APIと、通話UIを拡張する際によく使われるAPIを簡単にまとめて紹介します。より詳しい内容については、LINE Planet Documentationサイトを参照してください。
メディア制御API
通話中によく使用されるメディア制御APIは、以下のとおりです。なお、以下の例のconferenceはステップ3でnew PlanetKit.Conference()によって生成し、joinConference()で参加したインスタンスです。
- オーディオのミュート/ミュート解除
await conference.muteMyAudio(true); // ミュート
await conference.muteMyAudio(false); // ミュート解除
- ビデオの一時停止/再開
await conference.pauseMyVideo(); // ビデオの一時停止
await conference.resumeMyVideo(); // ビデオの再開
- 相手のビデオリクエスト(グリッドビューで使用):参加者が増えるほど、1つのセルが占めるピクセル数が少なくなるため、グリッドセルのサイズに合わせてresolutionを下げて呼び出すことで、帯域幅を節約できます。
// 例)1:1は'hd'、2x2は'vga'、それ以上は'qvga'
await conference.requestPeerVideo({
userId: peerId,
resolution: 'hd',
videoViewElement: peerVideoElement
});
- 通話終了:
leaveConferenceは同期メソッドです(void返却)。他のメディア制御APIとは異なり、awaitは付けません。
conference.leaveConference();
バーチャル背景API
MediaPipeベースの背景ぼかし機能です。モバイルウェブビューではサポートされていないため、デスクトップブラウザまたはデスクトップ版のLINE環境でのみ表示することを推奨します。
以下は、バーチャル背景を初期化するサンプルコードです。
import type VirtualBackground from '@line/planet-kit-virtual-background';
// VirtualBackgroundのシングルトンインスタンス(PlanetKitService内)
private static virtualBackgroundInstance: VirtualBackground
| null = null;
// シングルトンインスタンスの取得
public static async getVirtualBackgroundInstance(): Promise<VirtualBackground> {
if (!this.virtualBackgroundInstance) {
// 動的インポートでVirtualBackgroundモジュールロード
const VirtualBackgroundModule = await import('@line/planet-kit-virtual-background');
const VirtualBackground = VirtualBackgroundModule.default;
// シングルトンインスタンス生成(MediaPipeのリソースパスを指定)
this.virtualBackgroundInstance = new VirtualBackground({
locateFile: '/mediapipe-resource'
});
}
return this.virtualBackgroundInstance;
}
// MediaStreamManagerまたはConferenceにバーチャル背景を初期化
// target: 'msm'(プレビュー用)または'conference'(通話中)
public async initializeVirtualBackground(target: 'msm' | 'conference'): Promise<void> {
const vbInstance = await PlanetKitService.getVirtualBackgroundInstance();
if (target === 'msm') {
await this.mediaStreamManager.registerVirtualBackground(vbInstance);
await this.mediaStreamManager.waitForVirtualBackgroundInitialization();
} else {
await this.conference.registerVirtualBackground(vbInstance);
await this.conference.waitForVirtualBackgroundInitialization();
}
}
以下は、バーチャル背景のぼかしを有効または無効にするサンプルコードです。
// 背景ぼかしを有効にする(target: 'msm'または'conference')
public async enableVirtualBackgroundBlur(
target: 'msm' | 'conference',
canvasElement?: HTMLCanvasElement,
blurRadius: number = 10
): Promise<boolean> {
try {
if (target === 'msm') {
await this.mediaStreamManager.startVirtualBackgroundBlur(canvasElement, blurRadius);
} else {
await this.conference.startVirtualBackgroundBlur(canvasElement, blurRadius);
}
return true;
} catch (error) {
console.warn('バーチャル背景の有効化に失敗:', error);
return false; // UIでgraceful fallback処理
}
}
// 背景ぼかしを無効にする
public async disableVirtualBackground(target: 'msm' | 'conference'): Promise<boolean> {
try {
if (target === 'msm') {
await this.mediaStreamManager.stopVirtualBackground();
} else {
await this.conference.stopVirtualBackground();
}
return true;
} catch (error) {
console.warn('バーチャル背景の無効化に失敗:', error);
return false;
}
}
以下は、バーチャル背景の使用例です。
const handleVBToggle = async () => {
if (virtualBGEnabled) {
await planetKitService.disableVirtualBackground('msm');
} else {
const success = await planetKitService.enableVirtualBackgroundBlur('msm', canvasRef.current, 15);
if (!success) {
console.warn('VB有効化に失敗、VBなしで続行');
}
}
};
メディアストリーム管理API
プレビュー画面で作成したMediaStreamManagerをグループ通話画面で再利用するために、シングルトンパターンでインスタンスを保持します。PlanetKit SDKのMediaStreamManagerは、メディアストリームの生成、デバイスの変更、バーチャル背景の処理を統合的に管理します。このインスタンスをConferenceに渡すと、SDKは既存のストリームを自動的に再利用します。
実装パターンは以下のとおりです。
// シングルトンサービスの例(アプリ全体で単一のインスタンスを保持)
class MediaService {
private mediaStreamManager: MediaStreamManager | null = null;
async initMediaStreamManager() {
if (!this.mediaStreamManager) {
this.mediaStreamManager = new PlanetKit.MediaStreamManager();
}
return this.mediaStreamManager;
}
getMediaStreamManager() {
return this.mediaStreamManager;
}
releaseMediaStream() {
if (this.mediaStreamManager) {
this.mediaStreamManager.releaseMediaStream();
this.mediaStreamManager = null;
}
}
}
const mediaService = new MediaService(); // シングルトン
各ページの使用フローは以下のとおりです。
// 1. Previewページ:MediaStreamManagerの初期化およびストリームを生成
const msm = await mediaService.initMediaStreamManager();
await msm.createMediaStream({
audioInputDeviceId: selectedMic,
videoInputDeviceId: selectedCamera,
videoElement: videoRef.current
});
// 2. Conferenceページ:同一のMediaStreamManagerを渡す
const msm = mediaService.getMediaStreamManager();
await conference.joinConference({
// ...その他のパラメータ
mediaStreamManager: msm, // SDKが既存のストリームを自動使用
micOn: true,
cameraOn: true
});
// 3. 通話終了時の整理
mediaService.releaseMediaStream();
主なLIFF連携APIの紹介
以下は、LIFF SDKを初期化し、LINEログイン情報を取得するAPIの使用例です。
import liff from '@line/liff';
const initializeLiff = async (liffId: string) => {
// LIFF SDKの初期化
await liff.init({ liffId });
// LINEアプリ内の実行有無を確認
const isInClient = liff.isInClient();
// 言語の自動検出
const userLanguage = liff.getLanguage();
// ログイン状態の確認およびプロフィールの取得
if (liff.isLoggedIn()) {
const profile = await liff.getProfile();
// profileオブジェクトには以下の情報が含まれます:
// - userId:LINEユーザーの固有ID
// - displayName:ユーザーの表示名
// - pictureUrl:プロフィール画像URL
// - statusMessage:ステータスメッセージ
return profile;
}
return null;
};
トラブルシューティング
Q. LIFFがローカル環境で動作しません。
A. LIFFはHTTPSでのみ動作します。ローカル環境では、ngrokを使ってローカルサーバーをトンネリングすれば解決します。
Q. LINE Planetの通話接続時にCORSエラーが発生します。
A. LINE Planet Console(Project>Project Settings>Configuration)で、対象ドメインをCORSの許可リストに登録する必要があります。

Q. ローカル環境でアプリサーバーと通信する際、CORSの問題が発生します。
A. ビルドツールのdev serverプロキシ機能(例:Viteのserver.proxy、Next.jsのrewrites)を使用することで、開発環境でのみアプリサーバーへのリクエストをプロキシできます。
Q. 一部のモバイルデバイスで、カメラ切り替えに失敗します。
A. 一部のデバイスでは、カメラのラベルに「front」や「back」といったキーワードが含まれていないため、enumerateDevices()ラベルマッチングに失敗する場合があります。その場合は、resolveFacingModeDeviceIdがundefinedを返すように設定し、SDKのデフォルトカメラにフォールバックさせるか、「back user facing」のようなラベルが「front」と誤認識されないよう、「back」パターンを先にマッチングさせてください(ステップ2のコードを参照)。
おわりに
このプロジェクトは、PMとAndroidエンジニアの2人が、ウェブエンジニアなしで進めました。このような作業が可能だった理由は2つあります。1つ目は、LIFFとLINE Planetが、それぞれ最も難しい部分をあらかじめ解決してくれる点です。LINE認証、WebRTCメディア処理、グローバルネットワークインフラを自前で実装していたら、はるかに時間 がかかったと思います。2つ目は、TypeScriptとReactのコンポーネントモデルが、AndroidのView/ViewModelパターンと予想以上に似ていた点です。
LINEのエコシステムを基盤として、サービスにリアルタイム通話機能を追加したいチームにとって、この記事が良い出発点となることを願っています。LINE Planetの導入に関するお問い合わせについては、dl_planet_help@linecorp.comまでご連絡ください。


