MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第7回】画面共有

    前回の第6回:STUN/TURN と本番デプロイ:「社外とつながらない」を解消するで、社外の相手ともつながるための TURN と、HTTPS 環境へのデプロイ構成を整えました。今回は、打ち合わせでよく使う「画面共有」を追加します。

    「画面共有って、カメラの映像とは別にもう1本、映像の通信をつなぎ直すの?途中で切り替えたら通話が一瞬切れたりしない?」

    結論から言うと、つなぎ直す必要はありません。WebRTC には、送っている映像を別の映像に差し替える replaceTrack() という仕組みがあり、offer/answer のやり直し(再交渉)なしで、カメラの映像を画面の映像に入れ替えられます。今回はこの方法で、画面共有の開始・停止と、ブラウザの「共有を停止」ボタンで止められたときに自動でカメラに戻る動きまで作ります。

    目次

    今回作るもの:カメラと画面を切り替える画面共有

    • 通話中の操作ボタンに「画面を共有」を追加する(画面共有に対応していないブラウザではボタンを出さない)
    • 押すとブラウザの「共有する画面を選ぶ」画面が開き、選んだ画面・ウィンドウ・タブが相手に映る
    • 「画面共有を停止」ボタン、またはブラウザが表示する「共有を停止」で止めると、カメラの映像に戻る
    • 相手の画面には「画面を共有中」と表示し、画面全体が見えるように表示方法を切り替える
    • 画面共有中に相手が入室してきた場合は、最初から画面を送る

    追加・変更するファイル

    ファイル役割
    code/app/composables/useScreenShare.ts画面の取得(getDisplayMedia)と停止の検知(新規)
    code/app/composables/usePeerConnection.ts送信する映像を差し替える replaceVideoTrack() を追加
    code/shared/types/signaling.ts / code/server/utils/signaling.ts状態(MediaState)に screen を追加
    code/app/components/CallControls.vue画面共有ボタン
    code/app/components/RemoteVideo.vue / LocalPreview.vue共有中の表示(反転しない・全体を表示)
    code/app/pages/room/[id].vue上記の組み込み

    仕組み:getDisplayMedia と replaceTrack

    画面共有は、次の2つの API の組み合わせです。

    API役割注意点
    navigator.mediaDevices.getDisplayMedia()画面・ウィンドウ・タブの映像を取得するSecure Context 限定。クリックなどのユーザー操作の直後に呼ぶ必要がある。video: false は不可
    RTCRtpSender.replaceTrack()送信中の映像トラックを別のトラックに差し替える同じ種類(映像なら映像)どうしで、再交渉なしに切り替えられる

    getDisplayMedia は、呼び出すとブラウザが「どの画面を共有しますか」という選択画面を出します。利用者が選んだ後にしか映像は取れず、アプリ側から特定の画面を勝手に選ぶことはできません。また、利用者はブラウザが表示する「共有を停止」ボタンでいつでも止められ、そのとき映像トラックに ended イベントが発生します。音声の共有は対応状況がブラウザや OS によって異なるため、今回は映像だけを扱います。

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

    replaceTrack は、第1回の申し送りで「通話中のカメラ切り替えは replaceTrack で」と書いたものと同じ仕組みです。MDN には、トラックの切り替えに再交渉は不要であること、再交渉が必要になるような差し替え(例えば交渉済みの範囲を超える変更)は InvalidModificationError になることが書かれています。

    📰 出典:MDN「RTCRtpSender: replaceTrack() method」

    実装1:画面を取得する useScreenShare

    code/app/composables/useScreenShare.ts

    export function useScreenShare() {
      const stream = shallowRef<MediaStream | null>(null)
      const error = ref<string | null>(null)
      // PC の Chrome / Edge / Firefox / Safari は対応。スマホのブラウザは多くが未対応(関数自体が無い)
      const isSupported = ref(false)
      onMounted(() => {
        isSupported.value = typeof navigator.mediaDevices?.getDisplayMedia === 'function'
      })
    
      /** 共有を開始する。必ずボタンのクリックなど「ユーザー操作」の中から呼ぶこと */
      async function start(onEnded: () => void): Promise<MediaStreamTrack | null> {
        error.value = null
        try {
          // video: false は不可。音声の共有は対応状況がブラウザ・OS でまちまちなので今回は扱わない
          const next = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
          const track = next.getVideoTracks()[0]
          if (!track) return null
          // 文字や細かい線が読みやすいよう、画質(解像度)を優先するヒントを付ける
          track.contentHint = 'detail'
          // ブラウザの「共有を停止」ボタンで止められたときは ended が発生する
          track.addEventListener('ended', () => {
            if (stream.value !== next) return
            stream.value = null
            onEnded()
          })
          stream.value = next
          return track
        }
        catch (e) {
          const name = e instanceof Error ? e.name : ''
          // NotAllowedError は「共有する画面を選ぶ画面」でキャンセルされた場合も含む
          error.value = name === 'NotAllowedError'
            ? null
            : '画面共有を開始できませんでした。ブラウザや OS の画面収録の許可設定を確認してください。'
          return null
        }
      }
    
      /** 共有を止める(track.stop() では ended イベントは発生しない) */
      function stop() {
        stream.value?.getTracks().forEach(track => track.stop())
        stream.value = null
      }
    
      onScopeDispose(stop)
    
      return { stream, error, isSupported, start, stop }
    }

    ポイントは次のとおりです。

    • 対応していないブラウザではボタンを出さない:getDisplayMedia が存在するかを onMounted で確認します(SSR 中は navigator が無いため)。
    • キャンセルはエラーにしない:選択画面で「キャンセル」を押された場合も NotAllowedError になります。利用者が自分で止めただけなので、エラー表示はしません。それ以外(OS の画面収録の許可が無い等)はメッセージを出します。macOS などでは、OS の設定でブラウザに画面収録を許可していないと共有できないことがあります。
    • contentHint = 'detail':映像トラックに「文字や細部が重要」というヒントを付けます。回線が細いときに、ブラウザがなめらかさより解像度を優先する手がかりになります(実際の扱いはブラウザ次第です)。
    • ended は自分で stop したときには発生しない:自分の「画面共有を停止」ボタンで止めたときは、呼び出し側でカメラに戻す処理を行います。

    実装2:usePeerConnection に映像の差し替えを追加する

    code/app/composables/usePeerConnection.ts(追加・変更部分)

      /** 第7回: カメラの代わりに送る映像(画面共有中の画面のトラック)。null ならカメラを送る */
      let videoOverride: MediaStreamTrack | null = null
    
      function addLocalTracks(conn: RTCPeerConnection) {
        if (localTracksAdded) return
        localTracksAdded = true
        const localStream = getLocalStream()
        localStream?.getTracks().forEach((track) => {
          // 画面共有中に相手が入ってきた場合は、最初から画面を送る
          const sending = track.kind === 'video' && videoOverride ? videoOverride : track
          conn.addTrack(sending, localStream)
        })
      }
    
      /**
       * 第7回: 送信中の映像を差し替える(画面共有の開始・終了)。
       * replaceTrack は offer/answer のやり直し(再交渉)なしで、送る映像だけを入れ替えられる。
       * track に null を渡すとカメラに戻す。
       */
      async function replaceVideoTrack(track: MediaStreamTrack | null) {
        videoOverride = track
        const conn = pc
        if (!conn) return
        const next = track ?? getLocalStream()?.getVideoTracks()[0] ?? null
        // 映像用の送信口(sender)を探す。受信側のトラックの種類で判定すると、送信を止めている間も見つけられる
        const sender = conn.getTransceivers().find(t => t.receiver.track.kind === 'video')?.sender
        if (!sender || !localTracksAdded) return // まだ載せていなければ addLocalTracks で載る
        try {
          await sender.replaceTrack(next)
        }
        catch (e) {
          console.error('[peer] replaceTrack', e)
        }
      }

    送信口(RTCRtpSender)は、映像と音声で1つずつあります。MDN の例では getSenders() から sender.track.kind で映像の送信口を探していますが、送信中のトラックが無い(null の)状態だと種類が分かりません。そこで、送受信の組(トランシーバー)の受信側のトラックの種類で探しています。受信側のトラックは常に存在するためです。

    「今どちらを送るべきか」を videoOverride に覚えておくのもポイントです。第4回の変更で、先にルームにいた側は相手の offer を受け取ってからトラックを載せるようになりました。画面共有中に相手が入ってきた場合、ここで覚えておかないとカメラの映像を送ってしまいます。

    実装3:ルームページで開始・停止を切り替える

    code/app/pages/room/[id].vue(追加部分)

    <script setup lang="ts">
    const { remotePeerId, remoteStream, connectionState, routeType, replaceVideoTrack, close: closePeer }
      = usePeerConnection(signaling, () => localStream.value, () => iceConfig.value)
    const screen = useScreenShare()
    const isSharing = computed(() => screen.stream.value !== null)
    const { remoteState } = useRemoteMediaState(
      signaling,
      () => ({ audio: audioEnabled.value, video: videoEnabled.value, screen: isSharing.value }),
      remotePeerId,
    )
    
    // getDisplayMedia はクリック直後(ユーザー操作の中)で呼ぶ必要があるため、await の前に他の処理を挟まない
    async function toggleScreenShare() {
      if (isSharing.value) {
        screen.stop()
        await replaceVideoTrack(null) // カメラに戻す
        return
      }
      // ブラウザの「共有を停止」で止められたときも、カメラに戻す
      const track = await screen.start(() => { void replaceVideoTrack(null) })
      if (track) await replaceVideoTrack(track)
    }
    </script>

    getDisplayMedia は「ユーザー操作の直後」でないと呼べません(呼べない場合は InvalidStateError)。ボタンのクリックから getDisplayMedia までの間に、別の await(API の呼び出しなど)を挟むと、ブラウザによってはユーザー操作の扱いが切れてしまうおそれがあります。ここでは、クリックのハンドラからそのまま screen.start() を呼んでいます。

    退出時(leave())には screen.stop() も呼び、共有を確実に止めるようにしました。

    相手に「画面共有中」を伝える

    第5回で作った状態の通知(media-state)に、screen(画面共有中か)を追加しました。

    code/shared/types/signaling.ts(変更部分)

    export type MediaState = {
      audio: boolean
      video: boolean
      /** 第7回: 画面共有中か(true なら video には画面が流れている) */
      screen: boolean
    }

    サーバー側の検証(server/utils/signaling.ts の isMediaState)にも screen のチェックを追加しています。型を共有しているので、片方だけ直し忘れると型チェックで気付けます。

    これが必要な理由は、「カメラをオフにしたまま画面共有する」ケースです。第5回のままだと、相手の画面には「相手はカメラをオフにしています」が重なって、共有した画面が隠れてしまいます。

    code/app/components/RemoteVideo.vue(template の抜粋)

        <video ref="videoEl" class="remote__video" :data-screen="mediaState?.screen ?? false" autoplay playsinline />
        <template v-if="stream && mediaState">
          <!-- 画面共有中はカメラをオフにしていても画面が届くので、カメラオフの表示は出さない -->
          <span v-if="mediaState.screen" class="remote__badge remote__badge--screen">画面を共有中</span>
          <p v-else-if="!mediaState.video" class="remote__camera-off">
            相手はカメラをオフにしています
          </p>
          <span v-if="!mediaState.audio" class="remote__badge">ミュート中</span>
        </template>

    共有中は CSS の object-fit を contain にして、画面の端が切れないようにしています(カメラ映像は枠いっぱいに表示する cover のまま)。自分側の小さなプレビュー(LocalPreview)には mirror という props を追加し、共有中は送っている画面を反転せずに表示します。カメラのプレビューは鏡のように左右反転していますが、画面を反転すると文字が読めなくなるためです。

    動作確認の方法

    1. npm run dev で起動し、PC の2つのウィンドウで同じルームに入る
    2. 片方で「画面を共有」→ 共有する画面を選ぶ → もう片方に画面が映り、「画面を共有中」と表示される
    3. 「画面共有を停止」を押す → カメラの映像に戻る
    4. もう一度共有し、今度はブラウザが表示する「共有を停止」で止める → 同じくカメラに戻る
    5. カメラをオフにしてから共有する → 画面は見え、共有を止めると「カメラをオフにしています」の表示に戻る
    6. Chrome なら chrome://webrtc-internals で、共有の開始・停止で再交渉(新しい offer/answer)が起きていないことを確認できる

    筆者の環境では、Playwright で Chromium を操作し、テスト用のカメラ(--use-fake-device-for-media-stream)と、Chromium がテスト用に用意する画面キャプチャ(1280×720)で次の点を確認しました。

    確認したこと結果
    共有開始相手側の映像が 1280×720 に変わり「画面を共有中」を表示。送信側の setLocalDescription の呼び出し回数は増えず(再交渉なし)、signalingState は stable のまま
    共有トラックの ended(ブラウザの停止ボタン相当)カメラの映像(640×480)に戻る
    「画面共有を停止」ボタンカメラに戻り、画面のトラックは停止(ended)状態になる
    カメラオフ+共有画面が見え、カメラオフの表示は出ない。共有停止後は黒い映像と「カメラをオフにしています」
    共有中に相手が入室相手には最初から画面が届く
    選択画面でキャンセル(NotAllowedError)エラー表示なし。その他のエラーではメッセージを表示
    getDisplayMedia が無いブラウザ「画面を共有」ボタンを表示しない

    ブラウザの「共有を停止」ボタンそのものは、画面を持たない自動テストでは押せないため、ended イベントを発生させて代わりに確認しました。実際の画面・ウィンドウ・タブの選択、macOS の画面収録の許可、Firefox・Safari での挙動、スマホのブラウザでの対応状況は、この環境では確認していません。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • 映り込みに注意:画面全体を共有すると、通知、ほかのウィンドウ、ブラウザのタブ名などが相手に見えます。業務で使う場合は「ウィンドウ単位で共有する」「通知を切る」といった運用上の注意を利用者に案内してください。
    • 共有した画面の中に招待URLやICE情報が映ることもある:第4回の招待リンクや、webrtc-internals の画面(IPアドレスを含む)を共有しないよう注意します。
    • スマホでは使えないことが多い:MDN の互換性データ(執筆時点の2026年9月に確認)では、PC の Chrome・Edge・Firefox・Safari は対応、Android 版の Chrome・Firefox と iOS の Safari は非対応とされています。今回はボタンを出さないだけにしていますが、「スマホから共有したい」要件がある場合は、ネイティブアプリなど別の手段の検討が必要です。
    • 解像度が変わる:カメラ(例: 640×480)から画面(例: 1280×720 以上)に切り替わると、送る情報量が増えます。回線が細いと文字がぼやけたり、動きが遅くなったりします。第6回の TURN 経由の場合は、その分 TURN の通信量も増えます。
    • ユーザー操作の直後に呼ぶ:ボタンを押してから getDisplayMedia までの間に時間のかかる処理を挟まないようにします。

    発注者向けメモ:画面共有は「対応端末」と「映り込み対策」を要件に

    画面共有は、今回のように1対1の通話に追加するだけなら比較的小さな改修です。一方で、どの端末・ブラウザで使えるかと、見せてはいけないものが映る問題は、技術だけでは解決できない部分です。

    • ☐ 画面共有を使う端末・ブラウザ(PC のみか、スマホやタブレットも必要か)が要件に書かれているか
    • ☐ 音声(動画の音など)も一緒に共有する必要があるか(ブラウザ・OS によって対応が異なる)
    • ☐ 画面全体・ウィンドウ・ブラウザのタブのどれを共有できればよいか
    • ☐ 共有中であることが、共有している本人と相手の両方に分かる表示になっているか
    • ☐ 映り込み防止のための利用者向けの案内(マニュアル・注意書き)を誰が用意するか

    開発会社への質問例:

    • 「画面共有はどの端末・ブラウザで動作確認しますか?スマホからの共有には対応しますか?」
    • 「画面共有を開始・停止したとき、通話が途切れることはありますか?」
    • 「共有中にほかの参加者が入ってきた場合、その人にも共有画面が表示されますか?」
    • 「macOS などで画面収録の許可がない場合、利用者にはどう案内しますか?」

    「スマホからも共有したい」「複数人が同時に共有したい」といった要件は、今回の仕組みの延長では実現できず、設計や見積もりが大きく変わります。早めに確認しておくと安心です。

    まとめと次回予告

    • 画面共有は、getDisplayMedia で画面の映像を取り、replaceTrack でカメラの映像と差し替えるだけで実現できる。再交渉は不要
    • getDisplayMedia はユーザー操作の直後に呼ぶ。キャンセルは NotAllowedError になるのでエラー扱いしない
    • ブラウザの「共有を停止」は track の ended で検知し、カメラに戻す
    • 「今どちらを送るか」を覚えておき、共有中に入ってきた相手にも画面を送る
    • 対応端末(特にスマホ)と映り込み対策は、発注時に要件として明示する

    次回(第8回)は「DataChannel でテキストチャット」です。映像・音声と同じ P2P の接続の上で、サーバーを通さずにテキストメッセージをやりとりする仕組みを作ります。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (1件)

      目次