Monday, February 15, 2010

Beginning Firefox Extension Development (日本語訳あり)

I recently finished a Firefox addon to handle viewing videos on Nicovideo.
(最近ニコニコ動画の動画再生を操作するアドオンを完成した。)

I thought it would be beneficial to others to show how it was done.
(作り方を見せるのは皆さんに有益と思った。)

First, I used an online wizard to generate the addon skeleton:
(まずはオンラインウイザードでアドオンのスケルトンコードを生成する:)

http://ted.mielczarek.org/code/mozilla/extensionwiz/

I unpacked the resulting zip file to a c:\nicovideofullscreen
(出来上がったZIPファイルを「c:\nicovideofullscreen」へ解凍した。)

Then I put a file with the same name as my Extension ID in my profile folder's extension directory. In this case, the Extension ID is "nicovideofullscreen@onteria.jp":
(とFirefoxがこのアドオンを気付くためプロファイルフォルダーの「extension」フォルダーにExtension IDと同じ名前のファイルーを作った。この場合Extension IDは「nicovideofullscreen@onteria.jp」:)

c:\Users\-hidden-\AppData\Roaming\Mozilla\Firefox\Profiles\g1vcrvcd.default\extensions\nicovideofullscreen@onteria.jp

*Note: The profile folder depends on your operating system. Please review:
(※ ご注意: プロファイルフォルダーはOSによる。このページを確認して下さい:)

Profile
(プロファイル)

Next, I modified the install.rdf file, which contains basic details about the addon:
(次はアドオンの基本情報が含まれている「install.rdf」を編集した:)

<?xml version="1.0" encoding="UTF-8"?>
<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 
 xmlns:em="http://www.mozilla.org/2004/em-rdf#">
  <Description about="urn:mozilla:install-manifest">
    <em:id>nicovideofullscreen@onteria.jp</em:id>
    <em:name>Nicovideo Fullscreen</em:name>
    <em:version>1.0</em:version>
    <em:creator>onteria_</em:creator>
    <em:targetApplication>
      <Description>
        <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> <!-- firefox -->
        <em:minVersion>3.5</em:minVersion>
        <em:maxVersion>3.6.*</em:maxVersion>
      </Description>
    </em:targetApplication>
  </Description>
</RDF>

Here, I changed the Firefox version values to match 3.5 and 3.6.*. I am not using the alpha version, so I cannot say it works reliably in 3.7*.
(ここはFirefoxバージョンを3.5~3.6.*を適合させる。アルファバージョンを使ってないので3.7*に動けるかどうかがうまく確認出来ない。)

Now that I have the install.rdf file edited, I need to have the browser recognize my addon code, and execute the necessary function. To do this I create what's called an "overlay".
(「install.rdf」ファイルーを編集終わって、次はブラウザーが書いたアドオンコードを気づいて、必要な関数を実行する。そのため「overlay」と言う物を作る。)

The addon's basic overlay file looks like this:
(このアドオンのoverlayファイルーの基本はこう:)

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="chrome://nicovideofullscreen/skin/overlay.css" type="text/css"?>
<!DOCTYPE overlay SYSTEM "chrome://nicovideofullscreen/locale/nicovideofullscreen.dtd">
<overlay id="nicovideofullscreen-overlay"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
  <script src="io.js"/>
  <script src="utils.js"/>
  <script src="overlay.js"/>
  <stringbundleset id="stringbundleset">
    <stringbundle id="nicovideofullscreen-strings" src="chrome://nicovideofullscreen/locale/nicovideofullscreen.properties"/>
  </stringbundleset>
</overlay> 

Most of this code is generated by the previously mentioned skeleton code wizard. What Firefox will do is take this file and combine it with a specific part of the Firefox interface. In this case, it is being combined with the browser interface, as specified in the unchanged chrome.manifest file:
(多くのコードは以前に言及されたスケルトンコードウイザードで作られた。Firefoxはこのファイルを読み込みして、Firefoxインタフェースの特定の部分と結合される。この場合は変化していない「chrome.manifest」ファイルーによる、ブラウザーインタフェースと結合される:)

overlay chrome://browser/content/browser.xul chrome://nicovideofullscreen/content/firefoxOverlay.xul

