当サイトはアフィリエイトプログラムによる収益を得ています。

テレビでYouTubeを流したままWindowsを裏で操作|Moonlight+VDDで仮想モニターを作った

テレビで動画を流したままMacBookからWindowsの仮想画面を操作するイラスト

リビングのWindows PCは、SONYのテレビをモニターとして使っている。テレビでYouTubeを流しているときにWindowsの設定変更やブログ作業をしたくても、同じ画面を使えば当然テレビ側の映像が切り替わってしまう。

テレビで動画を再生しながらMacBookでWindowsの仮想モニターを操作している実写
奥のTVと手前のMacBookが、同じPCの別画面として同時に動いている

実際の完成状態がこれだ。奥のテレビではYouTubeを再生したまま、手前のMacBookでは同じWindows PCのVDD側を操作できている。

そこでWindowsへVirtual Display Driver(VDD)の仮想モニターを追加し、その画面だけをSunshine+MoonlightでMacBookへ飛ばす構成を作った。テレビのYouTubeには触れず、同じPCの別画面をMacBookから操作する、いわばWindowsの「裏画面」だ。

最終的にはMultiMonitorToolとBAT・PowerShellを組み合わせ、普段の1画面と、MacBookから操作する2画面構成を1回の実行で切り替えられるようにした。

スポンサーリンク

テレビの画面を奪わずにWindowsを触りたかった

やりたかったことは、別のWindows PCを増やすことではない。テレビで動画を流している1台のWindows PCを、そのままMacBookからも使うことだった。

普通にMoonlightで物理モニターを表示すると、MacBookからの操作がテレビにも見える。設定画面を開けばテレビ側も設定画面になり、動画を見ている人の邪魔になる。

欲しかったのは、同じPCの中にテレビ用と作業用の2画面を持たせ、作業用だけをMacBookへ送る構成だった。

VDDでWindowsに「裏画面」を作る

Windows 11のディスプレイ設定で物理画面と仮想画面が拡張表示になっている状態
Windows上ではTVとVDDが普通の2画面として並ぶ

Windowsのディスプレイ設定では、1920×1080のSONY TVと2560×1600のVDDが拡張表示の2画面として認識された。

VDD側はMacBook Airで使いやすい解像度にし、Windowsの表示倍率を200%に設定した。Windowsが割り当てるDISPLAY番号は再起動やドライバーの再認識で変わるため、現在の番号を設定値として保存しないようにした。

VDDの導入から仮想モニターを表示するところまでは、以前の記事でスクリーンショット付きでまとめている。

仮想画面がWindowsへ追加できたら、ホスト側のSunshineからその画面を配信し、MacBook側のMoonlightで接続する。Moonlight公式のセットアップガイドでも、ホストにSunshine、クライアントにMoonlightを使う基本構成が案内されている。

MultiMonitorToolで2つの表示を切り替える

MultiMonitorToolで2560×1600のVDDがアクティブかつプライマリになっている画面
2画面モードでは2560×1600のVDDをプライマリにした

MultiMonitorTool上でも、1920×1080のSONY TVと2560×1600のVDDが別モニターとして見える。2画面モードではVDDをアクティブかつプライマリにし、MacBookから操作するWindows画面をこちらへ寄せる。

普段の1画面モードではSONY TVだけを使い、SONYをプライマリにしてVDDを無効化する。作業するときだけVDDを有効化し、プライマリをVDDへ切り替える形だ。

MultiMonitorTool公式ページには、モニターの有効化・無効化、プライマリ変更、設定保存などの機能とコマンドライン指定が掲載されている。今回の切り替えは、その/enable、/disable、/SetPrimary、/sxmlを組み合わせた。

以前は物理4枚と仮想1枚を切り替える構成も試した。複数モニターの復帰順や設定ファイル読込でハマった経緯は、こちらの記事に分けている。

今回は物理画面がSONY TVの1枚だけなので、最終構成はかなり小さくできた。

1回の実行で1画面と2画面を切り替える

最終的にC:\Users\takes\Documents\Monitorへ残したのは、切り替え入口のBAT、状態判定と実操作を行うPowerShell、明示的に1画面・2画面へ変更する補助BATだ。monitor-status.xmlは実行するたびに現在状態を取得して上書きする。

