YouTube ライブのチャットを動画上に表示する Chrome 拡張機能

f:id:ray0mg:20181020105924p:plain

ダウンロード

YouTube ライブのチャットを動画上に表示する Vivaldi 用の拡張機能(UserJS)(以下、「この拡張機能」「本 UserJS」と呼ぶ)をつくった。そのコードについての技術的なメモを書き留めておく。

manifest.json

そもそもこの拡張機能は、「パッケージ化されていない拡張機能」である。ブラウザの設定で、拡張機能デベロッパーモードをオンにして、「読み込む」ボタン押下、manifest.json のあるフォルダを選択する。

manifest.json の中身は、以下のようになっている。

{
  "manifest_version": 2,
  "name": "UserJS",
  "version": "1.00",
  "content_scripts": [
    {
      "js": ["ytchat.js"],
      "run_at": "document_start",
      "matches": ["https://www.youtube.com/*"],
      "all_frames": true
    }
  ]
}

ここで重要なのが、"all_frames": true だ。YouTube のチャット領域は iframe 内に描画されている。all_frames:true を指定することによってのみ、本 UserJS の要である ytchat.js は iframe 内の空間でも実行されるようになる。それによって、ytchat.js は、動画上に表示するためのチャットの内容を取得することができるようになるのだ。

ytchat.js

manifest.json と同じフォルダに配置されている ytchat.js の中身は、大きく分けると 4 つのパートに分けることができる。

  • チャット内容を取得
  • 動画上に表示するチャットの表示方法を決める
  • チャットを動画上に表示
  • 本 UserJS オン・オフ切り替えボタンの表示

まずは「チャット内容を取得」について説明する。

if (location.href.startsWith("https://www.youtube.com/live_chat")) (function() {
  if (parent === self) return;
  // Program: Chat data getter
  var mo = new MutationObserver(function(records) {
    for (var rcd of records) {
      for (var nd of rcd.addedNodes) {
        var body = nd.querySelector("#message");
        if (!body) continue;
        var rawtext = "";
        for (var phrase of body.childNodes) {
          rawtext += phrase.alt || phrase.textContent;
        }
        var msg = {
          type: "chat-text",
          data: rawtext
        };
        parent.postMessage(JSON.stringify(msg), "https://www.youtube.com");
      }
    }
  });
  addEventListener("DOMContentLoaded", function() {
    mo.observe(document.querySelector("#items.yt-live-chat-item-list-renderer"),
      {childList:true});
  });
})();

本 UserJS のこの部分に限っては、iframe 内のチャット領域で実行される。

初挑戦

MutationObserver という珍しい API を使っている。近年、HTML へのノード追加・変更なんかを検出する DOMNodeInserted とかの、DOM Events の機能が、ブラウザから削除された。ゆえに、この新しい API を使うしかない。

ここで行っているのは、YouTube チャット領域を監視し、新しいチャットが書き込まれた際に、その本文だけを抜き出し、親フレームに送信することだ。

YouTube さんよぉ…

現時点の YouTube の仕様では、#items.yt-live-chat-item-list-renderer に該当する要素に対して、チャットへの新しい書き込みが、子要素として追加されていく。

YouTube の HTML には、なぜかは知らないが、同一の id を持つ要素が、大量に存在する。とんでもない仕様書違反行為である。こんな状況では document.getElementById() が意味を成さないので、#id.class で要素を泥臭く絞り込む必要があるのだ。

チャット本文取得

MutationObserver 内で for ループが二重になっているのが煩わしいが、避けられない。

rawtext += phrase.alt || phrase.textContent この部分では、チャットテキスト こんにちは あるいは、チャット絵文字 <img alt="💛" src="heart.png"> の alt を取得する。その他の要素は考慮していないが、おそらく問題はない。どんな要素の textContent も空文字か文字列である。もし、空文字ならば全く問題ない。逆に、たとえ、文字列だとしても、それはきっとチャット本文中に表示される文字列に違いないのであるから、問題ないはずなのだ。

表示

さて、次に、「動画上に表示するチャットの表示方法を決める」について。

// UI: Chat box on video
const container_id = "chatbox_on_video";
var addChatbox = function() {
  var video = document.querySelector(".html5-video-player");
  if (!video) return false;
  else if (document.getElementById(container_id)) return true;
  var container = document.createElement("pre");
  container.id = container_id;
  container.style.position = "relative";
  container.style.zIndex = "50";
  container.style.width = "0";
  container.style.opacity = "0.7";
  container.style.fontSize = "x-large";
  container.style.textShadow = "1px 1px 1px black";
  container.style.color = "white";
  video.appendChild(container);
  return true;
};
setInterval(addChatbox, 1000);