While an overlay file is primarily used for adding UI components to the browser interface, we will be using it to overlay addon code. This is done through the three script tags from the previous code.  overlay.js contains the startup functions for the code, so we'll look at that first:
(overlayファイルは普通ブラウザーインタフェースにUIコンポーネントを追加する役目けど、今度はアドオンコードを追加する。これは以前の3つのスクリプトタグでやる。スタートアップ関数は「overlay.js」に含まれているのでまずはこのファイルを見ましょう:)

var nicofs = {
  page_listener: {
    QueryInterface: function(aIID)
    {
     if (aIID.equals(Components.interfaces.nsIWebProgressListener) ||
         aIID.equals(Components.interfaces.nsISupportsWeakReference) ||
         aIID.equals(Components.interfaces.nsISupports))
       return this;
     throw Components.results.NS_NOINTERFACE;
    },
    onLocationChange: function(aProgress, aRequest, aURI)
    {
     if (aURI && aURI.spec.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/)) {
  content.document.addEventListener("DOMContentLoaded",nicofs.setup, true);
     }
     return 0;
    },
    onStateChange: function(aWebProgress, aRequest, aFlag, aStatus) {return 0;},
    onProgressChange: function() {return 0;},
    onStatusChange: function() {return 0;},
    onSecurityChange: function() {return 0;},
    onLinkIconAvailable: function() {return 0;}
  },
  onLoad: function(aEvent) {
 gBrowser.addProgressListener(nicofs.page_listener,
 Components.interfaces.nsIWebProgress.NOTIFY_LOCATION);
  },
  onUnload: function() {
    window.removeEventListener("load", nicofs.onLoad, false);
 window.removeEventListener("unload", nicofs.onUnload, false);
    gBrowser.removeProgressListener(nicofs.page_listener,
    Components.interfaces.nsIWebProgress.NOTIFY_STATE_DOCUMENT);
  },
  fullscreenListener: function(evt) {
 if(!evt.target.ownerDocument.location.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/))
  return
  
 if(!content.fullScreen)
  BrowserFullScreen();
  },
  unfullscreenListener: function(evt) {
 if(!evt.target.ownerDocument.location.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/))
  return
 if(content.fullScreen)
  BrowserFullScreen();
  },
  setup: function() {
 if(!content.fullScreen)
  BrowserFullScreen();

 content.document.addEventListener("FullscreenEvent", function(e) { nicofs.fullscreenListener(e); }, false, true); 
 content.document.addEventListener("UnFullscreenEvent", function(e) { nicofs.unfullscreenListener(e); }, false, true); 
 scriptInjection.inject(content.document, "content/injectJS/setupPlayer.js");
  },
};
window.addEventListener("load", nicofs.onLoad, false);
window.addEventListener("unload", nicofs.onUnload, false);

There's a lot of code to deal with here, so let's trace through how it will be executed:
(コードがいっぱいので、まずは実行される所から進みましょう:)

window.addEventListener("load", nicofs.onLoad, false);
window.addEventListener("unload", nicofs.onUnload, false);
//...
  onLoad: function(aEvent) {
 gBrowser.addProgressListener(nicofs.page_listener,
 Components.interfaces.nsIWebProgress.NOTIFY_LOCATION);
  },
  onUnload: function() {
    window.removeEventListener("load", nicofs.onLoad, false);
 window.removeEventListener("unload", nicofs.onUnload, false);
    gBrowser.removeProgressListener(nicofs.page_listener,
    Components.interfaces.nsIWebProgress.NOTIFY_STATE_DOCUMENT);
  },
//...

"window.addEventListener" is used to add startup and cleanup functions for the addon. When the browser runs, it will run our onLoad function. When the browser unloads, it will run our onUnload function to cleanup.
「window.addEventListener」はアドオンのスタートアップとクリーンアップ関数を追加する役目。ブラウザーが実行する時、「onLoad」関数を実行する。ブラウザーがアンロードする時、「onUnload」関数を実行する。)

The onLoad function adds a progress listener. This will be discussed in a moment. gBrowser is global variable to access the tabbrowser. I mainly acquired this code by looking over the NicoFox extension's codebase, since it had the functionality I needed. The actual code to handle the progressListener is here:
(「onLoad」関数はプログレスリスナーを追加する。詳しくはすぐに説明する。「gBrowser」は「tabbrowser」をアクセスするグローバル変数。「NicoFox」と言うアドオンが私の希望した機能があったので、「NicoFox」のコードベースを検討する時このコードを見つけた。「progressListener」を操縦するコードはこちら:)