Monitor
├─ Monitor-Switch.bat
├─ Monitor-Switch.ps1
├─ 1monitor.bat
├─ 2monitor.bat
├─ monitor-status.xml
└─ test

BATにはDISPLAY番号を書かない

1monitor.batと2monitor.batは、PowerShellへ動作モードを渡すだけにした。モニターを直接指定しないので、再起動後にDISPLAY番号が変わってもBATの修正は不要だ。

@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Monitor-Switch.ps1" -Mode SonyOnly
exit /b %errorlevel%
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Monitor-Switch.ps1" -Mode Dual
exit /b %errorlevel%

1画面へ戻す場合は、先にSONY TVがプライマリになったことを確認してからVDDを無効化する。2画面へ切り替える場合は、VDDが有効になったことを確認してからプライマリへ変更する。固定秒数を待つのではなく、100ms間隔で実状態を確認する方式にした。

固定IDでSONY TVとVDDを特定する

MultiMonitorToolのXMLから、SONY TVはShort Monitor IDのSNY3A01、VDDはDevice IDのRoot\MttVDDを第一条件として特定する。VDDはShort Monitor IDのMTT1337も照合する。

毎回1画面用・2画面用のBATを選ぶのも面倒なので、通常はMonitor-Switch.batからPowerShellを呼び出し、VDDが有効なら1画面へ、無効なら2画面へ切り替える。

@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Monitor-Switch.ps1" -Mode Toggle
exit /b %errorlevel%

記事では処理の核が分かるように、実際に使っているMonitor-Switch.ps1を少し整理して掲載する。

[CmdletBinding()]
param(
    [ValidateSet('Toggle', 'SonyOnly', 'Dual')]
    [string]$Mode = 'Toggle'
)

$ErrorActionPreference = 'Stop'
$MmtPath = 'C:\Users\takes\Documents\APP\multimonitortool-x64\MultiMonitorTool.exe'
$XmlPath = Join-Path $PSScriptRoot 'monitor-status.xml'
$SonyShortId = 'SNY3A01'
$VddDeviceId = 'Root\MttVDD'
$VddShortId = 'MTT1337'

