YouTube ライブのチャットを動画上に表示する Chrome 拡張機能
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