動画上に覆いかぶせるように、<pre> を relative で左上に配置している。z-index をそこそこ大きい数値にして、動画そのものに覆い隠されないようにしている。width:0 によって、動画を覆い尽くさないようにすることで、動画へのクリックイベントが動画に届くようにしている。幅ゼロではあるが、そこへ追加する文字列はきちんと表示される。

さて、次に「チャットを動画上に表示」について。

// Program: Chat render/updater
var doing = false;
addEventListener("message", function(event) {
  var container = document.getElementById(container_id);
  if (!doing || !container) return;
  if (event.origin === "https://www.youtube.com") {
    var json = JSON.parse(event.data);
    if (json.type === "chat-text") {
      var lim = 10;
      var list = container.textContent.split("\n");
      if (list.length > lim) {
        container.textContent = list.slice(-lim).join("\n");
      }
      container.textContent += json.data + "\n";
    }
  }
});

iframe 内のチャット領域から送信したチャット本文メッセージをこの部分で処理している。直近 10 件のチャット書き込みを動画上に表示する。

ON/OFF

最後に、「本 UserJS オン・オフ切り替えボタンの表示」について。

// UI: Controller (on,off)
const ctrlbtn_id = "chat_on_video_switch";
var switchUI = function(doing) {
  var container = document.getElementById(container_id);
  if (container) container.hidden = !doing;
  var btn = document.getElementById(ctrlbtn_id);
  if (btn) btn.style.opacity = 0.5 + doing;
};
var addCtrlBtn = function() {
  var dst = document.querySelector(".ytp-right-controls");
  if (!dst) return false;
  else if (document.getElementById(ctrlbtn_id)) return true;
  var imitee = dst.querySelector("button");
  if (!imitee) return false;
  var btn = imitee.cloneNode(true);
  btn.addEventListener("click", function() {
    doing = !doing;
    switchUI(doing);
  });
  btn.id = ctrlbtn_id;
  btn.style = "";
  btn.title = "chat overlay";
  btn.firstChild.innerHTML = `<text y="24" x="7" font-size="18">💭</text>`;
  dst.insertBefore(btn, dst.firstChild);
  switchUI(doing);
  return true;
};
setInterval(addCtrlBtn, 1000);

動画の下部に表示されるコントロール・ボタン部へ、独自ボタンを追加する。独自ボタンは、押すと、本 UserJS のオン・オフのスイッチが行われる。独自ボタンは、もともと YouTube にある本家ボタンをクローンしてつくっている。本家ボタンはそのアイコンの描画方法が SVG であり、それに倣って、SVG で「ふきだし」の絵文字を描画している。そうせずに生の文字列にすると、なぜか、ボタンの位置が上から 20px ほどズレて見づらく、押しづらくなる。

userjs/ytchat.js at 43cd6eef2fd8fb427a78b6eff0d0ebd659b69911 · 0mg/userjs · GitHub

Surface Pro 5 感想

Surface Pro 5 とは、2017年-2018年ごろに発売された Surface Pro のことだ。近頃、一か月間ほど使用している。

UEFI設定で本体スピーカーOFFできる

UEFI 設定画面は、電源が切ってある状態で、電源オンにする際、「音量+」ボタンを押しっぱなしにしつづけると入ることができる。

けど描画がバグることあり

設定画面には、本体カメラや本体スピーカーをオフにできる項目がある。使わないものはオフにしている。ただし、どれか 1 つでもオフにしていると、どういう因果かは不明だが、Windows サインイン画面で、描画がバグってしまうことがある。具体的には、ハイコントラスト表示に軽微な歪みを加えたようなものだ。一応、デスクトップアイコンなどは視認可能で、操作可能だが、色がめちゃくちゃで見ていられない。

この状態を解消するには、一度サインアウトしてから、サインインし直す。

オーディオインターフェース使える

Surface Pro 5 には、バスパワータイプの USB ポートハブ「Anker A7516」を接続し、2 つのデバイスを常時接続している。

どちらのデバイスも正常に動作している。非常に快適だ。電圧が足りなくなったことは一度もない。

今まで使っていた PC「Aspire V5-171」は、US-122mkII との相性が最高ではなく、たとえば、ある条件を満たすと、スリープ復帰後にブルースクリーンが生じたり、再起動しないと US-122mkII が動作しなくなったりしていた。

Surface Pro 5 と US-122mkII の相性は最高で、問題が起こったことは一度もない。

Bluetooth マウス使える

他に接続しているデバイスは、マウス「Logicool M336」だ。正常に動作している。こちらは、Bluetooth タイプなので、USB ポートを塞ぐことはない。

BitLocker 回復キーは控えよう

Windows 10 Pro では BitLocker が使える。BitLocker による保護を有効にする際は、「回復キー」を PC の外部(SD や紙など)に、確実に保存しておく必要がある。UEFI 設定を変更した後、Windows 起動時に、一度だけ、回復キーを求められたことがある。