page_listener: {
    QueryInterface: function(aIID)
    {
     if (aIID.equals(Components.interfaces.nsIWebProgressListener) ||
         aIID.equals(Components.interfaces.nsISupportsWeakReference) ||
         aIID.equals(Components.interfaces.nsISupports))
       return this;
     throw Components.results.NS_NOINTERFACE;
    },
    onLocationChange: function(aProgress, aRequest, aURI)
    {
     if (aURI && aURI.spec.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/)) {
  content.document.addEventListener("DOMContentLoaded",nicofs.setup, true);
     }
     return 0;
    },
    onStateChange: function(aWebProgress, aRequest, aFlag, aStatus) {return 0;},
    onProgressChange: function() {return 0;},
    onStatusChange: function() {return 0;},
    onSecurityChange: function() {return 0;},
    onLinkIconAvailable: function() {return 0;}
  },

The important part here is onLocationChange. This notifies us of when a new page is about to be loaded in the current window. The URL is is checked against the format of a Nico video view page. If it does not match, the addon won't do anything. Through this method we can restrict the addon to be page specific. Next, a DOMContentLoaded (the DOM is loaded with the exception of images and frames) event listener is added to content.document. "content", or the deprecated "_content", is used to point to the current window. From there we can access the DOM of the current page using content.document. However, this leads to a problem that the next code looks to solve:
(ここに必要なポイントは「onLocationChange」。このイベントは新しいページが現在のウインドにロードを始まることをスクリプトに情報する。新しいページのURLはニコニコ動画の動画再生ページのフォーマットと照合される。対抗するとアドオンのコードはここで終わり。この方法ではスクリプトをページによる実行出来る。次、「content.document」に「DOMContentLoaded」(画像とフレーム以外全てのDOM内容がロードされた)イベントリスナーが追加された。「content」または廃止予定の「_content」は現のウインドに指摘するグローバル変数。「content.document」で現ページのDOMをアクセス出来る。でも、この方法には少し問題がある。次のコードはそれを直すつもり:)

setup: function() {
 if(!content.fullScreen)
  BrowserFullScreen();

 content.document.addEventListener("FullscreenEvent", function(e) { nicofs.fullscreenListener(e); }, false, true); 
 content.document.addEventListener("UnFullscreenEvent", function(e) { nicofs.unfullscreenListener(e); }, false, true); 
 scriptInjection.inject(content.document, "content/injectJS/setupPlayer.js");
  },

