Chienomi

LWMPに画像ビューワとブックリーダーを追加 - localwebmediaplayer

開発::util

  • TOP
  • Articles
  • 開発
  • LWMPに画像ビューワとブックリーダーを追加 - localwebmediaplayer

ローカルネットワーク内のPC上のメディアファイルを別のデバイスで楽しめるLWMPだが、今回、画像関連機能を追加した。

シンプルにかなり大変だったのだが、使い勝手の良い機能になった。

機能紹介

画像ビューワ

画像をクリックすることで画像ビューワが起動するようになった。 現時点で対応している拡張子は.jpg, .jpeg, .gif, .bmp, .png, .webp, .avif, .heif.

画像ビューワは左右3分割のクリックターゲットを持っている。

右で次の画像、左で前の画像、中央で閉じる。

画像ビューワの操作エリア

ブックリーダー

ブックリーダーはBook Readerボタンを押すことで起動する。 今回もFeather Iconにお世話になっている。

ブックリーダーは上1/3と下2/3に分かれており、下2/3はさらに左右5分割になっている。

ブックリーダーの操作エリア

上1/3をクリックするとブックリーダーのオプションが表示される。 現在のところ

  • 見開き表示の切り替え
  • ページ左右反転の切り替え
  • ページジャンプ

が用意されている。

下は左から「左に2ページ進む」「左に1ページ進む」「ブックリーダーを閉じる」「右に1ページ進む」「右に2ページ進む」となっている。 左右で進む/戻るの挙動に関しては左右反転の影響を(見開き表示でなくても)受ける。 見開きでない場合、「2ページ進む」挙動は1ページ進むになる。

見開き表示でも画像がランドスケープである場合、単一画像表示になる。 この状態のとき、2ページ進む挙動は1ページに抑制される。

ちなみに、私にとってこのソフトウェアの目的が「スマホで観る」である関係上、ブックリーダーはだいぶ「おまけ機能」なのだけど、その割にはだいぶ作り込んだ。

技術紹介

画像ビューワ

Chienomiと同じような構造。

div > figure > imgという要素になっていて、

#ImgViewer {
  display: none;
  position: fixed;
  top: 0px;
  left: 0px;
  z-index: 15000;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  background-color: #fffffff0;
}

#ImgViewerFigure {
  margin: 0 auto;
  padding: 0;
}

#ImgViewerFigure img {
  height: 100%;
  width: 100%;
  max-height: 100vh;
  max-width: 100vw;
  object-fit: contain;
}

という感じになっている。

動作としては、画像のロードはimg要素のsrc属性を書き換えるだけである。 表示と非表示もImgViewerdisplayを変更するだけだ。

ブックリーダー

HTML構造

<div id="BookReaderBox">
  <div id="BookReaderOptionModalBox">
    <div id="BookReaderOptionModal">
      <form id="BookReaderOptionForm">
        <button id="BookReaderOptionSpread">Toggle Spread View</button>
        <button id="BookReaderOptionOrder">Toggle Page Order</button><br />
        <label>Page <input type="text" size="4" id="BookReaderPageNumber" /></label><button id="BookReaderPageJump">Go</button>
      </form>
    </div>
  </div>
  <canvas id="BookReaderCanvas"></canvas>
</div>

深くなっているが、基本的な構造は

div
  div
  canvas

であり、#BookReaderBox直下はOptionsのためのdivと、実際に表示するためのcanvasの2つ。

オプション部分に関してはオプション部品を配置している部分の操作で閉じてほしくはないし、その外側を押したら閉じてほしいため、ビューポート全体を覆うブロックがもう1枚ほしい。 このため、ブロックがネストすることになる。

フォームは正直いらなかった。 fieldsetとかのほうがよかったかも。

非表示になっているのは#BookReaderBox

基本的な動作

Book Readerボタンで#BookReaderBoxを表示する。(show_bookreader())