デカすぎる解像度だが安心

画面の大きさは 12.3 インチで、解像度は 2736x1824px だ。等倍表示すると、あまりにもドットが小さく、1px の線は産毛のような細さだ。黒色なのに灰色に感じられるほどだ。ただし、表示スケールの設定がデフォルトで 200% になっており、Explorer のアイコンやフリーソフトGUI など、ほぼあらゆるものが 2 倍に拡大されて表示されている。

Web ブラウザ JavaScript window.screen オブジェクトにおいても、availWidth, availHeight がそれぞれ実際値の半分になっており、1368x912px と見なしたレンダリングをしてくれている。

古いソフトウェアの中には、この表示スケール拡大で良好に変換されないものもあり、ゴマ粒のように小さすぎて読むのが難しいフォントで表示されてしまうこともある。そういう時は、そのソフトのファイルプロパティで、「高 DPI スケール設定の上書き」を行えば、概ね対応できる。

画面オフ・モダンスタンバイ・休止状態

最近、新しい PC を入手した。

新しいスリープ「モダンスタンバイ」とは!?

この Windows 10 タブレットは、シャットダウンメニューから「スリープ」を選択すると、スリープ状態に移行する。しかし、スリープ状態なのに、USB デバイスへの給電が行われっぱなしのままになっている。

この状態は、現代的なスリープの形態であり、様々な名称で呼ばれている。

  • モダン スタンバイ
  • コネクト スタンバイ
  • InstantGo
  • S0ix

この記事では「モダンスタンバイ」と呼ぶ。

画面オフAPIがなぜかモダンスタンバイを引き起こす

モダンスタンバイに移行する方法は、現段階で 4 つあることが分かっている。

  • シャットダウンメニューから「スリープ」を選択する
  • 電源の設定で、「電源ボタンを押すとスリープする」に設定している状態で、電源ボタンを押す
  • 電源の設定で、設定した「自動的にスリープになる時間」に到達する
  • 画面をオフにする Windows API SendNotifyMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2) を呼ぶ

重要なのは、4 つ目である。画面オフを PC に命令しているだけなのに、モダンスタンバイになってしまうのだ。

なんでやねーん

モダンスタンバイは、ほぼ「画面をオフにしているだけ」の状態に等しい。なので、画面をオフにしたいだけのつもりでモダンスタンバイにするのは、望み通りの挙動とも言えるが、「画面オフ」と「モダンスタンバイ」の間には、決定的な違いがある。それは、モダンスタンバイから復帰後に、ログインパスワードを求められるということだ。スリープ復帰後にログインパスワードを求められるように設定しているからこそ、こういうことには当然なるのだが、「画面オフ」API を呼ぶことによって単純に「画面オフ」になってくれさえすれば、このような「ん?」と引っかかる状況にはならない。

望むことは一つ、画面オフ API を呼んだら、モダンスタンバイにはならず、画面オフになってくれ。

画面だけオフにしたいのに……(´・ω・`)

画面オフ状態に移行する方法は、現段階で 1 つあることが分かっている。

  • 電源の設定で、設定した「自動的に画面オフになる時間」に到達する

手動で画面オフは不可能である。

モダンスタンバイ中、なぜか勝手に休止状態になる

さて、PC をモダンスタンバイ状態にしていると、一時間ほど経過して、自動で休止状態に移行してしまう。これは "Adaptive Hibernate" と呼ばれている。

させない方法がある!

"Adaptive Hibernate" を制御する方法の一つに、電源の設定には通常現れない、隠し設定を表示して設定するというものがある。

すなわち、レジストリ コンピューター\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Power\PowerSettings\8619B916-E004-4dd8-9B66-DAE86F806698\9FE527BE-1B70-48DA-930D-7BCF17B44990 キーの値 Attributes (REG_DWORD) を 0x2 に変更する。

regedit.exe で上記を行った後、電源の設定で電源オプションを開くと、「プレゼンス認識電源動作」という "Adaptive Hibernate" 関係のカテゴリが追加されているのが確認できる。その中の唯一の項目「スタンバイ割り当て率」で、モダンスタンバイ状態でどれだけバッテリを消費していいかの率を定められる。デフォルトでは 5% であり、モダンスタンバイ中にバッテリが 5% 消費されると、自動で休止状態に移行する。時間に換算すると、うちの環境では一時間前後だ。

やったぜ

「スタンバイ割り当て率」を 0% に変更すると、モダンスタンバイ中に自動で休止状態へ移行することが行われなくなるはずである。検証中だ。

ちなみに

ところで、スリープする Windows APISetSuspendState(FALSE, FALSE, FALSE); というコードを実行すると、モダンスタンバイにはならず、なんと休止状態に移行する。

ありがとう World Wide Web