HLS形式の動画を再生する

Vue.jsでHLS形式の動画を再生するプログラムに挑戦してみました!
今回はVue3、Nuxt3、TypeScriptの組み合わせで実装しています。
下記コマンドでインストールします。
npx nuxi init my-nuxt3-project
cd my-nuxt3-project
npm install

HLSを再生するには hls.js というJavaScriptライブラリを使用するのが一般的なので hls.js もインストールします。
npm i hls.js

※この記事は2025/08/21時点の情報です。

components/HlsPlayer.vue

<template>
  <div class="player-container" @keydown.stop>
    <div class="video-wrapper">
      <video
        ref="videoEl"
        class="video-element"
        :poster="poster"
        playsinline
        :muted="muted"
        :autoplay="autoplay"
        @click="togglePlay"
      ></video>

      <div class="controls">
        <div class="controls-row">
          <button class="btn" @click.stop="togglePlay">
            {{ isPlaying ? '⏸' : '▶' }}
          </button>

          <button class="btn" @click.stop="toggleMute">
            {{ muted ? '🔇' : '🔊' }}
          </button>

          <div class="time-display">
            {{ formatTime(currentTime) }} / {{ duration > 0 ? formatTime(duration) : '--:--' }}
          </div>

          <div class="rate-selector">
            <label>速度</label>
            <select v-model.number="playbackRate" @change="applyPlaybackRate">
              <option v-for="r in rates" :key="r" :value="r">{{ r.toFixed(2) }}</option>
            </select>
          </div>
        </div>

        <div
          ref="barEl"
          class="timeline-bar"
          @pointerdown="onBarPointerDown"
          @pointermove.prevent="onBarPointerMove"
          @pointerleave="hoverX = null"
        >
          <template v-for="(range, idx) in bufferedRanges" :key="idx">
            <div
              class="timeline-buffered"
              :style="{
                left: `${range.startPct}%`,
                width: `${range.widthPct}%`
              }"
            ></div>
          </template>

          <div
            class="timeline-progress"
            :style="{ width: progressPct + '%' }"
          ></div>

          <div class="chapters">
            <button
              v-for="chapter in chapters"
              :key="chapter.label"
              @click="seekTo(chapter.time)"
              class="chapter-btn"
            >
              {{ chapter.label }}
            </button>
          </div>

          <div
            v-if="hoverX !== null"
            class="timeline-hover"
            :style="{ left: hoverX + 'px' }"
          >
            {{ formatTime(hoverTime) }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.player-container {
  width: 100%;
  max-width: 960px;
  margin: 0 auto;
  user-select: none;
}
.video-wrapper {
  position: relative;
  background: black;
  border-radius: 16px;
  overflow: hidden;
}
.video-element {
  width: 100%;
  height: auto;
}
.controls {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 12px;
  background: linear-gradient(to top, rgba(0,0,0,0.6), transparent);
  color: white;
}
.controls-row {
  display: flex;
  align-items: center;
  gap: 8px;
}
.btn {
  padding: 4px 12px;
  border-radius: 8px;
  background: rgba(255,255,255,0.1);
  border: none;
  cursor: pointer;
}
.btn:hover {
  background: rgba(255,255,255,0.2);
}
.time-display {
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.rate-selector {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 4px;
}
.rate-selector select {
  padding: 2px 6px;
  border-radius: 6px;
  background: rgba(255,255,255,0.1);
  color: white;
  border: none;
}

.timeline-bar {
  margin-top: 12px;
  height: 8px;
  background: rgba(255,255,255,0.2);
  border-radius: 4px;
  position: relative;
  cursor: pointer;
}
.timeline-buffered {
  position: absolute;
  top: 0;
  height: 100%;
  background: rgba(255,255,255,0.4);
  border-radius: 4px;
}
.timeline-progress {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  background: white;
  border-radius: 4px;
}
.timeline-chapter {
  position: absolute;
  top: 0;
  height: 100%;
  width: 2px;
  background: rgba(0,0,0,0.7);
}
.timeline-hover {
  position: absolute;
  top: -28px;
  transform: translateX(-50%);
  font-size: 12px;
  background: black;
  color: white;
  padding: 2px 6px;
  border-radius: 6px;
  white-space: nowrap;
}
.chapter-labels {
  margin-top: 4px;
  font-size: 10px;
  display: flex;
  justify-content: space-between;
  color: rgba(255,255,255,0.8);
  position: relative;
}
.chapter-labels div {
  position: absolute;
  transform: translateX(-50%);
}
</style>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, computed } from 'vue'
import Hls from 'hls.js'

type Chapter = { time: number; label: string }

const props = defineProps<{
  src: string
  poster?: string
  autoplay?: boolean
  muted?: boolean
  chapters?: Chapter[]
}>()

const videoEl = ref<HTMLVideoElement | null>(null)
const barEl = ref<HTMLDivElement | null>(null)
const hls = ref<Hls | null>(null)

const isPlaying = ref(false)
const duration = ref(0)
const currentTime = ref(0)
const hoverX = ref<number | null>(null)
const playbackRate = ref(1.0)
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]