function Invoke-Mmt([string[]]$Arguments) {
    $process = Start-Process -FilePath $MmtPath -ArgumentList $Arguments `
        -WindowStyle Hidden -Wait -PassThru
    if ($process.ExitCode -ne 0) {
        throw "MultiMonitorTool failed: exit=$($process.ExitCode)"
    }
}

function ConvertTo-Bool($Value, [string]$Name) {
    $text = ([string]$Value).Trim()
    $yes = [string]([char]0x306F) + [char]0x3044
    $no = [string]([char]0x3044) + [char]0x3044 + [char]0x3048
    if ($text -in @('Yes', 'True', '1', $yes)) { return $true }
    if ($text -in @('No', 'False', '0', $no)) { return $false }
    throw "Unknown $Name value: $text"
}

function Get-MonitorState {
    Remove-Item -LiteralPath $XmlPath -Force -ErrorAction SilentlyContinue
    Invoke-Mmt @('/sxml', $XmlPath)

    [xml]$xml = Get-Content -LiteralPath $XmlPath -Raw
    $items = @($xml.monitors_list.item)
    $sonyMatches = @($items | Where-Object {
        [string]$_.short_monitor_id -ieq $SonyShortId
    })
    $vddMatches = @($items | Where-Object {
        [string]$_.device_id -ieq $VddDeviceId
    })

    if ($sonyMatches.Count -ne 1 -or $vddMatches.Count -ne 1) {
        throw 'Safety stop: SONY or VDD was not identified exactly once.'
    }

    $sony = $sonyMatches[0]
    $vdd = $vddMatches[0]
    if ([string]$vdd.short_monitor_id -ine $VddShortId) {
        throw 'Safety stop: VDD Short Monitor ID mismatch.'
    }

    [pscustomobject]@{
        SonyActive  = ConvertTo-Bool $sony.active 'SONY active'
        SonyPrimary = ConvertTo-Bool $sony.primary 'SONY primary'
        VddActive   = ConvertTo-Bool $vdd.active 'VDD active'
        VddPrimary  = ConvertTo-Bool $vdd.primary 'VDD primary'
    }
}

function Wait-ForState([scriptblock]$Condition) {
    $deadline = [DateTime]::UtcNow.AddSeconds(12)
    do {
        $state = Get-MonitorState
        if (& $Condition $state) { return $state }
        Start-Sleep -Milliseconds 100
    } while ([DateTime]::UtcNow -lt $deadline)
    throw 'Monitor state change timed out.'
}

function Set-SonyOnly($State) {
    if (-not $State.SonyActive) { throw 'Safety stop: SONY is inactive.' }
    if (-not $State.SonyPrimary) {
        Invoke-Mmt @('/SetPrimary', $SonyShortId)
        $State = Wait-ForState { param($s) $s.SonyActive -and $s.SonyPrimary }
    }
    if ($State.VddActive) {
        Invoke-Mmt @('/disable', $VddShortId)
        $null = Wait-ForState {
            param($s) $s.SonyActive -and $s.SonyPrimary -and -not $s.VddActive
        }
    }
}

function Set-Dual($State) {
    if (-not $State.SonyActive) { throw 'Safety stop: SONY is inactive.' }
    if (-not $State.VddActive) {
        Invoke-Mmt @('/enable', $VddShortId)
        $State = Wait-ForState { param($s) $s.SonyActive -and $s.VddActive }
    }
    if (-not $State.VddPrimary) {
        Invoke-Mmt @('/SetPrimary', $VddShortId)
        $null = Wait-ForState {
            param($s) $s.SonyActive -and $s.VddActive -and $s.VddPrimary
        }
    }
}

$state = Get-MonitorState
switch ($Mode) {
    'SonyOnly' { Set-SonyOnly $state }
    'Dual'     { Set-Dual $state }
    'Toggle' {
        if ($state.VddActive) { Set-SonyOnly $state }
        else { Set-Dual $state }
    }
}

操作時もMultiMonitorToolが対応しているShort Monitor IDを渡すため、PowerShell内にDISPLAY番号は登場しない。XMLのactiveとprimaryは日本語・英語の両方を判定できるようにしている。

さらに、対象が見つからない場合や同じ条件に複数台が一致した場合は、その時点で安全停止する。推測で別モニターを無効化しないことを優先した。

テレビはそのまま、MacBookから裏画面を操作

完成後は、テレビに映っている動画や操作状態を奪わず、Windowsの設定変更やブログ作業を別画面で進められるようになった。冒頭の実写が、TVとMacBookを同時に使っている実際の状態だ。

MacBook AirにMoonlight経由のWindows画面を表示して操作している実写
MacBook AirをWindowsの作業端末としてそのまま使える

MacBookの画面に見えているのは、Mac用の別環境ではなく、Moonlight経由で表示したWindowsの仮想モニターだ。MacBook Airの2560×1600に合わせたVDDと200%表示にしたことで、小さい文字も無理なく扱えた。

作る前は「MoonlightでWindowsを遠隔操作する」だけの構成を考えていたが、実際に便利だったのは、物理画面を使っている人へ影響を出さずに同じPCを触れることだった。1台のリビングPCに、テレビ用の表画面とMacBook用の裏画面を持たせた感覚に近い。

使う前に確認したい注意点

記事のSNY3A01、Root\MttVDD、MTT1337は今回の機器で確認した識別情報だ。他の環境で使う場合は、MultiMonitorToolのShort Monitor IDとDevice IDを確認して置き換える必要がある。DISPLAY番号だけを見て置き換える方法は、再起動後に番号が変わる可能性があるため避けた方がいい。

また、今回の構成はSunshineがキャプチャする画面をVDD側に合わせ、MacBookからその画面を操作する運用だ。複数のMoonlightクライアントごとに、同じSunshineから別々の画面を独立配信する仕組みとして作ったものではない。

BAT内のMultiMonitorToolの保存先とMonitorフォルダーのパスも、自分の配置へ置き換える必要がある。ExecutionPolicy Bypassはこのローカルスクリプトを実行するために付けているため、入手元が不明なPS1へ無条件に使わない方がいい。

テレビを止めずに同じWindows PCを触りたい、という目的に対して、VDDで別画面を作る方法はかなり相性がよかった。Moonlightを単なる遠隔操作にせず、物理画面とは別の作業場所として使えるようになったのが今回の一番大きな収穫だ。