Safie Engineers' Blog!

Safieのエンジニアが書くブログです

動画視聴のストレスゼロへ!ぼくのかんがえたさいきょうのシークバー

こんにちは。セーフィー株式会社エンジニアの伊藤です。

今回のお話は、動画配信サービスを開発・運営されている皆さまへ「UI/UX」についてのお願いです。冒頭からかなり対象を絞った内容に感じられるかもしれませんが、きっと多くの方にも共感していただける部分があると思います。

どうぞ気軽にお読みいただければ幸いです。

まずはこちらをご覧ください。(※音が出ます)

※ iOS では上手く表示されない不具合があります。動作環境は PC がおススメです。

動画見ながら寝落ち問題

さて、皆さんは「動画配信サービス」を利用していますか?

YouTube、Netflix、Amazonプライム・ビデオ、他にも数えきれないほどのサービスが乱立していますが、現代人なら何かしら一つは使っているのではないでしょうか。中には、いくつものサービスに加入して、毎晩ついつい夜更かししてしまったり、休日は朝から晩まで動画漬け、、なんて方もいらっしゃるかもしれませんね。かく言う私も、そんな動画漬けの毎日を送っている一人です。

そして今回は、そんな生活の中でどうしても気になる “あるUI” について、一言物申したいと思います。

それは「シークバー」です。

私は普段、寝ころびながらスマホで動画を見ることが多いのですが、ついつい寝落ちしてしまうことがあります。気づけば動画は再生し終わっていて、「あれ?どこまで見たんだっけ?」と、後から見返そうとするのですが、そこで困るのがシークバーです。動画の総再生時間に対して、シークバーの表示があまりにも短く、指で正確に寝落ちした位置まで移動するのがとても難しいのです。

シークバーのツライところ

「いやいや、それって本当にそんなに難しいの?」とピンとこない方もいるかもしれません。そこで、どれくらい操作が難しいのかを定量的に検証してみましょう。

まず、シークバーの物理的な長さですが、スマホを横向きにしたときの画面幅に依存します。端末によって違いはあるものの、大きめのスマホでも画面の横幅はせいぜい 150mm 程度です。(ちなみに、私が使っているスマホは 110mm でした)

ただし、これは画面全体の幅。実際のシークバーには左右にマージン(余白)があるため、バー自体の有効な長さはもう少し短くなります。ここでは仮に、シークバーの長さを 140mm としてみましょう。この状態で 2時間(= 7,200秒)の映画を見ているとすると、

1mm あたりの時間: 7,200秒 ÷ 140mm ≒ 50 秒/mm

さらに、指の腹の太さはだいたい 15mm くらいありますよね。つまり、指先 1 本分に約 12 分(=50秒 × 15mm)の映像が圧縮されているわけです。

この状態で「あと5秒戻りたい」と思っても、1mm以下の繊細な操作が求められます。まるでお米に字を書くようなものですね。しかも、苦労してシークバーを微調整したところで、動画が再生されるまでは、どの場面から始まるのか分かりません。「このシーンはまだ意識があったな」「あっ、ちょっと行きすぎた」そんなことを考えながら、スクリーンを何度も指でスリスリ。そして、行きすぎては戻し、戻しすぎては進める。こうして、微調整を何度も繰り返す羽目になるのです。

理想のシークバーの提案

そこで冒頭の動画の新しいシークバーの提案です。各社の動画配信サービスでぜひ採用していただきたい、理想のシークバーを自分なりに実装してみました。

工夫したポイントは、主に以下の2つです。

1. シークバーのスケールを拡大・縮小できる

再生位置の指定は、マウスならドラッグ操作で、スマホならタッチ操作で行います。これ自体は一般的な操作ですが、ここにスケーリング機能を加えました。

  • マウスホイールで拡大・縮小(スマホの場合はピンチイン・ピンチアウト)
  • スケールを大きくすれば、ざっくり全体を早送り・巻き戻し
  • スケールを小さくすれば、秒単位での微調整もストレスフリー

「まず大まかに探し → 細かく調整する」という動作が、スムーズに完結できるのです。

2. 動画のサムネイルを表示して視覚的に検索できる

シークバーの上に動画のサムネイルを表示する機能も加えました。これは最近のプレイヤーにもよく見られる機能ですが、今回の提案ではスケール拡大・縮小と組み合わせて使えるのがポイントです。

  • 拡大時には、前後のフレームが視認できるようにし、シーンの切り替わりもひと目で把握できる
  • 「どこで寝落ちしたか」が視覚的に探しやすくなる

