序
ローカルネットワーク内の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属性を書き換えるだけである。
表示と非表示もImgViewerのdisplayを変更するだけだ。
ブックリーダー
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はメディアクライアントはあくまでウェブブラウザであり、ウェブブラウザの機能にゆだねているので、ファイルが扱えるかどうかはブラウザ次第なのだ。
サムネイル
画像に関してはサムネイルを返すこともできるが、ファイルブラウザが重くなるのは私は許容できないので意図的に入れていない。
キーボード操作
全体でキャプチャしたくないので、#BookReaderBoxでkeydownイベントを捕まえたいのだが、divなのでセットしづらい。
そこでまず、#BookReaderBoxにtabindex="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にはそれ自体で対応しているので、対応すべきはnexttrackとprevioustrackだけになる。
// Media key
navigator.mediaSession?.setActionHandler('nexttrack', e => {
playlist_next()
})
navigator.mediaSession?.setActionHandler('previoustrack', e => {
playlist_prev()
})メディアメタデータ
OSと連動するメタデータの再生。
デスクトップのメディアウィジェットやスマートフォンのロック画面と連動させることができる。
audioやvideoを再生するだけで再生状態は反映されるが、これではタイトルやアーティストは表示できない。
これを表示させる方法が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 archlinuxArchlinuxを起動したら一旦アップデートして
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/cにC:ドライブがマウントされていることを知っておくと幸せになれるかもしれない。
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で配信する機能とか用意してもいいのかもしれない。 需要があればだが。