この表示の流れで#BookReaderBoxのビューポートに合わせたサイズ調整を行い、currentStateを更新してdraw_bookreader_page()を呼んで描画する。

draw_brookreader_page()currentStateを見て見開き表示のdraw_bookreader_page_spread()と単一表示のdraw_bookreader_page_single()に分岐するが、draw_bookreader_page_spread()は画像のアスペクト比を見てdraw_bookreader_page_single()に流れる場合がある。

表示する画像の配列とインデックスの関係は画像ビューワと同じcurrentState.imglistを使っている。

タッチコールバックとページ送り

const bookreader_touch_callback = function(e) {
  const br_box = document.getElementById("BookReaderBox")
  const rect = br_box.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  const zone_width = rect.width / 5
  const zone_height = rect.height / 3

  if (y < zone_height) {
    show_bookreader_options()
  } else {
    if (x < zone_width) {
      currentState.bookreader.rtl ? bookreader_next2() : bookreader_prev2()
    } else if (x < zone_width * 2) {
      currentState.bookreader.rtl ? bookreader_next1() : bookreader_prev1()
    } else if (x < zone_width * 3) {
      hide_bookreader()
    } else if (x < zone_width * 4) {
      currentState.bookreader.rtl ? bookreader_prev1() : bookreader_next1()
    } else {
      currentState.bookreader.rtl ? bookreader_prev2() : bookreader_next2()
    }
  }
}

重要なのがElement.getBoundingClientRect。 要素の位置や大きさに関する情報が得られるもので、今回の場合クリック座標との照合に使っている。

ページ進行方向が切り替えられることから、

  • クリックは左右で判定し、左右の関数が進むのか戻るのかを判定する
  • クリックが進むか戻るかを判定した上で関数を呼ぶ

の2通りの方法があるが、後者を採用した。

また、進むページ数に関しても

  • ゾーンに対応した処理を呼び、2ページ送り関数が1ページに留めるべきかを判定する
  • 進むのが1ページか2ページかを判定した上で関数を呼ぶ

の2通りがあるが

const bookreader_next2 = function() {
  const pages = (currentState.bookreader.spread && !currentState.bookreader.force_single) ? 2 : 1
  draw_bookreader_page(currentState.bookreader.page + pages)
}

このように、2ページ送り関数の中で判定するようにした。

分けたほうが分かりやすい派と一箇所にまとめるべき派が生まれる話だとは思う。

描画パート

const draw_bookreader_page_spread = function({pagenum, canvas, ctx, scale, rect, maxWidth, maxHeight, page}) {
  if (page > currentState.imglist.length - 2) { page = currentState.imglist.length - 2 }
  const img1 = new Image()
  const img2 = new Image()

  img1.src = "/media/" + currentState.imglist[page]
  img2.src = "/media/" + currentState.imglist[page + 1]

  let loaded = 0
  const onLoad = () => {
    loaded++
    if (loaded < 2) {return}

    const aspect1 = img1.width / img1.height
    const aspect2 = img2.width / img2.height

    if (aspect1 > 1 || aspect2 > 1) {
      currentState.bookreader.force_single = true
      draw_bookreader_page_single({pagenum, canvas, ctx, scale, rect, maxWidth, maxHeight, page})
      return
    }

    const targetHeight = maxHeight
    const drawWidth1 = targetHeight * aspect1
    const drawWidth2 = targetHeight * aspect2

    let fitScale = 1
    if (drawWidth1 > (maxWidth / 2) || drawWidth2 > (maxWidth / 2)) {
      fitScale = Math.min(((maxWidth / 2) / drawWidth1), ((maxWidth / 2) / drawWidth2))
    }

    const finalHeight = targetHeight * fitScale / scale
    const finalWidth1 = drawWidth1 * fitScale / scale
    const finalWidth2 = drawWidth2 * fitScale / scale
    
    const centerX = rect.width / 2
    const x1 = currentState.bookreader.rtl ? centerX : centerX - finalWidth1
    const y1 = (rect.height - finalHeight) / 2
    const y2 = y1
    const x2 = currentState.bookreader.rtl ? centerX - finalWidth2 : centerX

    ctx.drawImage(img1, x1, y1, finalWidth1, finalHeight)
    ctx.drawImage(img2, x2, y2, finalWidth2, finalHeight)

    currentState.bookreader.page = page
    pagenum.value = page + 1
  }

  img1.onload = onLoad
  img2.onload = onLoad
}