const progressPct = computed(() => {
  if (!duration.value) return 0
  return (currentTime.value / duration.value) * 100
})

const normalizedChapters = computed(() => {
  if (!props.chapters || !props.chapters.length || !duration.value) return [] as Array<Chapter & { pct: number }>
  return props.chapters
    .filter(c => c.time >= 0 && c.time <= duration.value)
    .map(c => ({ ...c, pct: (c.time / duration.value) * 100 }))
})

const bufferedRanges = computed(() => {
  const v = videoEl.value
  if (!v || !duration.value || v.buffered.length === 0) return [] as Array<{ startPct: number; widthPct: number }>
  const out: Array<{ startPct: number; widthPct: number }> = []
  for (let i = 0; i < v.buffered.length; i++) {
    const start = v.buffered.start(i)
    const end = v.buffered.end(i)
    out.push({ startPct: (start / duration.value) * 100, widthPct: ((end - start) / duration.value) * 100 })
  }
  return out
})

const formatTime = (sec: number) => {
  if (!Number.isFinite(sec)) return '--:--'
  const s = Math.floor(sec)
  const h = Math.floor(s / 3600)
  const m = Math.floor((s % 3600) / 60)
  const ss = s % 60
  const mm = String(m).padStart(2, '0')
  const sss = String(ss).padStart(2, '0')
  return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`
}

const attachSrc = () => {
  const v = videoEl.value
  if (!v) return

  // SafariなどHLSネイティブ再生対応
  if (v.canPlayType('application/vnd.apple.mpegURL')) {
    v.src = props.src
    v.addEventListener('loadedmetadata', () => {
      duration.value = v.duration
    }, { once: true })
  } else if (Hls.isSupported()) {
    const instance = new Hls({
      // 例: 自動ビットレート、低遅延オプションなどここで調整可能
      // lowLatencyMode: true,
      // maxLiveSyncPlaybackRate: 1.5,
    })
    hls.value = instance
    instance.attachMedia(v)
    instance.on(Hls.Events.MEDIA_ATTACHED, () => {
      instance.loadSource(props.src)
    })
    instance.on(Hls.Events.MANIFEST_PARSED, () => {
      duration.value = v.duration || duration.value
      if (props.autoplay) v.play().catch(() => {/* autoplay ブロック対策 */})
    })
  } else {
    console.error('This browser does not support HLS.')
  }
}

const cleanup = () => {
  if (hls.value) {
    hls.value.destroy()
    hls.value = null
  }
}

const togglePlay = async () => {
  const v = videoEl.value
  if (!v) return
  if (v.paused) {
    await v.play().catch(() => {/* ignore */})
  } else {
    v.pause()
  }
}

const toggleMute = () => {
  const v = videoEl.value
  if (!v) return
  v.muted = !v.muted
}

const onTimeUpdate = () => {
  const v = videoEl.value
  if (!v) return
  currentTime.value = v.currentTime
  duration.value = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : duration.value
  isPlaying.value = !v.paused && !v.ended
}

const seekToPct = (pct: number) => {
  const v = videoEl.value
  if (!v || !duration.value) return
  const t = Math.min(Math.max(pct, 0), 100) * duration.value / 100
  v.currentTime = t
}

const onBarPointerDown = (e: PointerEvent) => {
  const bar = barEl.value
  const v = videoEl.value
  if (!bar || !v || !duration.value) return
  bar.setPointerCapture(e.pointerId)
  updateHover(e)
  seekToPct(getPctFromClientX(e.clientX))
}

const onBarPointerMove = (e: PointerEvent) => {
  const bar = barEl.value
  if (!bar) return
  updateHover(e)
  if (bar.hasPointerCapture(e.pointerId)) {
    seekToPct(getPctFromClientX(e.clientX))
  }
}

const updateHover = (e: PointerEvent) => {
  const bar = barEl.value
  if (!bar) return
  const rect = bar.getBoundingClientRect()
  const x = Math.min(Math.max(e.clientX - rect.left, 0), rect.width)
  hoverX.value = x
}

const getPctFromClientX = (clientX: number) => {
  const bar = barEl.value
  if (!bar) return 0
  const rect = bar.getBoundingClientRect()
  const x = Math.min(Math.max(clientX - rect.left, 0), rect.width)
  return (x / rect.width) * 100
}

const hoverTime = computed(() => {
  if (hoverX.value === null || !barEl.value || !duration.value) return 0
  const rect = barEl.value.getBoundingClientRect()
  const pct = hoverX.value / rect.width
  return pct * duration.value
})

const applyPlaybackRate = () => {
  const v = videoEl.value
  if (!v) return
  v.playbackRate = playbackRate.value
}

onMounted(() => {
  attachSrc()
  const v = videoEl.value
  if (!v) return
  v.addEventListener('timeupdate', onTimeUpdate)
  v.addEventListener('play', onTimeUpdate)
  v.addEventListener('pause', onTimeUpdate)
  v.addEventListener('durationchange', onTimeUpdate)
  v.addEventListener('progress', onTimeUpdate)
})

onBeforeUnmount(() => {
  const v = videoEl.value
  if (v) {
    v.removeEventListener('timeupdate', onTimeUpdate)
    v.removeEventListener('play', onTimeUpdate)
    v.removeEventListener('pause', onTimeUpdate)
    v.removeEventListener('durationchange', onTimeUpdate)
    v.removeEventListener('progress', onTimeUpdate)
  }
  cleanup()
})

// チャプタークリックでシーク
const seekTo = (time: number) => {
  if (videoEl.value) {
    videoEl.value.currentTime = time;
    videoEl.value.play(); // 自動再生したい場合
  }
};

watch(() => props.src, () => {
  cleanup()
  // srcが変わった場合、再アタッチ
  attachSrc()
})
</script>

pages/Hls.vue

<script setup lang="ts">
import HlsPlayer from '~/components/HlsPlayer.vue'

const chapters = [
  { time: 5, label: 'Intro' },
  { time: 30, label: 'Scene 1' },
  { time: 60, label: 'Scene 2' }
]
</script>

<template>
  <HlsPlayer 
    src="ここにHLS形式の動画ファイルのURLを指定します" 
    :chapters="chapters"
  />
</template>

今回はタイムラインやチャプター選択などの機能も加えていますが、単純に再生するだけなら、ほんの少しだけのコードで実装できます。今回のサンプルは理解できそうですか? 他にも様々なことができそうなので時間があれば勉強してみようと思います。

HLS形式の動画を再生するプログラムを覚えられましたか?

今回の内容は初心者のポンコツ2人組には敷居が高そうですね・・・
この2人はいつもこんな感じでマイペースに頑張ってます!

管理人情報