プレゼンテーションカラオケサービスをlit-htmlで作ってみた

大阪オフィスの岡田です。 2020年も1ヶ月半が過ぎましたが、2019年の弊社大阪オフィスの忘年会の出し物として プレゼンテーションカラオケ をやるのにあたり、サービスをサクッと作りました。
技術を試してみるという意味もあったので、サクッとブログに残しておきます。

経緯

私の所属している大阪オフィスでは忘年会で プレゼンテーションカラオケ(パワ-ポイントカラオケ) をやることになったのですが、その決定現場にたまたま居合わせたため、サービスを作ってみようと手を上げました。
最近はAPIサーバばかり作っていたので、久々にフロントエンドも触っておこうという下心もあり、一石二鳥です。

プレゼンテーションカラオケとは?

制限時間5分でランダムに表示される画像に対して即興でプレゼンテーションします。 詳細はこちらを御覧ください。 (参照:日本プレゼンカラオケ協会)

作ったもの(2020/02/14追記)

デモです。

技術と選定理由

要件は 画像をランダムに表示させる のみなので、フロントエンドだけで完結出来ると考えました。
後になって気づいたことですが、この時点では忘年会会場の電波状態がわからなかったので、この判断は正しかったですね。

個人的にもPolymerを触ったことがあり、今回は lit-html に挑戦することにしました。

  • node
  • yarn
  • lit-html

lit-htmlとは?

lit-htmlは、JavaScriptでHTMLテンプレートを書くことができ(HTML in JS)、DOMを作成・更新するのに必要なデータとテンプレートを効率的に描画・再描画します (参照:https://lit-html.polymer-jp.org/)

WEBComponent を利用したHTMLのレンダリングを行うライブラリです。
JSXのような感じですが、VDOMを利用しておらず、ネイティブクローニングをによるDOMの更新を行うのが特徴です。

提供される関数は html render の2つで、html で要素を作り、renderで描画します。

ソースコード

全体は github に公開しています。 メインのアプリケーション部分はこんな感じ。

import { html, render } from 'lit-html'
import { images } from './images.js'

const MAX_SLIDES = 4

// 配列をシャッフルしてMAX_SLIDES枚返す
const shuffleImages = imgs => {
  const shuffledImgs = imgs
    .map(a => [Math.random(), a])
    .sort((a, b) => a[0] - b[0])
    .map((a, i) => {
        return { path: `./images/${a[1]}`, pageNo: i + 1 }
        })

  return shuffledImgs.slice(0, MAX_SLIDES)
}

// 状態を更新する
const createStore = () => {
  let state = {}

  return newState => {
    if (!state.slides || newState) {
      if (!state.slides || newState.slideIndex === undefined) {
        const slides = [
        { path: './resources/PPK_first.png', first: true },
          ...shuffleImages(images),
          { path: './resources/PPK_end.png', last: true },
        ]
          state = { ...state, ...newState, slides }
      } else {
        state = { ...state, ...newState }
      }
      renderApp()
    }
    return state
  }
}

const store = createStore()

  // スライドを開始
const startSlide = () => {
  store({ slideIndex: 0 })
}

// 次のスライド
const nextSlide = () => {
  const { slideIndex, slides } = store()

    const slide = slides[slideIndex]
    if (slide && slide.last === undefined) {
      store({ slideIndex: slideIndex + 1 })
    } else {
      console.warn(`Not have next slide. index = ${slideIndex}`)
    }
}

// 前のスライド
const prevSlide = () => {
  const { slideIndex } = store()

    if (slideIndex > 0) {
      store({ slideIndex: slideIndex - 1 })
    } else {
      console.warn(`Not have prev slide. index = ${slideIndex}`)
    }
}

// タイトルに戻る
const backToTitle = () => {
  store({ slideIndex: undefined })
}

// スライド用のStyle
const slideStyle = path => {
  return `
    position: absolute; 
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: black;
    background-image: url("${path}"); 
    background-size: contain;
    background-position: center;
    background-repeat: no-repeat;
  `
}

// スライドのページ(html)
const slidePage = () => {
  const { slideIndex, slides } = store()

    console.debug('image:', slides[slideIndex])

    const path = slides[slideIndex].path
    const pageNo = slides[slideIndex].pageNo

    const pageInfo = html`
    <div
    style="color: white; text-align: right; position: relative; z-index: 100;"
    >
    ${pageNo}/${MAX_SLIDES}
  </div>
    `
    return html`
    ${pageNo && pageInfo}
  <div
    @click=${slides[slideIndex].last ? backToTitle : nextSlide}
  style=${slideStyle(path)}
  />
    `
}

const page = () => {
  const { slideIndex, slides } = store()

    if (slideIndex === undefined) {
      return html`
        <div
        @click=${startSlide}
      style=${slideStyle('./resources/PPK_titleback.png')}
      />
        `
    } else {
      return html`
        ${slidePage()}
      `
    }
}

// pageを描画する
function renderApp() {
  return render(page(), document.body)
}

// キー操作系 右・下・Enterで次のスライド/左・上で前のスライド
document.addEventListener('keydown', e => {
    const key = e.key
    switch (e.key) {
    case 'ArrowRight':
    case 'ArrowDown':
    case 'Enter':
    nextSlide()
    break
    case 'ArrowLeft':
    case 'ArrowUp':
    prevSlide()
    break
  }
})

renderApp()

コマンド

$ yarn create:images // /resources 内にある画像ファイル名の配列を作る(サーバ起動前)
$ polymer serve` //サーバ起動
$ polymer build` //ビルド

ハマりどころ

状態をどこで持つか?

React等では標準搭載されている機能かと思いますが、lit-htmlにはありません。より関数的に作れるのでは個人的には好みなのですが、プレゼンテーションの状態によって表示するページを出し分けたかったので、状態を管理する必要が出てきました。
こちらの記事 を参考にさせていただき、自前で実装しています。

画像の情報をどうやってもたせるか

これは lit-html は関係ない話ですが、フロントエンドのみで画像をランダム表示させるのに、画像情報をどうやってもたせるかを悩んでいました。色々悩んだ末、画像名の配列を出力する npm script を用意することにしました。もっといいやり方があったら教えて下さい。

まとめ

機能がシンプルすぎるため、lit-htmlだけでサービスを作ることは難しい印象でした。
依存ライブラリも少ないため、最終的に作られるものの容量を意識しなければ行けない場面(lambda等) では威力を発揮するかもしれません。

肝心のプレゼンテーションカラオケは大盛況で、忘年会だけで収まらず、納会でも実施されるほどでした。