videojs-contrib-hls の AES 適用コンテンツにおける inactive なタブまたはウインドウの再生遅延対策

inactive なタブまたはウインドウで再生遅延(音飛びなど)が発生

videojs/videojs-contrib-hls で AES が適用された HLS のプレイリストを再生中に、タブがバックグラウンドになったり、タブ的にはフォアグラウンドだけどウインドウ単位でバックグラウンドだったり、ようは不可視状態になってしばらくすると音飛びする現象がありました。

去年末の調査当時は Chrome と Firefox を使って v0.17.9 (videojs v4版) と v1.2.2 (videojs v5版) で検証しました。HLS ネイティブな Edge や Safari では影響がない話です。

Decrypt のスレッド占有回避に setTimeout してるところ

原因を掘っていくと、videojs-contrib-hls/decrypter.js の Decrypt 処理に行き当たりました。

// split up the encryption job and do the individual chunks asynchronously
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
                                          key,
                                          initVector,
                                          decrypted));

AES のソフトウェア decryption をしているところで、非同期ジョブ管理に使っている asyncStream が患部っぽい。WebCryptェ...。

Page Visibility による setTimeout の実行頻度低下

asyncStream という videojs-contrib-hls/decrypter.js の非同期風ジョブ管理の元を辿ると、setTimeout を使った制御が見られます。delay = 1 なので、やりたいことはほとんど setImmediate です。

AsyncStream = function() {
  this.jobs = [];
  this.delay = 1;
  this.timeout_ = null;
};
AsyncStream.prototype = new videojs.Hls.Stream();
AsyncStream.prototype.processJob_ = function() {
  this.jobs.shift()();
  if (this.jobs.length) {
    this.timeout_ = setTimeout(this.processJob_.bind(this),
                               this.delay);
  } else {
    this.timeout_ = null;
  }
};
AsyncStream.prototype.push = function(job) {
  this.jobs.push(job);
  if (!this.timeout_) {
    this.timeout_ = setTimeout(this.processJob_.bind(this),
                               this.delay);
  }
};

setIntervalsetTimeout はタブが inactive だとタイマー実行が遅延されます。それによって、セグメントデータの decryption が追いつかずデータが詰まっていた模様。音声、動画のように途切れてほしくないメディアコンテンツをアレするときには避けたい一手ですね。

postMessage に逃がせば OK

非同期風で実行が遅延されない postMessage に置き換えます。decryption を直接 WebWorker に投げればいいんでは...という気もしますが今回はモンキーパッチなので、setTimeout の役割を postMessage で置き換える対応を行います。

AsyncStream = function() {
  this.jobs = [];
  this.origin = location.protocol + '//' + location.host;
  this.messageId = 'videojs-contrib-hls-' + Date.now();
  this.handler_ = null;
};
AsyncStream.prototype = new videojs.Hls.Stream();
AsyncStream.prototype.processJob_ = function() {
  this.jobs.shift()();
  if (this.jobs.length) {
    window.postMessage(this.messageId, this.origin);
  } else {
    window.removeEventListener('message', this.handler_, false);
    this.handler_ = null;
  }
};
AsyncStream.prototype.push = function(job) {
  this.jobs.push(job);
  if (!this.handler_) {
    this.handler_ = function(event) {
      if (event.origin !== location.origin || event.data !== this.messageId) {
        return;
      }
      this.processJob_();
    }.bind(this);
    window.addEventListener('message', this.handler_, false);
  }
  window.postMessage(this.messageId, this.origin);
};

Pull Request がんばる

おもむろに書いたらテスト通らなくてアレですが、ちゃんと contribute しようという気持ちはあるのでがんばりましょう。