Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

VueでWebストーリーっぽいコンポーネントを作ってみた

この記事はヤプリ #1 Advent Calendar 2022 の19日目の投稿になります。

フロントエンドエンジニアのこん(@k0n_karin)です!今回は、Vueを使ってWebストーリーっぽいコンポーネントを実装してみましたので、その簡単な解説記事です。

Webストーリーとは

ウェブストーリーは、動画、音声、画像、アニメーション、テキストを融合して 動的な消費体験を創造する、一般的な「ストーリー」形式のウェブ バージョンです。

developers.google.com

もしかしたらInstagramのストーリーズのほうがご存じの方が多いかもしれませんね。

about.instagram.com

作った経緯

ヤプリではアプリの一部でWebViewを使うシーンがあります。WebViewのメリットは、なんと言ってもネイティブアプリ内でWebの技術が使えることです。iOS/Androidそれぞれの開発が不要になるので、機能を小さく開発しスピーディに仮説検証できます。
そのためWebViewを使って、フロントエンドの力でアプリ体験の向上に一助できないか検討していました。

ちょうど先日メディアファインダーの動画対応がリリースされたこともあり、動画を使った新しい取り組みができないか、ということで白羽の矢が立ったのがWebストーリーでした。

news.yappli.co.jp

WebストーリーはGoogleのAMP frameworkで利用できます。Nuxt.js 2.xだとcommunityがサポートしているのですが、Vue単体でのAMPの扱いがわからず、OSSもいい感じのものが見当たらなかったので、なら勉強も兼ねてイチから作ってみよう!ということで作ってみました。

(ブログ執筆後、公開する前に改めてAMP frameworkを調べていたところ、そういえばAMP frameworkの実態はWeb Componentsだよなと思い、Vueの公式ドキュメントを見たらなんとご丁寧にWeb Componentsの使い方が書いてありました。これで無事にVueでもAMP frameworkを動かすことができてしまいました。嬉しいのやら悲しいのやら・・・。)

できたもの

Androidで見たWebストーリー

github.com

作る過程

準備

今回は以下の要件を満たせる最低限のコードを書いていきます。

  • WebストーリーのUIの一部再現
  • モバイル向けの表示

では早速Vueのプロジェクトをセットアップします。気づけばVue3も最初からViteになっていました。

npm init vue@latest web-stories
cd web-stories
npm i
npm run dev

コンポーネント設計

まずコンポーネント設計ですが、ストーリーを表示するページと1つのストーリーでコンポーネントを分ける方針で行きます。

  • StoryView.vue
  • StoryPlayer.vue

StoryView.vueで表示したい動画や画像のURLなどの情報を持たせ、それをStoryPlayer.vueに渡すような形でpropsを定義してあげます。

StoryView.vue

<script setup lang="ts">
import StoryPlayer from "@/components/StoryPlayer.vue";

const stories = [
  {
    id: 1,
    src: "cat.mp4",
    transitionUrl: "https://www.google.com/",
  },
  {
    id: 2,
    src: "cat.jpg",
    transitionUrl: "",
  },
];
</script>

<template>
  <main class="StoryView">
    <div v-for="(story, index) in stories" :key="story.id">
      <StoryPlayer
        :src="story.src"
        :transition-url="story.transitionUrl"
         :index="index"
        />
    </div>
  </main>
</template>

<style scoped>
.StoryView {
  width: 100vw;
  height: 100vh;
}
</style>

StoryPlayer.vue

<script setup lang="ts">
defineProps<{
  src: string;
  destination: string;
  index: number;
}>();
</script>

<template>
  <div></div>
</template>

ストーリーの表示

さて、ストーリーのUIに必要な要素はざっと以下の3つです。

  • 動画or画像
  • 画面上部のインジケーター
  • 画面下部のボタン(遷移先のリンクがある場合)

まずは動画と画像を出し分けて表示するところを実装します。これは拡張子から判別します。
また、<video>は自動再生したいのでmutedplaysinlineを付与します。これをしないと自動再生ができなかったり、フルスクリーン再生されてしまいます。
そして、ストーリーを端末いっぱいに表示したいので、画像も動画も、縦長も横長もobject-fit: containにします。

StoryPlayer.vue

<script setup lang="ts">
const props = defineProps<{
  src: string;
  transitionUrl: string;
  index: number;
}>();