この2点を組み合わせることで、「あの場面を探したいのに見つからない」という、あのもどかしさから解放されるのではないかと考えています!

まとめ

以上、現場からのお願いでした!

ちなみに冒頭のデモのサムネイル部分の描画には、ブラウザにそこそこ負荷がかかっているようで、上手く動作しないことがあります。特に iOS ではサムネイルが上手く表示されない不具合が見つかっています。動作環境は PC がおススメです。

また今回作ったシークバーをお好きな動画でお試しいただけるように、お手元の動画ファイルを読み込めるバージョンを用意しました。良ければこちらも使ってみてください。

動画配信サービスを手がける皆さま、どうかこの寝落ちユーザーの声に耳を傾けていただければ幸いです!

おまけ(シークバーの作り方)

ここからはエンジニア向けのお話しになります。せっかくなので、スケールを自由に変更できるシークバーの実装方法について解説したいと思います。

冒頭の動画サンプルでは、サムネイル表示などの機能も含めているため、コードが少し複雑になっています。そこで、今回はシーク機能に限定したシンプルなデモを用意しました。こちらのコードをベースにして、具体的な実装ポイントや仕組みをわかりやすく説明していきたいと思います。ソースコードはこちらです。

実装方法はいくつか考えられますが、今回はWebアプリとしての開発ということで、 Canvas API を使って実装しました。Canvas API は標準的な描画処理のインターフェイスを提供しており、モバイルなど他のプラットフォームでも似たような機能やライブラリがあるので、比較的簡単に移植が可能だと思います。

ステップ1 設計図を書く

まずはデザインを決めて、その設計図を描きましょう。Canvas API では点や線、長方形といった基本的な図形のほか、テキストや画像も描画できますが、どんなGUIを作るにしても、まずは描きたいものを細かな要素に分解することが大切です。

サンプルは下図のようなデザインになっていますが、よく見ると線と長方形、そしてテキストで構成されていることが分かると思います。

こちらに細かく寸法を記入していきます。

寸法の取り方はセンス次第で自由に決めていただいて構いませんが、このようなパラメータを変数としてまとめておくと、あとからデザインを調整するときにとても便利です。

const SEEKBAR_POS_Y = 60;
const SEEKBAR_HEIGHT = 15;
const SCALE_LINE_HEIGHT = 8;
const SCALE_TIME_TEXT_SIZE = 16;
const CURRENT_TIME_TEXT_SIZE = 28;
const HEIGHT = 120;

ステップ2 座標変換

続いて、シークバーの描画に必要な知識をインプットしていきましょう。

シークバーの横方向は、動画の時間の経過を表しています。ここで、動画の時間と画面上の描画位置を結びつけるために、「座標変換」という考え方が役立ちます。聞き慣れない言葉かもしれませんが、実は中学生のときに習った一次関数の考え方とほぼ同じものです。

今回作ったものは、まず図のように動画全体から、その一部を切り取った赤枠の部分を画面上に描画しています。このように考えると、「動画の時間軸」と「画面の座標軸」という、2つの異なる座標軸が存在していることになります。

ここで、動画の時刻を  t 、画面の水平方向の位置を  x とします。さらに、

  • 画面の左端の位置に対応する時間:  startTime
  • 画面1ピクセルあたりの時間幅:  timePerPix

とすると、次のような変換式が成り立ちます。

 t = timePerPix * x + startTime

こちらの式だけでは、時間軸と画面座標軸の変換を具体的にイメージしづらいと思うので、下記のように値を定義して、その関係を図示してみます。

  • 画面の幅: width
  • 現在時刻: currentTime
  • 画面の右端の位置に対応する時間: endTime
  • 動画の長さ: duration

図の上側の青色の直線が時間軸で、下側のオレンジ色の直線が画面座標軸になります。グラフにすると次にような直線(一次関数)です。

この直線の式の定数項である  startTime を変化させると、時間軸上で切り取る範囲が変わります。その結果、画面上ではシークバーの表示位置が左右に移動し、まるで時間が動いているように見えるのです。

また、直線の傾きを表す  timePerPixの値を変えると、画面上で切り出される時間の範囲が変わります。これにより、シークバーのスケールが拡大・縮小されたような見た目になります。