ページ送りのちらつきを減らすためcanvas要素を使っている。 HTMLCanvasElementの画像描画の使い方は基本的には

const ctx = canvas.getContext("2d") // -> CanvasRenderingContext2D
ctx.drawImage(image, dx, dy, dWidth, dHeight)

である。 画像の原寸のまま出すのであればdWidth, dHeightも省略できる。 逆に言えば、dWidth, dHeightを指定することで拡大縮小ができる。

実用重要になるのが、「キャンバスの大きさ」を必要とすること。 これはHTMLの要素としての大きさ、つまりCSSにおけるwidth, heightとは別で、canvas要素のwidth, heightの値になる。 CSSだけで拡大すると小さなキャンバスに描いた状態のものを引き伸ばすことになり、ガビガビになる。

普通に属性値として書いても良いのだが、

const draw_bookreader_page = function(page=0) {
  const pagenum = document.getElementById("BookReaderPageNumber")
  const canvas = document.getElementById("BookReaderCanvas")
  const ctx = canvas.getContext("2d")
  const scale = window.devicePixelRatio

  const desired_height = window.innerHeight
  canvas.style.height = desired_height + "px"
  const rect = canvas.getBoundingClientRect()
  const maxWidth = rect.width * scale
  const maxHeight = rect.height * scale
  canvas.width = maxWidth
  canvas.height = maxHeight
  ctx.scale(scale, scale)

  if (page < 0) { page = 0 }

  currentState.force_single = false
  if (currentState.bookreader.spread) {
    draw_bookreader_page_spread({pagenum, canvas, ctx, scale, rect, maxWidth, maxHeight, page})
  } else {
    draw_bookreader_page_single({pagenum, canvas, ctx, scale, rect, maxWidth, maxHeight, page})
  }
}

という形で動的に指定することで最大のサイズを得ている。 スケーリングにも対応しておかないと、高密度ディスプレイで画像を美しく出せない。

適切なサイズのキャンバスができたら、そこに画像を描く。 見開きベストフィットモードを求める場合は、縦か横どちらかで最大のサイズにしたあと、はみだす場合は調整が必要。 見開き表示はポートレート画像であることが確定しているため、高さのほうで合わせて、それぞれの画像がビュー幅の半分を超えるなら調整する。

あとはそれぞれのサイズと座標を計算して描画する。

進行方向に関しては2つの画像を右に置くか左に置くかを判定するのに使っている。

stopPropagation()

毎回説明したほうが良いことといえば

  • event.stopPropagation() - イベントの伝播を止める。このイベントリスナーを親要素に伝えない
  • event.preventDefault() - イベントの規定動作を行わない。フォーム内のボタンの場合はだいたい必須

addEventListener()で追加するDOM Level 2イベントは複数設定することができ、設定した順に適用される。 stopPropagation()はこの複数設定されたイベントを止めることはしない。

このためラッパーウィンドウやモーダルベースなど、それ以上イベントを伝播させることが想定されていないものにあらかじめ仕込んでおくと事故がなくて良い。

document.getElementById("BookReaderBox").addEventListener("click", e => { e.stopPropagation() })

リサイズ時

CSS任せになっている画像ビューワは特別なことはいらないが、ブックリーダーはサイズの調整をしたいのと、単にそれだけではキャンバスがリセットされて空になってしまう。

色々試したがキャンバスがリセットされたままになる問題が解消できなかったため、リサイズ時は一度ブックリーダーそのものを消し、少し間を置いて再描画するようにした。

  if (currentState.bookreader.shown) {
    hide_bookreader()

    setTimeout(() => {
      show_bookreader()
    }, 300)
  }