const isImg = computed(() => {
  const extention = props.src.split('.').at(-1)
  const imgExtensions = ['jpg', 'jpeg', 'png', 'webp']
  return imgExtensions.includes(`${extention}`)
})

const isVideo = computed(() => {
// videoも同様に判別
})
</script>

<template>
  <img v-if="isImg" :src="src" class="StoryPlayer" />
  <video
    v-if="isVideo"
    :src="src"
    class="StoryPlayer"
    muted
    playsinline
  ></video>
</template>

<style scoped>
.StoryPlayer {
  position: absolute;
  height: 100%;
  width: 100%;
  object-fit: contain;
}
</style>

次にヘッダーのインジケーターです。これはストーリーの数に応じて設置します。
再生中or再生済みのストーリーのインジケーターを白塗りにするので、 --playedのクラスをmodifierとして付与します。再生済みの判断はv-forindexcurrentIndexを比較すればOKです。

StoryView.vue

<script setup lang="ts">
import { ref } from "vue";

import StoryPlayer from "@/components/StoryPlayer.vue";

const stories = [/* 略 */]
const currentIndex = ref(0);
</script>

<template>
  <main class="StoryView">
    <div class="StoryView__header">
      <div
        v-for="(_, index) in stories"
        class="StoryView__headerLine"
        :class="{
          'StoryView__headerLine--played': index <= currentIndex,
        }"
      ></div>
    </div>
    <!-- 略 -->
  </main>
</template>

<style scoped>
.StoryView__header {
  display: flex;
  gap: 5px;
  padding: 10px 5px;
  position: absolute;
  width: 100%;
  z-index: 10;
}
.StoryView__headerLine {
  flex-grow: 1;
  border-bottom: 2px solid #666;
}

.StoryView__headerLine--played {
  border-bottom-color: #fff;
}
</style>

最後にストーリーの上に浮かせるボタンですね。タップしたらtransitionUrlに遷移させます。また、transitionUrlがある場合のみ表示するのでこのようになります。

StoryPlayer.vue

<script setup lang="ts">
/* 略 */

const transition = (transitionUrl: string) => {
  location.href = transitionUrl;
};
</script>

<template>
  <!-- 略 -->
  <button
    v-if="transitionUrl !== ''"
    class="StoryPlayerLink"
    @click="transition(transitionUrl)"
  >
    詳しくはこちら
  </button>
</template>

<style scoped>
.StoryPlayerLink {
  position: absolute;
  bottom: 30px;
  left: 0;
  right: 0;
  width: 150px;
  height: 40px;
  background-color: #eee;
  color: #666;
  font-size: 16px;
  font-weight: bold;
  border-radius: 30px;
  margin: auto;
  padding: 0 12px;
}
</style>

ストーリーのコントロール

表示の準備ができたので、次は動画と画像をコントロールします。主にやることは3つです。

  • ストーリーの自動再生
  • タップでストーリーの送り戻し
  • ロングタップ時のコントロール

では自動再生から順番にやっていきます。

画像の場合、自動再生の概念はないので現時点では対応不要です。次項の送り戻しで再生の制御を実装します。
動画の場合、自動再生は<video>autoplayで可能ですが、全ての動画が自動再生されると困るので、再生するストーリーを決めます。

先程StoryViewで定義したcurrentIndexv-forindexを比較して、v-showで再生したいストーリーだけを表示します。 また、StoryPlayer側にも表示状態をisVisibleとしてpropsで渡します。

StoryPlayer.vue

<div
  v-for="(story, index) in stories"
  v-show="index === currentIndex"
  :key="story.id"
  class="StoryView__container"
>
  <StoryPlayer
    :src="story.src"
    :transition-url="story.transitionUrl"
    :index="index"
    :is-visible="index === currentIndex"
  />
</div>

isVisibleの状態を元に、StoryPlayer側で再生の制御をします。

今回は使ったことがなかったので、Vueのカスタムディレクティブを試してみます。<script setup>のカスタムディレクティブは、接頭辞にvをつける必要があります。

ja.vuejs.org

props.isVisblecomputedにしてバインドし、trueのときは動画を再生、falseのときは動画を停止し0秒に戻します。

StoryPlayer.vue