ちなみに今回のデザインでは、画面の中央の位置が現在時刻 ( currentTime) をあらわしているので、 startTime は現在時刻から画面幅の半分 ( width/2)の時間を差し引いた値となり、次のように表せます。

 startTime = curretTime -  timePerPix * width / 2

というわけで、動画の切り出し位置を制御するための変数として現在時刻 ( currentTime) 、スケールを制御するための変数として1ピクセル当たりの時間( timePerPix) 、この 2 つを保持しておけば十分です。

// 現在の時刻
currentTimeMs = 0;

// 1ピクセルあたりの時間(ミリ秒)
timeMsPerPix = 2400;

元の変換式に  startTime を代入して消すと、現在時刻 ( currentTime) と1 ピクセル当たり時間幅 ( timePerPix) を用いた式に整理できます。

 t = timePerPix * x + curretTime -  timePerPix * width / 2

ここからさらに変形して  x イコールの形にすると、時間軸上の座標  t を画面上の座標  x に変換できます。

 x = (t - curretTime -  timePerPix * width / 2) / timePerPix

これらが動画の時間軸と画面の座標軸を行き来するための変換式になります。描画位置の計算のために頻繁に利用するので関数化しておきます。

  pix2time(pix) {
    const startTimeMs = this.currentTimeMs - this.canvas.width / 2 * this.timeMsPerPix;
    return pix * this.timeMsPerPix + startTimeMs;
  }

  time2pix(timeMs) {
    const startTimeMs = this.currentTimeMs - this.canvas.width / 2 * this.timeMsPerPix;
    return (timeMs - startTimeMs) / this.timeMsPerPix
  }

ステップ3 描画

さて、準備が整ったので、いよいよ描画方法について解説していきます。

描画に用いる Canvas API の利用方法はここで詳しく解説はしませんが、長方形とテキストの描画しか用いないので、下記の2点だけ雑に理解していればオッケイです。

描画処理は draw() 関数の中にまとめられていますが、少し長いので段階的に見ていきましょう。

背景の描画

まずは背景の描画から。ここでは、キャンバス全体を指定した色で塗りつぶしています。つまり、この時点では背景が真っ白になっているだけです。

ctx.fillStyle = SEEKBAR_BACKGROUND_COLOR;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

シークバー本体の描画

次にシークバー本体を描画します。バーの背景は画面の横幅いっぱいに長方形を描くだけです。その後に動画コンテンツが存在する部分を緑色で塗り分けます。

// シークバーの背景描画
ctx.fillStyle = SEEKBAR_BLANK_COLOR;
ctx.fillRect(0, SEEKBAR_POS_Y, this.canvas.width, SEEKBAR_HEIGHT);

// シークバーの動画コンテンツがある部分を描画
const dataStartPosX = this.time2pix(0);
const dataEndPosX = this.time2pix(this.durationMs);
ctx.fillStyle = SEEKBAR_DATA_COLOR;
ctx.fillRect(dataStartPosX, SEEKBAR_POS_Y, dataEndPosX - dataStartPosX, SEEKBAR_HEIGHT);

シークバーの目盛りの描画

続いて少し長めですが、シークバーの目盛りを描画しているコードをご紹介します。

ctx.font = SCALE_TIME_TEXT_SIZE + 'px sans-serif';
const { scaleInterval, textInterval } = this.adjustScale(this.timeMsPerPix)
const startTime = this.pix2time(0)
const endTime = this.pix2time(this.canvas.width)
const firstScaleTime = Math.floor(startTime / scaleInterval) * scaleInterval
let i = 0
while (1) {
  const scaleTime = firstScaleTime + i * scaleInterval
  if (scaleTime > endTime) {
    break;
  }

  if (scaleTime > this.durationMs) {
    break;
  }

  if (scaleTime < 0) {
    i++
    continue
  }

  // 目盛りを描画
  const scalePosX = this.time2pix(scaleTime)
  const scalePosStartY = SEEKBAR_POS_Y + SEEKBAR_HEIGHT
  const scalePosEndY = scaleTime % textInterval === 0 ? 
    scalePosStartY + SEEKBAR_SCALE_HEIGHT * 2 : 
    scalePosStartY + SEEKBAR_SCALE_HEIGHT
  ctx.fillStyle = SEEKBAR_SCALE_COLOR;
  ctx.fillRect(scalePosX, scalePosStartY, 1, scalePosEndY - scalePosStartY)

  // 時間をテキストで描画
  if (scaleTime % textInterval === 0) {
    ctx.textAlign = 'center'
    ctx.textBaseline = 'top'
    ctx.fillText(this.time2str(scaleTime), scalePosX, scalePosEndY + 5)
  }

  i++
}