画像拡張子

LWMPが「画像」として識別するのは以下のとおり

フォーマット 拡張子
JPEG .jpg, .jpeg, .jfif, .pjpeg, .pjp
PNG .png
WebP .webp
AVIF .avif
Bitmap .bmp
GIF .gif

もちろん、HEIF, JPEG XLあるいはJPEG 2000, JPEG XRなど他にも画像フォーマットはたくさん存在しているが、ウェブブラウザの対応はかなり消極的かつ保守的。 EdgeはAVIFもなかなか採用しなかったし、FirefoxはWebPをなかなか採用しなかった。

ちなみに、.jfifは現在はJPEGの規格に取り込まれているため拡張子だけの話。

.pjpegおよび.pjpはProgressive JPEG用の拡張子。 ただし、IEはJPEGファイルアップロード時にimage/pjpegとしてアップロードするというクセがあった。 実際にProgressive JPEGであるかどうかを問わずである。 まぁとにかく、幅広いブラウザで開けるらしいので、需要があるかどうかはともかくサポートするようにしている。

忘れてはいけないのが、LWMPはメディアクライアントはあくまでウェブブラウザであり、ウェブブラウザの機能にゆだねているので、ファイルが扱えるかどうかはブラウザ次第なのだ。

サムネイル

画像に関してはサムネイルを返すこともできるが、ファイルブラウザが重くなるのは私は許容できないので意図的に入れていない。

キーボード操作

全体でキャプチャしたくないので、#BookReaderBoxkeydownイベントを捕まえたいのだが、divなのでセットしづらい。

そこでまず、#BookReaderBoxtabindex="0"を入れておく。

<div id="BookReaderBox" tabindex="0">

その上で、開いたときに強制フォーカス

br_box.focus()

これでタブキーで強制的にフォーカスを外さない限りはキーボード操作の対象にできる。

キーボード操作は

  • 左右はそれぞれ左右へのページめくり
  • 下は進む固定、上は戻る固定
  • PgDnは1ページ進む、PgUpは1ページ戻る

というMComix風の挙動を採用している。

// Setup keyboard event
document.getElementById("BookReaderBox").addEventListener("keydown", e => {
  if (e.code === "ArrowDown") {
    bookreader_next2()
  } else if (e.code === "ArrowUp") {
    bookreader_prev2()
  } else if (e.code === "ArrowLeft") {
    currentState.bookreader.rtl ? bookreader_next2() : bookreader_prev2()
  } else if (e.code === "ArrowRight") {
    currentState.bookreader.rtl ? bookreader_prev2() : bookreader_next2()
  } else if (e.code === "PageDown") {
    bookreader_next1()
  } else if (e.code === "PageUp") {
    bookreader_prev1()
  }
})

その他の機能

「戻る」ナビゲーション

はるらぼで飼い慣らせなかったVanilla JavaScript SPAのヒストリナビゲーションへのリベンジである。

基本的な概念

Vanilla JavaScript SPAにおいてトップクラスに困難なのが「戻る」「進む」の制御である。

これはwindow.historyに内包されている。

ヒストリイベントはstateと呼ばれている。 window.history.pushState()popstateイベントハンドラでアクセスすることになるが、その名前に反してpopstateはヒストリイベントを取り出したときだけでなく、「進む」操作によってヒストリイベントが追加されるときもpopstateイベントが発生する。

  • window.history.pushState()によって明示的にステートを追加できる
  • ヒストリの操作によってstateスタックが増減するとき、popstateイベントが発生する
  • popstateイベントによって渡されるevent.stateは、増減が行われたあとにstateスタックの最上位となるものである

ヒストリ操作によって、というのは、ブラウザの「進む」「戻る」あるいはそれに相当するhistory.back(), history.forward()history.go()を指している。 言い換えると、pushState()でstateスタックに積むときだけが例外的にpopstateイベントが発生させない。

