から拡張機能つくったけど、Googleに承認されなかったのでここで供養させてください…
2020/12/18 追記
承認されてました。
ResumeVideo - Chrome ウェブストア
追記終わり
概要
機能概要
いわゆる「レジューム再生」機能です。
参考)レジューム再生(続きから再生)の使い方 | ニコニコ動画アプリヘルプ
主に以下2つの機能から構成されます。
動画保存
- 現在再生中の動画URLと再生時間を保存します。
- コンテキストメニュー(拡張機能アイコンやChrome上で右クリック)またはショートカットキーから呼び出します。
- 正常に保存できれば、現在のタブを閉じ、拡張機能アイコンのバッジ(GIFでは緑色部分)が加算されます。
レジューム再生
得られた知見
今回初めてChrome拡張機能を作成しましたが、なかなか勘所を掴むのに苦労しました。
結果として以下の知見が得られたので、本文中で詳しく書いていこうと思います。
- 拡張機能では、3つの異なる実行環境を意識して設計・開発しなければならない。
- 重めの処理はポップアップでは行わない。早めにバックグラウンドに処理を渡す。
-
"activeTab"
権限の範囲に注意。"<all_urls>"
など権限を付けるとかなり審査が厳しくなる。
はじめに
動機
W◯Sの続きから見たいんだよ!
皆大好きW◯S、私は録画環境が無いのでオンデマンド配信で前日放送分を見てるのですが、どうしてもながら観の都合で途中で切ってしまう。
という訳で一昨日放送分の途中から見ようと思っても、公式では“続きから再生”機能がありません。
※ アプリ版では“続きから再生”できますが、2倍以上で倍速再生ができないし、画面小さいし…。PCではこの拡張機能で任意の速度で再生できます。
拡張機能で実現可能?
どうしたもんかと考えた時に、前述の再生速度を変える拡張機能があるのであれば、続きから再生する機能も拡張機能で作れるのではないかと思いつきました。
それができれば、OneTab - Chrome ウェブストアのようにタブ情報を保存する機能と組み合わせて「続きから再生」できる!
よし、拡張機能作ろう!
というのがきっかけです。
筆者スペック
フロント未経験、組み込み2年目。ウェブやりたい。
拡張機能の設計が組み込みっぽい考え方をするなぁと思ったのでその視点を提供できればと。
本記事の内容
というわけで本記事では、機能概要で紹介した「動画保存」と「レジューム再生」機能の設計と実装を中心に見ていきます。
以下の内容については詳しく触れないので、別途参考ページ等をご確認ください。
- 拡張機能とは?ソースから読み込む方法は?
- 公式のチュートリアルが一番分かりやすいです。Getting Started Tutorial - Google Chrome
- 拡張機能のアーキテクチャ
- やはり公式が一番。Architecture overview - Chrome Developers
- 日本語ではこの記事が詳しいです。チュートリアルをざっと流すとより理解しやすいでしょう。Chrome拡張の開発方法まとめ その1:概念編 - Qiita
- ウェブストアへの公開方法
ソース
全ソースは以下。
resume_video/ResumeVideo at master · arakaki-tokyo/resume_video
「動画保存」機能
それでは早速「動画保存」機能から見ていきましょう。
基本的な設計は以下のようになります。
関連ソース
const StockVideo = (tab) => {
// find video tag
chrome.tabs.executeScript(
{
allFrames: true,
code: `
vs = document.getElementsByTagName("video");
for(v of vs){
if(v.currentSrc !== ''){
v.currentTime;
break;
}
}
`
},
(result) => {
// The result is array, having returns of each flame.
// If flames don't contain valid video tag, the return value is null.
for (const value of result) {
if (value !== null) {
chrome.storage.local.get(
['videos'],
(result) => {
const newVideos = result.videos;
newVideos.push({
timeStamp: Date.now(),
url: tab.url,
title: tab.title,
currentTime: value,
f: tab.favIconUrl
});
chrome.storage.local.set(
{ videos: newVideos },
() => {
chrome.browserAction.setBadgeText({ text: String(newVideos.length) });
chrome.tabs.remove(tab.id);
}
);
}
);
break;
}
}
}
);
};
再生時間の取得
コンテキストメニューのクリックなどから呼び出される関数では、最初にContent scriptsによって動画の再生時間を取得します。
ソース該当箇所
...
chrome.tabs.executeScript(
{
allFrames: true,
code: `
vs = document.getElementsByTagName("video");
for(v of vs){
if(v.currentSrc !== ''){
v.currentTime;
break;
}
}
`
},
(result) => {
...
chrome.tabs.executeScript(integer tabId, object details, function callback)
Injects JavaScript code into a page. For details, see the programmatic injection section of the content scripts doc.
ここでallFrames: true
としているのは、配信サイトによって動画のみ別フレームで埋め込んでいる場合があるためです。
また、実際に再生される動画以外にもvideoタグがある場合があるので、有効なvideoタグをif(v.currentSrc !== '')
で判定しています。
参照)4.8.6 The video element — HTML5#dom-media-currentsrc
The
currentSrc
IDL attribute is initially the empty string. Its value is changed by the resource selection algorithm defined below.
現在の再生時間はvideoタグのcurrentTime
属性から取得します。
参照)4.8.6 The video element — HTML5#dom-media-currenttime
The
currentTime
attribute must, on getting, return the current playback position, expressed in seconds. On setting, the user agent must seek to the new value (which might raise an exception).
コールバックの引数
コールバックの引数には各フレームで実行されたスクリプトの結果が配列として格納されるのだとドキュメントに書いてますが、「スクリプトの結果」とは何なのかがよく分かりません。
function(array of any result) {...};
array of any (optional) result
The result of the script in every injected frame.
chrome.tabs - Google Chrome
下記によれば、最後に評価された文(式文)の値が「スクリプトの結果」となるようです。
"The result of the script" is the value of the last evaluated statement, which can be the value returned by a function (i.e. an IIFE, using a
return
statement). Generally, this will be the same thing that the console would display as the results of the execution (notconsole.log()
, but the results) if you executed the code/script from the Web Console (F12) (e.g. for the scriptvar foo='my result';foo;
, theresults
array will contain the string "my result
" as an element).javascript - chrome.tabs.executeScript(): How to get result of content script? - Stack Overflow
太字筆者
という訳で、ソースではv.currentTime;
が評価された値(=v.currentTimeの値)がresultに入ることになります。
動画の保存
再生時間が取得できたら、他の情報もオブジェクトにまとめてストレージに保存します。
手順としては以下にようになります。
- 現在の動画リストをストレージから取得
- 動画リストに今回取得した動画を追加
- 動画リストをストレージに保存
ここで動画リストは、下記オブジェクトの配列です。
ソース該当箇所
newVideos.push({
timeStamp: Date.now(),
url: tab.url,
title: tab.title,
currentTime: value,
f: tab.favIconUrl
});
urlやページtitle、favicon urlはtab
オブジェクトから取得しています。
参照)chrome.tabs - Google Chrome#type-Tab
string (optional) url
The last committed URL of the main frame of the tab.
string (optional) title
The title of the tab.
string (optional) favIconUrl
The URL of the tab's favicon.
ではこのtabオブジェクトはどこから来たのかというと、コンテキストメニューのコールバックに渡される引数です。
参照)chrome.contextMenus - Google Chrome
addListener
chrome.contextMenus.onClicked.addListener(function callback)
The callback parameter should be a function that looks like this:
function(object info, tabs.Tab tab) {...};
「レジューム再生」機能
続いて「レジューム再生」機能を見ていきましょう。
設計は以下。ここで概要でも触れた「3つの実行環境」、すなわちPopUpとBackground、Tab(Content Script) が出揃います。
関連ソース
chrome.storage.local.get(
'videos',
(result) => {
videoList = result.videos;
let content = "<ul>"
for (const video of result.videos) {
const iCurrentTime = parseInt(video.currentTime);
const sCurrentTimeMinutes = String(Math.floor(iCurrentTime / 60)).padStart(2, "0");
const sCurrentTimeSeconds = String(iCurrentTime % 60).padStart(2, "0");
content += `
<li>
<p class="timeStamp">⏯<b>${sCurrentTimeMinutes}:${sCurrentTimeSeconds}</b> (${new MyDate(video.timeStamp).strftime("%Y/%m/%d %H:%M")})</p>
<div class="linkContainer">
<a class="videoLink" href="${video.url}" title="${video.url}" timeStamp="${video.timeStamp}" currentTime="${video.currentTime}">
<img src="${video.f}">
${video.title}
</a>
<button class="delete" timeStamp="${video.timeStamp}">✖</button>
</div>
</li>`;
}
videoListContainer.innerHTML = content;
Array.prototype.forEach.call(document.getElementsByClassName("videoLink"),v => {
v.addEventListener("click", function(e){
deleteVideo(e);
chrome.runtime.sendMessage(
{
videoUrl: this.getAttribute("href"),
currentTime: this.getAttribute("currentTime"),
}
);
});
});
Array.prototype.forEach.call(document.getElementsByClassName("delete"),v => {
v.addEventListener("click", function(e){
this.closest("li").style.display = "none";
deleteVideo(e);
});
});
}
);
chrome.runtime.onMessage.addListener(
function (request, sender, sendResponse) {
chrome.tabs.create(
{ url: request.videoUrl, active: true },
(tab) => {
chrome.permissions.contains({ origins: ['<all_urls>'] }, result => {
if (result) {
(function resume(cnt) {
setTimeout(() => {
chrome.tabs.executeScript(
tab.id,
{
allFrames: true,
code: `
vs = document.getElementsByTagName("video");
for(v of vs){
if (v != undefined && v.currentSrc != "" && v.currentTime < ${request.currentTime})
v.currentTime = ${request.currentTime};
}
`
}
);
if (cnt > 0)
resume(--cnt);
}, 1500);
})(40);
}
});
}
);
}
);
動画一覧表示
拡張機能のポップアップでは、保存した動画の一覧を表示します。
当初は地道にdocument.createElement()
して属性つけて〜と考えてたのですが、コードが膨らんで極めて読みづらくなったのでテンプレートリテラルに変更しました。
該当箇所
content += `
<li>
<p class="timeStamp">⏯<b>${sCurrentTimeMinutes}:${sCurrentTimeSeconds}</b> (${new MyDate(video.timeStamp).strftime("%Y/%m/%d %H:%M")})</p>
<div class="linkContainer">
<a class="videoLink" href="${video.url}" title="${video.url}" timeStamp="${video.timeStamp}" currentTime="${video.currentTime}">
<img src="${video.f}">
${video.title}
</a>
<button class="delete" timeStamp="${video.timeStamp}">✖</button>
</div>
</li>`;
参考)
JavaScriptでinnerHTMLとappendChildの速度比較
しかしながらこの方法では、ループ内でクリックイベントを設定することができません。
というのも、拡張機能ではonclick
などのインラインイベントハンドラーが禁止されているのです。
参照)Content Security Policy (CSP) - Google Chrome
Inline JavaScript will not be executed. This restriction bans both inline
<script>
blocks and inline event handlers (e.g.<button onclick="...">
).
というわけで、DOMを生成した後a
タグにまとめてクリックイベントを設定します。
該当箇所
Array.prototype.forEach.call(document.getElementsByClassName("videoLink"),v => {
v.addEventListener("click", function(e){
deleteVideo(e);
chrome.runtime.sendMessage(
{
videoUrl: this.getAttribute("href"),
currentTime: this.getAttribute("currentTime"),
}
);
});
});
Popupの生存期間
いよいよレジューム再生のシーケンスに入りますが、ポップアップ上で動画リンクがクリックされた場合、popup.js
ではさっさとバックグラウンドに処理を渡してしまいます。(⇑のchrome.runtime.sendMessage
)
「指定urlを開く」だけの処理であればpopup.js
からも実行可能ですが、popup.js
の処理はタブ移動などでポップアップが閉じると終了してしまいます。(シーケンス図参照)
そこで、指定urlを開いた後も処理を行う場合など、重め(長め)の処理はバックグラウンドで実行させることで予期せぬ終了を防ぐことができます。
ちなみに、拡張機能のポップアップ内のa
タグはtarget
属性がついていない場合、クリックでは反応しないようです。(右クリックから「新しいタブで開く」などは可能)
activeTab
権限の範囲
さて、popup.js
からメッセージを受け取ったbackground.js
ではタブの生成(chrome.tabs.create
)と指定時間からの再生(chrome.tabs.executeScript
)を行う訳ですが、ここで拡張機能の権限について確認したいと思います。
結論から言うと、今回のような処理の場合<all_urls>
権限が必須となります。
なぜなら、指定時間からの再生のために実行するContent scripts(chrome.tabs.executeScript
)がこの権限を要求するためです。
ところで、chrome.tabs.executeScript
は「動画保存」機能でも呼び出していました。実はこのときのContent scriptsはactiveTab
権限で実行できていたのです。
というのも、activeTab
権限は拡張機能が呼び出されたときにアクティブなタブに対する一時的な権限を与えます。
参照)Declare permissions and warn users - Chrome Developers
The
activeTab
permission grants an extension temporary access to the currently active tab when the user invokes the extension.
その権限によってContent scriptsが実行できるのですが、レジューム再生のシーケンスで開かれる動画ページのタブは、前述のactiveTab
権限の範囲外となってしまいます。
ちなみに、chrome.tabs.create
ではなくchrome.tabs.update
によって、拡張機能が呼び出されたときにアクティブなタブで動画ページを読み込んだとしても、activeTab
権限の範囲外です。
activeTab
権限で付与される“一時的な権限”は、ページ遷移によって失効してしまうからです。
以上の理由から今回の拡張機能では<all_urls>
権限が必要だったのですが、この権限を付与すると拡張機能は極めて広範な処理ができてしまいます。
参考)ブラウザ拡張の権限でどこまで(悪いことを)できるのか?とその対策【デモあり】 - Qiita
そのため、ウェブストアに登録する際も審査に時間が掛かる(厳しくなる)旨が明記されているので、可能であれば<all_urls>
権限などは使わないようにしたほうがよいでしょう。
videoタグで指定の時間から再生
ようやくレジューム再生の処理ですが、やってることはシンプルで、videoタグのcurrentTime
属性に保存していた再生時間をセットするだけです。
該当箇所
...
chrome.tabs.executeScript(
tab.id,
{
allFrames: true,
code: `
vs = document.getElementsByTagName("video");
for(v of vs){
if (v != undefined && v.currentSrc != "" && v.currentTime < ${request.currentTime})
v.currentTime = ${request.currentTime};
}
`
}
);
...
問題なのはこのContent scriptsを実行する時点でvideoタグ(またはvideoタグを含むフレーム)が読み込まれているかなどが不確定なことで、試行錯誤の中では以下のようなことがありました。
- 記事タイトルのサイトでは一日ほどでセッションが切れるので、その場合動画URLを読み込んで再生しようとしても一旦ログインページに飛ばされる。
- 記事タイトルのサイトでは上記Content scriptsを実行して途中から再生開始しても、コンマ数秒後に最初から再生されてしまう。(サイトの元々のスクリプトの挙動か?)
- You Tubeでは再生時に広告が差し込まれる場合、その広告に対してContent scriptsが実行されてしまう。
当初はContent scriptsが正常に終了したか判定してどうのこうのというフローを考えてたのですが、上記諸々に対応しようとするとコードが複雑になる割に融通が効かないので、とにかく1500ms毎に1分間繰り返すことにしました。
また、「1500ms毎に1分間繰り返す」も含めてContent scriptsとして実行する方法も考えられます。
参考)Comparing improve_contentscript..93b741b0286ba4e0d7a1aa21846d323a8cf566cd · arakaki-tokyo/resume_video
が、その実装だと記事タイトルサイトではレジューム再生できません。
恐らくvideoタグを含むフレームが読み込み完了する前にContent scriptsの挿入が行われてしまうからかと思いますが、原因不明。
なおchrome.tabs.executeScript
では挿入タイミングを指定することもできるですが、最も遅く挿入されるように指定しても駄目でした。
参照)
- chrome.tabs - Chrome Developers
- chrome.extensionTypes - Chrome Developers
- extensionTypes.RunAt - Mozilla | MDN
余談
不承認理由
違反:
違反の参照 ID: Yellow Magnesium
違反事項:
説明に表記されている機能を提供していない。
申請で書いた拙い英語が分かりづらかったのか…?対応できてない動画サイトがあったのか…?
上記以外のヒントがないのでよく分かりません🥺
対応サイト
元々がテレ東ビジネスオンデマンドのためだけに作った拡張機能ですが、単純なvideoタグを使った動画配信サイトであれば大体いけると思います。今回確認した限りだと以下の通りです。
- You Tube:いけます
- Tver:いけます(というか元から続きから再生できますが、Cookieやウェブストレージなどを利用しているようなので、それらを使用できないシークレットモードではこの拡張機能が使えます!まぁ使わないですね…)
- AbemaTV:いけます(元から続きから再生できる、同上)
- プライムビデオ:いけない(こんな拡張機能を使うまでもないですが、動画再生ページが動的に生成されるため)
という訳で、ちゃんとしたVODサービスであればデフォルトで「続きから再生」機能がついてるのでこんな拡張機能は不要なのでした😂
終わりに
結局役に立たない拡張機能を作り上げてしまいましたが、個人的には拡張機能のイロハを掴めたことと、VODサービスのUIなどについて考える機会を得たことは有意義な経験でした!
群雄割拠の様相を呈するVOD界隈ですが、大手の配信事業者に依存することなく、小規模なコンテンツホルダーが直接配信サービスを手掛ける道も残されるべきだと思います。
問題はやはりインフラやUI/UXの整備に掛かるコストですが、Podcastの動画版みたいな標準規格でもあれば現状に一石を投じることができるのでは、と想像してみたのですが難しいんでしょうね〜
ちなみに私はアマプラの有料チャンネルに乗り換えました。ごめんなさい…