最初に描画する目盛りの時間は以下のように計算しています。

  const firstScaleTime = Math.floor(startTime / scaleInterval) * scaleInterval

ここで使っている scaleInterval は目盛りの間隔を表す変数で、1ピクセルあたりの時間幅に応じて調整しています。この位置は、下図のように画面の左端ギリギリの外にある目盛りの時間を指しています。

最初の目盛りの位置が決まれば、あとは画面右端まで等間隔で線を描画していくだけです。

let i = 0
while (1) {
  const scaleTime = firstScaleTime + i * scaleInterval
    
  ・
  ・ ループを抜ける判定
  ・
    
  const scalePosX = this.time2pix(scaleTime)
      
  ・
  ・ scalePosX の位置に目盛りを描画
  ・
    
  ++i 
}

現在時刻の描画

現在時刻を示す赤い線とテキストを描画して完成です。

// 中心の線を描画
ctx.fillStyle = SEEKBAR_CENTER_LINE_COLOR;
ctx.fillRect(this.canvas.width / 2, 0, 1, this.canvas.height);    

// 現在時刻の文字背景を塗りつぶす
const textBgWidth = 100
const textBgHeight = CURRENT_TIME_TEXT_SIZE + 5
const textBgPosX = (this.canvas.width - textBgWidth) / 2
const textBgPosY = (SEEKBAR_POS_Y - textBgHeight) / 2
ctx.fillStyle = SEEKBAR_BACKGROUND_COLOR;
ctx.fillRect(textBgPosX, textBgPosY, textBgWidth, textBgHeight);

// 現在時刻のテキストを描画
ctx.font = CURRENT_TIME_TEXT_SIZE + 'px sans-serif';
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'    
ctx.fillStyle = SEEKBAR_TIME_TEXT_COLOR;
const textPosX = this.canvas.width / 2
const textPosY = SEEKBAR_POS_Y / 2
ctx.fillText(this.time2str(this.currentTimeMs), textPosX, textPosY);

ステップ4 再生位置の移動 & スケール変換

最後に、マウスイベントに合わせて再生位置の移動やスケール変更を行っているコードを見ていきましょう。

まず、再生位置の移動はマウスのドラッグ操作に連動して currentTime を更新するだけです。

this.canvas.addEventListener('mousedown', (e) => {
  this.isMouseDown = true
  this.mousePosX = e.offsetX
})

this.canvas.addEventListener('mouseup', () => {
  this.isMouseDown = false
})

this.canvas.addEventListener('mouseleave', () => {
  this.isMouseDown = false
})

this.canvas.addEventListener('mousemove', (e) => {
  if (this.isMouseDown) {
    const prevMousePosX = this.mousePosX
    this.mousePosX = e.offsetX
    const diffX = prevMousePosX - this.mousePosX
    const newCurrentTime = this.currentTimeMs + diffX * this.timeMsPerPix
    this.currentTimeMs = Math.max(0, Math.min(this.durationMs, newCurrentTime))
    this.draw()
  }
})

次に、スケールの変更もシンプルで、マウスホイールの操作に応じて timeMsPerPix を調整するだけです。

this.canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  let newTimeMsPerPix = this.timeMsPerPix
  if (e.deltaY > 0) {
    newTimeMsPerPix *= 1.1
  }else{
    newTimeMsPerPix *= 0.9
  }
  this.timeMsPerPix = Math.max(MIN_TIME_MS_PER_PIX, newTimeMsPerPix)
  this.draw()
})

これで、マウス操作による直感的なシークとスケール調整が実現できます。タッチ操作もサポートしていますが、実装方法は過去に執筆した 記事 でも解説しておりますので、こちらを参考にしていただければと思います。

まとめ

長くなりましたが、以上が処理の全容になります。

今回解説した座標変換の考えを利用すれば、アイデア次第で色んなものが作れそうな気がしませんか?ちなみに座標軸を2次元に拡張すれば、GoogleMap のようなマップアプリも作れます。みなさまの何かのお役に立てれば幸いです。

最後までお付き合いいただき、ありがとうございました。

© Safie Inc.