<video v-video-control="isVisible">
const isVisible = computed(() => {
  return props.isVisible
})
const vVideoControl: DirectiveHook<HTMLVideoElement, null, boolean> = (
  el,
  binding
) => {
  if (binding.value) {
    el.play();
  } else {
    el.pause()
    el.currentTime = 0
  }
};

では次にストーリーの送り戻しを実装していきます。
ストーリーは画面の左半分と右半分をタップすると、前後のストーリーを再生できます。左:右の割合がInstagramだと1:9、AMPのWebストーリーだと2:8くらいでした。今回は後者の2:8くらいで行こうと思います。
<button>を使ってストーリーをタップできるようにします。

StoryPlayer.vue

<button
  class="StoryPlayerPrev"
></button>
<button
  class="StoryPlayerNext"
></button>
.StoryPlayerPrev {
  position: absolute;
  left: 0;
  height: 100%;
  width: 20%;
}
.StoryPlayerNext {
  position: absolute;
  right: 0;
  height: 100%;
  width: 80%;
}

続いて、それぞれのボタンをタップ時に前後のストーリーを再生するようにしましょう。

ここでは、StoryPlayer内でtouchendイベントを購読し、emitを用いてStoryView側で制御します。タッチデバイス以外でも動作するようにmouseupも購読しておきましょう。 touchend.preventをしないとmouseupイベントも発火させてしまうので、お忘れなきよう。
https://www.w3.org/TR/touch-events/#list-of-touchevent-types

StoryPlayer.vue

const emit = defineEmits<{
  (e: "prev" | "next"): void;
}>();

const handlePressEnd = (key: "prev" | "next") => {
  emit(key)
}
<button
  class="StoryPlayerPrev"
  @touchend.prevent="handlePressEnd('prev')"
  @mouseup="handlePressEnd('prev')"
></button>
<button
  class="StoryPlayerNext"
  @touchend.prevent="handlePressEnd('next')"
  @mouseup="handlePressEnd('next')"
></button>

StoryView.vue

const nextStory = () => {
  if (currentIndex.value < stories.length - 1) {
    currentIndex.value++;
  } else {
    currentIndex.value = 0;
  }
};

const prevStory = () => {
  if (currentIndex.value !== 0) {
    currentIndex.value--;
  }
};
<StoryPlayer
  :src="story.src"
  :transition-url="story.transitionUrl"
  :index="index"
  :is-visible="index === currentIndex"
  @prev="prevStory()"
  @next="nextStory()"
/>

これで前後のストーリーを再生できるようになりました。

ところが、動画の再生が終わった後に次のストーリーが再生されなかったり、そもそも画像は再生時間がなかったりするので、それぞれ実装していきます。
動画は再生が終わったらemit("next")します。<video>は再生が終了するとendedイベントが発火されるので、それを購読すればOKですね。

StoryPlayer.vue

// script
const handleVideoEnded = () => {
  emit("next");
};

// template
<video @ended="handleVideoEnded"></video>

画像の場合は、再生時間を決めた上で、再生時間のタイマーが必要になります。 画像は5秒間表示するようにしてみます。画像の表示フラグのisVisiblewatch()して、setInterval()で100msごとにインクリメントしていきましょう。
また、カウンターもwatch()し、5秒を超えたらemit("next")して、カウンターとsetInterval()をリセットします。

StoryPlayer.vue

const INTERVAL_MS = 100;
const IMAGE_DISPLAY_MS = 5000;
const imageDisplayTimerId = ref<NodeJS.Timeout>();
const imageDisplayCounter = ref(0);

watch(
  isVisible,
  (newVal) => {
    if (isImg.value && newVal) {
      imageDisplayTimerId.value = setInterval(() => {
        imageDisplayCounter.value += INTERVAL_MS;
      }, INTERVAL_MS);
    }
  },
    // 初期描画時もwatchします
  {
    immediate: true,
  }
);

watch(imageDisplayCounter, (newVal) => {
  if (newVal >= IMAGE_DISPLAY_MS) {
    emit("next");

    imageDisplayCounter.value = 0;
    if (imageDisplayTimerId.value !== undefined) {
      clearInterval(imageDisplayTimerId.value);
      imageDisplayTimerId.value = undefined;
    }
  }
});

最後にロングタップの制御をします。ストーリーをロングタップすると、ストーリーが一時停止されます。これも動画と画像でそれぞれ処理が異なってきます。個人的に、この実装が一番大変でした。

