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
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>
今回はタイムラインやチャプター選択などの機能も加えていますが、単純に再生するだけなら、ほんの少しだけのコードで実装できます。今回のサンプルは理解できそうですか? 他にも様々なことができそうなので時間があれば勉強してみようと思います。
今回の内容は初心者のポンコツ2人組には敷居が高そうですね・・・
この2人はいつもこんな感じでマイペースに頑張ってます!