もしそれ以上進むことができない突き当りのページがあるとすると、そこからは「戻る」しかなく、するとpopstateイベントが発生する。 しかし、history.back()で得られるstateは突き当りのページからひとつ戻ったものになるため、突き当りのページでpushState()したものは得る機会がない。

では無視できるかというと、ここで「進む」した場合は再度突き当りのページでpushState()したものがスタックに積まれると同時にpopstateイベントが発生する。 それがbackによって発生したのか、forwardによって発生したのかは、イベントからはわからない。

機能させるための規則

pushするstateの内容

stateの内容は任意のオブジェクトである。 自身が明示的にpushした以外のstateが入り込むことがあるため、区別できるようなプロパティをもたせる。

history.pushState({
  lwmp: true
})

popstateでは自分が入れた以外のstateが来た場合は単に無視するのが基本。

e => {
  const state = e.state
  if (!e.state.lwmp) { return }
}
backで閉じる

状態遷移したあとこれを閉じるという操作をする場合、stateスタックと画面の状態を揃えるためhistory.back()で閉じるようにする。

a, b, cと開いていって一括で閉じるような挙動である場合は、history.go()を使うか、aだけにpushState()を仕込む。

前述のように突き当りのページからbackしたときは突き当りのページだったかどうかをイベントから判別できない。 なので、今開いているビューが何かということは別途変数にとっておく必要がある。

// Back navigation
window.addEventListener("popstate", e => {
  const state = e.state
  if (!state.lwmp) { return }
  if (currentState.currentView) {
    switch (currentState.currentView) {
      case "player":
        switch_browser()
        break
      case "imgview":
        hide_imgview()
        break
      case "book":
        hide_bookreader()
        break
    }
    currentState.currentView = null
  } else {
    switch (state.type) {
      case "player":
        switch_player()
        break
      case "imgview":
        show_imgview(state.path)
        break
      case "book":
        show_bookreader()
        break
      default:
        load_browser(state.path)
    }
  }
})
stateラップ関数とコール方法

一番ダサくなるところ。 状態遷移関数をpushState()する関数でラップする

function open_modal() {
  //...
}

function open_modal_with_state() {
  history.pushState({
    view: "modal"
  })
  open_modal()
}

通常はopen_modal_with_state()を呼ぶが、popstateの中からはopen_modal()を呼ぶようにする。 でないと、「進む」操作によってpopstateが発生したときに無限にstateを積み続けてしまう。

メディアキー

メディアキーの操作はnavigation.mediaSession.setActionHandlerによってハンドラ登録ができる。

video要素とaudio要素はplayとpauseにはそれ自体で対応しているので、対応すべきはnexttrackprevioustrackだけになる。

// Media key
navigator.mediaSession?.setActionHandler('nexttrack', e => {
  playlist_next()
})

navigator.mediaSession?.setActionHandler('previoustrack', e => {
  playlist_prev()
})

メディアメタデータ

OSと連動するメタデータの再生。 デスクトップのメディアウィジェットやスマートフォンのロック画面と連動させることができる。 audiovideoを再生するだけで再生状態は反映されるが、これではタイトルやアーティストは表示できない。

これを表示させる方法がnavigation.mediaSession.metadataである。 title, artist, album, artworkをセットすることができる。

ただ、Vivaldiだとここでセットした情報がOSに共有されなかった。

メタデータ機能はプレイリスト表示などを含めて表示させるプランはあるが、まだ実施していないためかなり限定的である。

ミニマル……?

プレーン、バニラ、ミニマルを掲げたlocalwebmediaplayerだけれど、main.js

❯ wc -lm src/main.js
  715 20961 src/main.js

と全く小さくない状態になってしまった。 ひょっとしたら何かしらのフレームワークを導入したほうが小さくなった可能性もあるが、それでも18kBなのでフレームワークのサイズよりは小さいだろう。

画像ビューワ実装前と比べると