タップ開始でストーリーを一時停止し、そこから2パターンに分岐します。

  1. タップ時間がしきい値以下なら、ストーリーを送り戻し
  2. タップ時間がしきい値以上なら、ストーリーを再開

では実装してみましょう。まずロングタップのタイマーから実装します。タイマーには3つの要素が必要になります。

  • タップ時間のカウンター
  • タップ中のフラグ
  • ロングタップを判定するしきい値

タップ開始のイベントはtouchstartです。タップ中のフラグをtrueにし、カウンターをsetInterval()でインクリメントします。
ここで一時停止するために、動画の場合はtemplate参照を使って.pause()します。
画像の場合はimageDisplayTimerIdのタイマー内で、タップ中のフラグがtrueのときはカウンターをインクリメントしないようにします。

StoryPlayer.vue

<video ref="videoRef"></video>
const videoRef = ref<HTMLVideoElement | null>(null);

const pressingTimerId = ref<NodeJS.Timeout>();
const pressingCounter = ref(0);
const isPressing = ref(false);
const PRESSING_THRESHOLD = 200;

const handlePressStart = () => {
  isPressing.value = true;

  if (isVideo.value) {
    videoRef.value?.pause();
  }

  pressingTimerId.value = setInterval(() => {
    pressingCounter.value += INTERVAL_MS;
  }, INTERVAL_MS);
};

watch(
  isVisible,
  (newVal) => {
    if (isImg.value && newVal) {
      imageDisplayTimerId.value = setInterval(() => {
        // タップ時はカウントしない
        if (isPressing.value) {
          return;
        }
        imageDisplayCounter.value += INTERVAL_MS;
      }, INTERVAL_MS);
    }
  },
  {
    immediate: true,
  }
);

タップ終了時の処理は、送り戻しを実装した際のhandlePressEnd()を拡張していきます。

まずタップ中のフラグをfalseにし、タイマーとカウンターをリセットします。

そして動画の場合、タップ時間がしきい値以下ならemitし、しきい値以上ならストーリーを再開します。

画像の場合、タップ時間がしきい値以下なら画像のタイマーをリセットしてemitし、しきい値以上なら何もしません。

StoryPlayer.vue

const handlePressEnd = (key: "prev" | "next") => {
  if (isVideo.value) {
    if (pressingCounter.value < PRESSING_THRESHOLD) {
      emit(key);
    } else {
      videoRef.value?.play();
    }
  }

  if (isImg.value) {
    if (pressingCounter.value < PRESSING_THRESHOLD) {
      // 送り戻しの場合は画像のタイマーをリセット
      imageDisplayCounter.value = 0;
      if (imageDisplayTimerId.value !== undefined) {
        clearInterval(imageDisplayTimerId.value);
        imageDisplayTimerId.value = undefined;
      }
      emit(key);
    }
  }

  isPressing.value = false;
  pressingCounter.value = 0;
  clearInterval(pressingTimerId.value);
  pressingTimerId.value = undefined;
};
<button
  class="StoryPlayerPrev"
  @touchstart.prevent="handlePressStart()"
  @touchend.prevent="handlePressEnd('prev')"
  @mousedown="handlePressStart()"
  @mouseup="handlePressEnd('prev')"
></button>
<button
  class="StoryPlayerNext"
  @touchstart.prevent="handlePressStart()"
  @touchend.prevent="handlePressEnd('next')"
  @mousedown="handlePressStart()"
  @mouseup="handlePressEnd('next')"
></button>

ここまでで、動画と画像をストーリーとして再生することができるようになりました🥳

これ以降もやることは山積みで、動画や画像の読み込み最適化、ストーリー上のテキスト表示、インジケーターのプログレスバー化などなど、盛りだくさんです。

おわりに

結果的に自作したコンポーネントは使わずとも、冒頭の通りVueでAMP frameworkが使えることがわかりましたが、動画の再生周りやTouchEventについての知識が深まったのでかなり勉強になりました。(次はVueとAMPの記事を書こうかな・・・。
解説記事を書くにあたって、作ったものをわかりやすくリファクタリングしたり、改めて調べ直して知識の再整理する機会になったので、非常に有意義なアウトプットになったと実感しています。

ヤプリではフロントエンドに限らずこのような取り組みやアウトプットを積極的に奨励しています! もしご興味があれば、ぜひ一度カジュアルにお話ししてみませんか?

open.talentio.com