Before looking over the full screen code, I'd like to explain the scriptInject. scriptInject is the following code that I wrote:
(フールスクリーンを説明する前に「scriptInject」コードを説明したいと思う。次のコードは私が書いた「scriptInject」:

var scriptInjection = {
 extensionID: "nicovideofullscreen@onteria.jp",
 inject: function(document, script)
 {
  var source;
  if(typeof script == "function")
  {
   source = "(" + script + ")()";
  }
  else
  {
   source = scriptInjection.readExtensionScript(script);
  }
  
  var script = document.createElement('script');
  script.setAttribute("type", "application/javascript");
  script.textContent = source;
  document.body.appendChild(script);
 },
 readExtensionScript: function(path)
 {
  var em = Components.classes["@mozilla.org/extensions/manager;1"].  
  getService(Components.interfaces.nsIExtensionManager);  

  // the path may use forward slash ("/") as the delimiter  
  // returns nsIFile for the extension's install.rdf  
  var file = em.getInstallLocation(scriptInjection.extensionID).getItemFile(scriptInjection.extensionID, path); 
  if (file.exists()) {
   var str = FileIO.read(file);
   return str;
  }
 }
}

Because an addon can do dangerous things to a user's PC, it cannot directly access the javascript functions of a webpage. To get around this, I used a tactic from the Greasemonkey wiki called "Content Script Injection". This method inserts a script tag into the page's DOM, which will run at the webpage level, safe from the addon code. This code accepts one of two sources for the script: functions or files. If it is a function, it uses the function object's builtin "toString" method to obtain the function's source code. If it is a file, it reads the file in and uses it as the source. Once the source of the javascript is obtained, it is inserted in the DOM tree and run.
(アドオンはユーザーのPCに色々な危険な物が出来る理由でウエブページのあるJavascriptをアクセス出来ない。この問題を回避するため、Greasemonkeyウイッキーで見つけた「Content Script Injection」方法を使用した。この方法はページのDOMにスクリプトタグを追加して、アドオンコードから離れて、ページスコープで実行する。このコードはスクリプトのソース二つ対応出来る:関数とファイル。関数の場合は関数の「toString」と言う備わるメソッドで関数のソースコードを読み込む。ファイルーの場合、そのファイルを読み込んで、ソースとして使用する。JSのソースが取得されたらDOMツリーに追加されて、実行される。

Now to look into the functionality. When a user navigates to a video's view page, it will fullscreen the browser, wait until the nicoplayer is loaded, maximize the nicoplayer, wait 3 seconds, and finally play the video. Once the video playback is initiated, pausing the video will restore from fullscreen and restore the nicoplayer, and the end of the video will simply restore from fullscreen. Replaying / Resuming the video will restore fullscreen and maximize the player. Note that the Nico alerts are the same as pausing and resuming playback. First off is the code to deal with fullscreen, since that is not something one can do from within the DOM scope (otherwise I would have written a Greasemonkey script):
(今度はアドオンの機能を説明する。ユーザーが動画再生ページにナビゲートしたらこのアドオンはブラウザーをフルスクリーンにして、ニコプレイヤーのロード完成を待って、ニコプレイヤーを最大にして、3秒を待って、そして自動的に動画を再生する。動画再生が始まったら、動画を一時停止時、アドオンがブラウザーをフルスクリーンから戻って、ニコプレイヤーのサイズも戻す。動画再生を再び始めたら、アドオンはブラウザーをフルスクリーンにして、またニコプレイヤーを最大にする。ニコ情報は一時停止時から再生して、再生を再び始めると同じ。フルスクリーンはウエブページから出来ない(その場合だったらアドオンじゃなくてGreasemonkeyスクリプトのほうがいい)ので、まずはフルスクリーンのコードを見ましょう:

if(!content.fullScreen)
  BrowserFullScreen();

 content.document.addEventListener("FullscreenEvent", function(e) { nicofs.fullscreenListener(e); }, false, true); 
 content.document.addEventListener("UnFullscreenEvent", function(e) { nicofs.unfullscreenListener(e); }, false, true); 

  fullscreenListener: function(evt) {
 if(!evt.target.ownerDocument.location.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/))
  return
  
 if(!content.fullScreen)
  BrowserFullScreen();
  },
  unfullscreenListener: function(evt) {
 if(!evt.target.ownerDocument.location.match(/^http:\/\/(www|tw|es|de)\.nicovideo\.jp\/watch\/[a-z]{0,2}[0-9]+$/))
  return
 if(content.fullScreen)
  BrowserFullScreen();
  },

In order for the content document to communicate back to the document that the browser needs to fullscreen, event handling is used. On the web page, code will dispatch a custom event handler, which the addon will pickup. First, the addon checks to make sure another page is not sending this custom event to be safe. Then, it will fullscreen / restore if the browser is not in that state already.
(ドキュメントはブラウザーフルスクリーンの必要をアドオンコードに連絡するため、イベントハンドリングを使用する。ウエブページのコードはカスタムイベントを発送して、アドオンはそのカスタムイベントを受ける。アドオンのコードは安全のため、イベントの原因は別のサイトから発送されてないことを確認する。大丈夫だったらもう希望の状態じゃない場合アドオンはブラウザーをフルスクリーン・元に戻す。)

Next is the nicoplayer specific code, which will run in web page scope. The nicoplayer makes a javascript interface available for the script to use. This interface includes the follow methods and events:
(次はウエブページで実行するニコプレイヤー向かうコード。ニコプレイヤーはスクリプトが使用出来るJavascriptインタフェースを準備する。このインタフェースにはご覧のメソッドとイベントが含まれている:)

Methods
(メソッド)
  • ext_play(playback)
    • playback = true or false
    • true = Play / 再生
    • false = Stop /  停止
  • ext_setPlayheadTime(time)
    •  time = ms?
  • ext_setMute(setting)
    • true = Mute / ミュート
    • false = Unmutted / ミュートを解除する
  • ext_setVolume(volume)
    •  volume = 0-100?
  • ext_setCommentVisible(visibility)
    • true = コメントを表示する
    • false =  コメントを表示しない
  • ext_setRepeat(repeat)
    • true = リピートする
    • false = リピートしない
  • ext_setVideoSize(mode)
    •  "fit" = ニコプレイヤーを最大にする
    • "normal" = ニコプレイヤーを元に戻す
  • ext_isMute()
    • Get mute status  ミュート
  • ext_getVolume()
    • Get volume 音量
  • ext_isCommentVisible()
    • Get comment visibility コメント視程
  • ext_isRepeat()
    • Get repeat status リピート
  • ext_getVideoSize()
    • Nicoplayer size("normal", "fit") ニコプレイヤーのサイズ("normal", "fit")
  • ext_getStatus
    • ニコプレイヤーのステータス
      • seeking シーク中
      • load ロード中
      • stopped 停止
      • playing 再生中
      • paused 一時停止
      • end 終わり
  • ext_getPlayheadTime()
    • Playback time from beginning 初めからの期間(ms?)
  • ext_getTotalTime()
    • Video duration 動画の期間
  • ext_isEditedOwnerThread
    • ?
  • ext_sendLocalMessage
    • Post comment コメントポスト
  • ext_getLoadedRatio()
    •  Load percentage(0-1) ロード率(0-1)
Events
イベント
  • Nicovideo.launchP4
    • ?
  • onNicoPlayerReady(id)
    • Nicoplayer setup is done ニコプレイヤーの準備は終了
    • id = flvplayer
  • toggleMaximizePlayer
    • Nicoplayer maximized ニコプレイヤーが最大に
  • onNicoPlayerStatus(id, status)
    • Nicoplayer status changed ニコプレイヤーのステータスが変わった
    • id = flvplayer
    • status = see ext_getStatus 「ext_getStatus」を確認して下さい
  • resetMymemoryParameters 
    • ?

In order to properly maximize the Nicoplayer, we need to make sure it's loaded.  We can use the onNicoPlayerReady event to do so.  However, this is already called, so we use this method to add on to the existing code:
(ニコプレイヤーを最大にするためプレイヤーロードの終わりを待たなければならない。このため「onNicoPlayerReady」イベントを使用出来る。でも、このイベントはもうページにコールされているので、そのコードに自分のコードを追加しなくちゃ:)

var nicofs_playerInit = false;
var tmpFunction = onNicoPlayerReady;
onNicoPlayerReady = function()
{
 tmpFunction();
 $("flvplayer").ext_setVideoSize("fit");
 window.setTimeout('$("flvplayer").ext_play(true);', 3000);
}

The code maximizes the Nicoplayer, then tells it to start playback in 3 seconds.
(このコードはニコプレイヤーを最大にして、3秒後再生を始める。)

Finally, we use the onNicoPlayerStatus to maximize / restore according to the status:

function onNicoPlayerStatus(object, status)
{
 if(!nicofs_playerInit)
 {
  if(status == "playing")
   nicofs_playerInit = true;
  
  return;
 }
 
 switch(status)
 {
  case "playing":
   fullscreenBrowser();
   $("flvplayer").ext_setVideoSize("fit");
   break;
  case "paused":
   unfullscreenBrowser();
   $("flvplayer").ext_setVideoSize("normal");
   break;
  case "end":
   unfullscreenBrowser();
   break;
  default:
   break;
 }
}

function fullscreenBrowser()
{
 var event = content.document.createEvent("Events");
 event.initEvent("FullscreenEvent", true, false);
 $("flvplayer").dispatchEvent(event);
}

function unfullscreenBrowser()
{
 var event = content.document.createEvent("Events");
 event.initEvent("UnFullscreenEvent", true, false);
 $("flvplayer").dispatchEvent(event);
}

Notice the custom "FullscreenEvent" and "UnFullscreenEvent" event handlers to communicate with the addon.
(アドオンコードと連絡するため「FullscreenEvent」と「UnFullscreenEvent」のイベントハンドラーをご注意ください。)

That, in a nutshell, is how I created my Firefox addon. Feel free to ask any questions in the comments field.
(はい、簡単に言うとこれで初めてのFirefoxアドオンを作った。もし質問があればコメント欄で聞いて下さい。ではー)