❯ git diff --stat c79fb09
 .gitignore                                   |   1 +
 README.md                                    |  65 ++-
 doc/img/bookreader.webp                      | Bin 0 -> 1050 bytes
 filebrowser.webp => doc/img/filebrowser.webp | Bin
 doc/img/imageviewer.webp                     | Bin 0 -> 856 bytes
 videoplayer.webp => doc/img/videoplayer.webp | Bin
 src/httpclient.mjs                           |   7 +-
 src/img/LICENSE                              |  21 +
 src/img/book-open.svg                        |   1 +
 src/img/image.svg                            |   1 +
 src/index.html                               |  19 +-
 src/main.js                                  | 419 ++++++++++++++++++-
 src/mediaplay.rb                             |  10 +-
 src/theme.css                                |  73 ++++
 14 files changed, 592 insertions(+), 25 deletions(-)

と倍以上になっているのがわかる。

それでも実際は結構読みやすいコードでもあるはずだ。 さすがに関数が多いので把握は簡単ではないが。

だいたい完成形

LWMPを私が使っていて思っていた

  • 画像が見たい (主に電子書籍が読みたい)
  • バックナビゲーションで戻れないのが結構不便

が解消できたので、個人的にはだいたい完成形かなと思っている。 あとはメタデータを返す機能を考えているけれど、欲しい機能はそれくらいだ。

Linux系のデスクトップをメインで運用していて、かつスマホでPCのメディアファイルにアクセスしたい人、という結構限られる話になってしまうが、使い心地は大変良いのでぜひみんな使って欲しい。

ちなみに、Windowsで動作するかどうかはわからないし、LighttpdがWindows向けに公式に提供されていないのであんまりWindows PCユーザーに向けて考えてはいないものではあるが、Lighttpd以外のウェブサーバーと組み合わせること自体は可能だし、なによりWSLを使って動作させることができるので、がんばればWindowsでも使える。

最近はArchlinuxも使えるようになったので、お手軽だろう。

wsl --install archlinux

Archlinuxを起動したら一旦アップデートして

sudo pacman -Syu

必要なものをインストールする。

sudo pacman -S ruby lighttpd

そしたらこんな感じで準備する

$ mkdir -p ~/.local/opt
$ cd ~/.local/opt
$ git clone https://github.com/reasonset/localwebmediaplayer.git
$ cd
$ mkdir -p ~/.config/reasonset/lwmp

コレクションはこんな感じで用意する

$ mkdir -p ~/lwmp/foo
$ cd ~/lwmp/foo
$ ln -s /mnt/c/User/foo/Music

WSLでは/mnt/cC:ドライブがマウントされていることを知っておくと幸せになれるかもしれない。

lwmp以下には起動スクリプトとLighttpdの設定ファイルがあれば良い。

server.username         = "foo"
server.groupname        = "foo"
server.modules  += ( "mod_cgi", "mod_alias", "mod_setenv", "mod_access" )
setenv.add-environment = ( "MEDIA_ROOT" => env.MEDIA_ROOT )
server.document-root    = env.REPOS_ROOT
dir-listing.activate    = "disable"
dir-listing.encoding    = "utf-8"
index-file.names        = ( "index.html" )
server.bind     = "0.0.0.0"
server.port     = 8000

cgi.assign      = (
  ".rb" => "/usr/bin/ruby"
)

alias.url = ("/media/" => env.MEDIA_ROOT )
#!/bin/bash

export MEDIA_ROOT=$HOME/lwmp/foo/
export REPOS_ROOT=$HOME/.local/opt/localwebmediaplayer/src/
lighttpd -D -f ~/.config/reasonset/lwmp/foo-lighttpd.conf

あとは実行するだけ。

bash ~/.config/reasonset/lwmp/lwmp-foo.bash

ひょっとしたらそろそろマルチプラットフォームで動かしやすいようにWebrickで配信する機能とか用意してもいいのかもしれない。 需要があればだが。