MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第1回】カメラとマイクを取得してプレビューし、デバイスを選べるようにする

    ビデオチャットで最初に作るのは、通話そのものではなく「自分のカメラとマイクが正しく使えるか」を確かめる画面です。前回の第0回:Nuxt 4でビデオチャットの土台を作る:なぜHTTPSが必要なのかでは、Nuxt 4 プロジェクトを作り、カメラが使えるページ(Secure Context)かどうかを判定する画面を用意しました。

    「getUserMediaでカメラ映像を出すだけなら簡単そう。でも『カメラが映らない』と言われたとき、何を表示すればいいの?」

    今回は getUserMedia(=ブラウザからカメラ・マイクの使用許可を求め、映像と音声を受け取るAPI)で自分の映像をプレビューし、カメラ・マイクを選べるようにします。結論から言うと、映すだけならコードは数行ですが、実運用で大事なのは「許可されなかった」「見つからない」「他のアプリが使用中」を区別して、利用者が自分で直せる言葉で伝えることです。サンプルではこの3つを中心に、エラーを7種類に分けて表示します。

    目次

    今回作るもの:カメラ・マイクの確認画面

    新しく /preview ページを追加します。

    • 「カメラとマイクを確認する」ボタンを押すと、ブラウザが使用許可を求める
    • 許可されると自分の映像が鏡のように(左右反転で)表示される
    • カメラ・マイクをセレクトボックスで選び直せる。USB カメラの抜き差しにも一覧が追従する
    • 許可されない・見つからない・使用中などの場合は、それぞれ対処法を含んだメッセージを表示する
    • 「停止する」ボタンや、ページを離れたときにカメラを確実に解放する(ランプが消える)

    追加・変更するファイル

    ファイル役割
    code/app/composables/useLocalMedia.tsgetUserMedia の呼び出し、停止、エラーの分類
    code/app/composables/useDevices.tsカメラ・マイクの一覧取得と抜き差しへの追従
    code/app/components/LocalPreview.vue自分の映像を表示するだけの部品
    code/app/components/DeviceSelector.vueカメラ・マイクのセレクトボックス
    code/app/pages/preview.vue上の部品を組み合わせた確認画面

    WebRTC に関わる処理は composables(=画面から切り離した再利用できるロジック)に閉じ込め、コンポーネントは表示に専念させる、というのが連載の方針です。第4回の「入室前プレビュー」や第3回以降の通話画面でも、この2つの composable をそのまま使います。

    仕組み:getUserMedia と enumerateDevices の役割分担

    API何をするか注意点
    navigator.mediaDevices.getUserMedia()カメラ・マイクの使用許可を求め、MediaStream(=映像・音声トラックの束)を返すSecure Context 限定。失敗理由は例外の name で分かる
    navigator.mediaDevices.enumerateDevices()接続されているカメラ・マイクの一覧を返す許可前はデバイス名(label)が空
    devicechange イベントデバイスが抜き差しされたことを知らせる一覧は自分で取り直す
    MediaStreamTrack.stop()カメラ・マイクの使用をやめる呼ばないとランプが点いたまま

    📰 出典:MDN「MediaDevices: getUserMedia() method」

    MDN には getUserMedia が失敗したときの例外が列挙されています。サンプルでは、利用者の対処が変わる単位で次のようにまとめました。

    例外の name主な原因サンプルでの分類
    NotAllowedError利用者が拒否した、ブラウザ設定でブロック、安全でないページdenied(許可されていない)
    NotFoundError要求した種類のデバイスが無いnotfound(見つからない)
    NotReadableError / AbortError許可はされたが、OS・ハードウェア・他アプリの都合で開始できないinuse(使用中など)
    OverconstrainedError指定した条件(デバイスIDなど)に合うデバイスが無いoverconstrained(選び直し)
    SecurityErrorページでメディアの使用が無効化されているinsecure

    NotReadableError は「他のアプリが使用中」のときに起きることが多い一方、MDN の説明は「OS・ブラウザ・ページのレベルのハードウェアエラー」と幅広いものです。そのため、メッセージは「使用中でないか確認してください」と、確認をお願いする言い方にしています。

    実装1:useLocalMedia でカメラ・マイクを取得する

    エラーの種類と表示文言を先に決める

    code/app/composables/useLocalMedia.ts(冒頭の抜粋)

    export type LocalMediaErrorKind =
      | 'insecure' // Secure Context でない(navigator.mediaDevices が無い)
      | 'denied' // 利用者またはブラウザ設定で拒否された
      | 'notfound' // カメラ・マイクが見つからない
      | 'inuse' // 他のアプリが使用中、または OS・ハードウェアの問題
      | 'overconstrained' // 指定したデバイスが見つからない(抜かれた等)
      | 'ended' // 取得後にデバイスが切断された
      | 'unknown'
    
    export type LocalMediaError = { kind: LocalMediaErrorKind; message: string }
    
    const MESSAGES: Record<LocalMediaErrorKind, string> = {
      insecure: 'このページではカメラ・マイクを使えません。HTTPS(開発中は http://localhost)で開いてください。',
      denied: 'カメラ・マイクの使用が許可されていません。アドレスバー付近のサイト設定から許可し、もう一度お試しください。',
      notfound: 'カメラまたはマイクが見つかりません。接続を確認してください。',
      inuse: 'カメラまたはマイクを開始できません。他のアプリ(Web会議ツール等)で使用中でないか確認してください。',
      overconstrained: '選択したデバイスが見つかりません。デバイスを選び直してください。',
      ended: 'カメラまたはマイクが切断されました。接続を確認し、もう一度開始してください。',
      unknown: 'カメラ・マイクの開始中に予期しないエラーが発生しました。',
    }
    
    function toLocalMediaError(e: unknown): LocalMediaError {
      const name = e instanceof DOMException || e instanceof Error ? e.name : ''
      const kind: LocalMediaErrorKind =
        name === 'NotAllowedError' ? 'denied'
          : name === 'NotFoundError' ? 'notfound'
            : name === 'NotReadableError' || name === 'AbortError' ? 'inuse'
              : name === 'OverconstrainedError' ? 'overconstrained'
                : name === 'SecurityError' ? 'insecure'
                  : 'unknown'
      return { kind, message: MESSAGES[kind] }
    }

    エラーの「種類(kind)」と「表示文言(message)」を分けておくのがポイントです。画面側は kind を見てアイコンや再試行ボタンを出し分けられますし、文言の変更は1か所で済みます。

    取得・停止・破棄時の解放

    code/app/composables/useLocalMedia.ts(useLocalMedia 本体)

    export function useLocalMedia() {
      // MediaStream はブラウザのオブジェクトなので深いリアクティブ化はせず shallowRef で持つ
      const stream = shallowRef<MediaStream | null>(null)
      const error = ref<LocalMediaError | null>(null)
      const isStarting = ref(false)
    
      function stop() {
        // track.stop() しないとカメラのランプが点いたままになる
        stream.value?.getTracks().forEach(track => track.stop())
        stream.value = null
      }
    
      async function start(options: LocalMediaOptions = {}) {
        error.value = null
        if (!navigator.mediaDevices?.getUserMedia) {
          error.value = { kind: 'insecure', message: MESSAGES.insecure }
          return
        }
    
        const constraints: MediaStreamConstraints = {
          video: options.videoDeviceId ? { deviceId: { exact: options.videoDeviceId } } : true,
          audio: options.audioDeviceId ? { deviceId: { exact: options.audioDeviceId } } : true,
        }
    
        isStarting.value = true
        // 切り替え時は先に今のデバイスを解放する(OS によっては同じカメラを二重に開けないため)
        stop()
        try {
          const next = await navigator.mediaDevices.getUserMedia(constraints)
          next.getTracks().forEach((track) => {
            track.addEventListener('ended', () => {
              if (stream.value === next) error.value = { kind: 'ended', message: MESSAGES.ended }
            })
          })
          stream.value = next
        }
        catch (e) {
          error.value = toLocalMediaError(e)
        }
        finally {
          isStarting.value = false
        }
      }
    
      /** 実際に使われているデバイスID(ブラウザが既定のデバイスを選んだ場合も分かる) */
      function currentDeviceId(kind: 'video' | 'audio'): string | undefined {
        const track = kind === 'video' ? stream.value?.getVideoTracks()[0] : stream.value?.getAudioTracks()[0]
        return track?.getSettings().deviceId
      }
    
      // このコンポーザブルを使ったコンポーネントが破棄されたら必ずデバイスを解放する
      onScopeDispose(stop)
    
      return { stream, error, isStarting, start, stop, currentDeviceId }
    }

    押さえておきたい点は4つです。

    • shallowRef を使う:MediaStream はブラウザが管理するオブジェクトです。中身までリアクティブ(=変更を自動追跡する仕組み)にする必要はないので、差し替えだけを検知する shallowRef にしています。
    • deviceId は exact で指定する:exact を付けると「そのデバイスでなければ失敗」という意味になり、抜かれたデバイスを指定したときは OverconstrainedError になります。付けない場合は「できればこのデバイス」という希望扱いで、別のデバイスが使われることがあります。
    • 切り替え前に stop() する:新しいデバイスを取る前に今のトラックを止めています。一瞬映像が途切れますが、同じカメラを二重に開こうとして失敗する環境を避けられます。
    • onScopeDispose(stop):この composable を使った画面が破棄されたとき(別ページへ移動したときなど)に自動で stop() します。「ページを移ったのにカメラのランプが消えない」を防ぐための保険です。

    📰 出典:MDN「MediaStreamTrack: stop() method」

    実装2:useDevices でカメラ・マイクの一覧を取る

    code/app/composables/useDevices.ts

    export type DeviceOption = { deviceId: string; label: string }
    
    export function useDevices() {
      const videoInputs = ref<DeviceOption[]>([])
      const audioInputs = ref<DeviceOption[]>([])
    
      async function refresh() {
        if (!navigator.mediaDevices?.enumerateDevices) return
        const devices = await navigator.mediaDevices.enumerateDevices()
        // 権限を許可する前は label が空文字になるため、仮の名前を付ける
        const toOptions = (kind: MediaDeviceKind, prefix: string) =>
          devices
            .filter(d => d.kind === kind && d.deviceId !== '')
            .map((d, i) => ({ deviceId: d.deviceId, label: d.label || `${prefix} ${i + 1}` }))
        videoInputs.value = toOptions('videoinput', 'カメラ')
        audioInputs.value = toOptions('audioinput', 'マイク')
      }
    
      const onDeviceChange = () => { void refresh() }
    
      onMounted(() => {
        void refresh()
        navigator.mediaDevices?.addEventListener('devicechange', onDeviceChange)
      })
      onBeforeUnmount(() => {
        navigator.mediaDevices?.removeEventListener('devicechange', onDeviceChange)
      })
    
      return { videoInputs, audioInputs, refresh }
    }

    📰 出典:MDN「MediaDevices: enumerateDevices() method」

    MDN によると、既定のデバイスを除き、許可を得たデバイスだけが「利用可能」として扱われ、許可前はデバイス名(label)が空になります。ブラウザによっては deviceId も空で返るため、サンプルでは deviceId が空のものを一覧から外し、名前が空なら「カメラ 1」のような仮の名前を付けています。一覧はページ表示時と、許可が取れた直後、そして devicechange イベントのたびに取り直します。

    devicechange のリスナーは onBeforeUnmount で必ず外します。外し忘れると、ページを移動した後も古い画面の処理が動き続けてしまいます。

    実装3:プレビューとデバイス選択の画面

    LocalPreview.vue:srcObject は JS で設定する

    code/app/components/LocalPreview.vue(抜粋)

    <script setup lang="ts">
    const props = defineProps<{ stream: MediaStream | null }>()
    
    const videoEl = ref<HTMLVideoElement | null>(null)
    
    // srcObject は属性ではなくプロパティなので、テンプレートではなく JS で設定する
    watch(
      [videoEl, () => props.stream],
      ([el, stream]) => {
        if (el) el.srcObject = stream
      },
      { immediate: true },
    )
    </script>
    
    <template>
      <div class="preview">
        <!-- 自分の声がスピーカーから返らないよう muted。iOS でインライン再生させるため playsinline -->
        <video ref="videoEl" class="preview__video" autoplay playsinline muted />
        <p v-if="!stream" class="preview__empty">
          カメラは停止中です
        </p>
      </div>
    </template>

    video 要素の3つの属性にはそれぞれ理由があります。muted は自分のマイク音声が自分のスピーカーから返ってくる(ハウリングする)のを防ぐため、autoplay は映像を自動で再生するため、playsinline は iPhone などで全画面にならずページ内で再生するためです。スタイルでは transform: scaleX(-1) で左右反転し、鏡を見ているような表示にしています(相手に送る映像は反転されません)。

    DeviceSelector.vue は、受け取った一覧を2つの select に並べ、選択値を defineModel で親に返すだけの部品なので省略します。

    preview.vue:許可はボタンを押してから求める

    code/app/pages/preview.vue(script 部分の抜粋)

    <script setup lang="ts">
    const { stream, error, isStarting, start, stop, currentDeviceId } = useLocalMedia()
    const { videoInputs, audioInputs, refresh } = useDevices()
    
    const videoDeviceId = ref('')
    const audioDeviceId = ref('')
    
    async function startPreview() {
      await start({
        videoDeviceId: videoDeviceId.value || undefined,
        audioDeviceId: audioDeviceId.value || undefined,
      })
      if (!stream.value) return
      // 許可された後はデバイス名(label)が取れるようになるので一覧を取り直す
      await refresh()
      // ブラウザが既定で選んだデバイスをセレクトボックスに反映する
      videoDeviceId.value = currentDeviceId('video') ?? ''
      audioDeviceId.value = currentDeviceId('audio') ?? ''
    }
    
    // プレビュー中にデバイスを選び直したら取り直す
    watch([videoDeviceId, audioDeviceId], ([v, a]) => {
      if (!stream.value) return
      if (v === currentDeviceId('video') && a === currentDeviceId('audio')) return
      void startPreview()
    })
    
    // デバイスが抜かれて一覧から消えたら、選択を「既定のデバイス」に戻す
    watch([videoInputs, audioInputs], ([videos, audios]) => {
      if (videoDeviceId.value && !videos.some(d => d.deviceId === videoDeviceId.value)) videoDeviceId.value = ''
      if (audioDeviceId.value && !audios.some(d => d.deviceId === audioDeviceId.value)) audioDeviceId.value = ''
    })
    </script>

    ページを開いた瞬間に許可を求めるのではなく、「カメラとマイクを確認する」ボタンを押してから getUserMedia を呼んでいます。画面には「映像と音声はこの端末の中だけで使われ、サーバーには送信されません」と使い道も書きました。いきなり許可ダイアログが出ると拒否されやすく、一度拒否されるとブラウザの設定画面から戻してもらう必要があるためです。

    最初の watch では、プレビュー中にセレクトボックスが変わったら取り直します。startPreview の最後で実際に使われているデバイスを選択に反映したときにも watch が動きますが、「今使っているデバイスと同じなら何もしない」という判定で二重取得を防いでいます。

    動作確認の方法

    Chromium 系ブラウザには、実機のカメラの代わりにテスト用の映像・音声を流す起動オプションがあります。筆者は Playwright で Chromium を自動操作し、次のパターンを確認しました(npm run build → node .output/server/index.mjs で起動)。

    確認したこと起動オプション結果
    許可 → プレビュー表示–use-fake-device-for-media-stream –use-fake-ui-for-media-stream640×480 の映像が表示され、カメラ・マイク名が一覧に出る
    マイクの切り替え同上選んだマイクに切り替わり、元のトラックは ended(停止)になる
    停止ボタン・ページ移動同上どちらもトラックが ended になる
    拒否–use-fake-device-for-media-stream –deny-permission-prompts「許可されていません」のメッセージ
    カメラが無い–use-fake-ui-for-media-stream(テスト用デバイス無し)「見つかりません」のメッセージ

    手元のPCで試す場合は、npm run dev で起動して http://localhost:3000/preview を開き、次の点を確認してください。

    1. ボタンを押して許可 → 自分の映像が映る
    2. ブラウザのサイト設定でカメラを「ブロック」にして再読み込み → 許可されていない旨が表示される
    3. USB カメラを抜き差し → セレクトボックスの一覧が更新される。使用中のカメラを抜くと「切断されました」と表示される
    4. 別のアプリでカメラを使用中にして開始 → 使用中の可能性を伝えるメッセージになる

    3 と 4(実機のUSBカメラの抜き差し、他アプリとの取り合い)は筆者の検証環境では確認できていません。特に「他アプリが使用中」のときに NotReadableError になるか、そのまま映るかは、OS やブラウザによって異なる可能性があります。

    npm run build と npm run typecheck(型チェック)はエラーなく通っています。

    つまずきやすい点と注意

    • 許可前はデバイス名が空:ページを開いた直後に一覧を出すと名前が空です。許可を取った後に enumerateDevices を呼び直すのを忘れがちです。
    • track.stop() を忘れるとランプが消えない:video 要素から外しただけではカメラは止まりません。停止ボタン、ページ移動、エラー時のすべてで stop() が呼ばれるようにします。
    • 一度拒否されるとコードからは再表示できない:ブラウザは拒否を記憶します。「サイト設定から許可してください」と具体的な戻し方を案内する文言が必要です。
    • SSR 中に navigator を触らない:useDevices は onMounted 内で一覧を取り、useLocalMedia の start() はボタン操作からしか呼ばれないので、サーバー側では実行されません。
    • 映像はサーバーに送られない:今回の画面は端末内で完結します。利用者に不安を与えないよう、その旨を画面に書いておくと親切です。
    • 本番で省略していること:スマホのフロント/背面カメラ切り替え(facingMode)、映像の解像度指定、音量メーターなどは今回扱っていません。

    発注者向けメモ:「カメラが映らない」問い合わせを減らすために

    ビデオ通話の問い合わせで多いのは「カメラが映らない」「声が届かない」です。その原因は、許可していない・他のアプリが使っている・デバイスが外れている、といった利用者側の環境であることが少なくありません。エラー時の文言と案内を、最初から要件に入れておくことで、サポートの負担を大きく減らせます。

    • ☐ 通話前に「カメラ・マイクの確認画面」を設けるか決めている
    • ☐ 許可されない・見つからない・使用中の各ケースで表示する文言と、案内するページ(設定方法の説明など)を決めている
    • ☐ 利用者が使う端末・ブラウザ(会社支給PC、個人スマホ等)と、会社のセキュリティ設定でカメラが制限されていないかを確認している
    • ☐ 通話終了時・画面を閉じたときにカメラを確実に止めることを受け入れ条件に入れている

    開発会社への質問例:

    • 「カメラやマイクの使用を拒否された場合、利用者にはどんな画面・文言が出ますか?」
    • 「他のWeb会議ツールでカメラを使用中のとき、どう表示されるかを確認してもらえますか?」
    • 「カメラ・マイクの確認画面は、どの端末とブラウザで動作確認する予定ですか?」
    • 「通話を終えたとき、カメラのランプが消えることを確認項目に入れてもらえますか?」

    エラー表示は後回しにされやすい部分ですが、利用者の満足度を大きく左右します。画面ごとに「失敗したら何を表示するか」を一緒に確認していくのがおすすめです。

    まとめと次回予告

    • getUserMedia は例外の name で失敗理由が分かる。利用者の対処が変わる単位(拒否・見つからない・使用中など)に分けて文言を用意する
    • 許可はボタン操作の後に求め、使い道を画面に書く
    • enumerateDevices は許可前だと名前が空。許可後と devicechange のたびに取り直す
    • track.stop() を停止ボタン・ページ移動・切り替えのすべてで確実に呼ぶ

    次回(第2回)は「Nitro の WebSocket でシグナリングサーバーを作る」です。いよいよ通話相手とのやりとりに入り、Nuxt のサーバー機能だけで、同じルームにいる相手にだけメッセージを届ける仕組みを作ります。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (3件)

      目次