Vue.js M3U8播放器进阶:字幕支持与播放列表功能深度实现
·
前言
基于Vue.js的M3U8在线播放器开发实战:从零到一构建专业级播放器
在基础播放器功能之上,字幕支持和播放列表管理是提升用户体验的关键功能。本文将深入讲解如何在现有的Vue.js M3U8播放器中集成这些高级特性。
字幕功能实现
示例:pandas 是基于NumPy 的一种工具,该工具是为了解决数据分析任务而创建的。
字幕文件格式支持
首先,我们需要扩展播放器以支持多种字幕格式:
代码如下(示例):
// types/subtitle.ts
export interface SubtitleTrack {
id: string
src: string
srclang: string
label: string
kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata'
default?: boolean
}
export const SUBTITLE_FORMATS = {
VTT: 'vtt',
SRT: 'srt',
ASS: 'ass',
SSA: 'ssa'
} as const
export type SubtitleFormat = typeof SUBTITLE_FORMATS[keyof typeof SUBTITLE_FORMATS]
字幕解析器实现
代码如下(示例):
// utils/subtitleParser.ts
class SubtitleParser {
// 解析SRT格式
static parseSRT(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = []
const blocks = content.split(/\n\s*\n/)
blocks.forEach(block => {
const lines = block.trim().split('\n')
if (lines.length >= 3) {
const [index, timecode, ...textLines] = lines
const timeMatch = timecode.match(/(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/)
if (timeMatch) {
cues.push({
id: index,
startTime: this.parseSRTTime(timeMatch[1], timeMatch[2], timeMatch[3], timeMatch[4]),
endTime: this.parseSRTTime(timeMatch[5], timeMatch[6], timeMatch[7], timeMatch[8]),
text: textLines.join('\n')
})
}
}
})
return cues
}
// 解析VTT格式
static parseVTT(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = []
const lines = content.split('\n')
let currentCue: Partial<SubtitleCue> = {}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (line === '') continue
if (line.includes('-->')) {
const timeMatch = line.match(/(\d{2}):(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\.(\d{3})/)
if (timeMatch) {
currentCue = {
startTime: this.parseVTTTime(timeMatch[1], timeMatch[2], timeMatch[3], timeMatch[4]),
endTime: this.parseVTTTime(timeMatch[5], timeMatch[6], timeMatch[7], timeMatch[8]),
text: ''
}
}
} else if (currentCue.startTime !== undefined) {
currentCue.text += (currentCue.text ? '\n' : '') + line
if (!lines[i + 1]?.trim()) {
cues.push(currentCue as SubtitleCue)
currentCue = {}
}
}
}
return cues
}
private static parseSRTTime(hours: string, minutes: string, seconds: string, milliseconds: string): number {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(milliseconds) / 1000
}
private static parseVTTTime(hours: string, minutes: string, seconds: string, milliseconds: string): number {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(milliseconds) / 1000
}
}
字幕组件实现
代码如下(示例):
<template>
<div class="subtitle-manager">
<el-card header="字幕管理" class="subtitle-card">
<div class="subtitle-controls">
<el-button @click="showUploadDialog = true">上传字幕</el-button>
<el-select v-model="activeSubtitle" placeholder="选择字幕轨道" @change="switchSubtitle">
<el-option label="关闭字幕" value="off" />
<el-option
v-for="track in subtitleTracks"
:key="track.id"
:label="track.label"
:value="track.id"
/>
</el-select>
<el-button-group>
<el-button @click="adjustSubtitleTiming(-0.5)">-0.5s</el-button>
<el-button @click="adjustSubtitleTiming(0.5)">+0.5s</el-button>
<el-button @click="resetSubtitleTiming">重置时序</el-button>
</el-button-group>
</div>
<!-- 字幕显示区域 -->
<div
v-if="currentSubtitleText && activeSubtitle !== 'off'"
class="subtitle-display"
:style="subtitleStyle"
>
{{ currentSubtitleText }}
</div>
</el-card>
<!-- 字幕上传对话框 -->
<el-dialog v-model="showUploadDialog" title="上传字幕文件" width="500px">
<el-upload
ref="uploadRef"
action="#"
:auto-upload="false"
:on-change="handleSubtitleUpload"
:file-list="fileList"
accept=".vtt,.srt,.ass"
>
<template #trigger>
<el-button type="primary">选择字幕文件</el-button>
</template>
<template #tip>
<div class="el-upload__tip">支持格式: VTT, SRT, ASS (最大 2MB)</div>
</template>
</el-upload>
<div v-if="uploadingSubtitle" class="upload-options">
<el-input v-model="newSubtitleLabel" placeholder="字幕标签 (如: 中文简体)" />
<el-input v-model="newSubtitleLang" placeholder="语言代码 (如: zh-CN)" />
</div>
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button type="primary" @click="confirmSubtitleUpload">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { SubtitleTrack, SubtitleCue } from '../types/subtitle'
import { SubtitleParser } from '../utils/subtitleParser'
// 响应式数据
const subtitleTracks = ref<SubtitleTrack[]>([])
const activeSubtitle = ref('off')
const currentSubtitleText = ref('')
const showUploadDialog = ref(false)
const uploadingSubtitle = ref(null)
const newSubtitleLabel = ref('')
const newSubtitleLang = ref('')
const subtitleOffset = ref(0)
const fileList = ref([])
// 计算属性
const subtitleStyle = computed(() => ({
fontSize: '24px',
color: '#ffffff',
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
textAlign: 'center',
padding: '10px',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: '4px',
margin: '20px 0'
}))
// 字幕时序处理
let subtitleCues: SubtitleCue[] = []
let subtitleInterval: number | null = null
// 初始化内置字幕轨道
const initDefaultSubtitles = () => {
subtitleTracks.value = [
{
id: 'chinese',
src: '/subtitles/chinese.vtt',
srclang: 'zh-CN',
label: '中文简体',
kind: 'subtitles'
},
{
id: 'english',
src: '/subtitles/english.vtt',
srclang: 'en',
label: 'English',
kind: 'subtitles'
}
]
}
// 切换字幕
const switchSubtitle = async (trackId: string) => {
if (trackId === 'off') {
clearSubtitleDisplay()
return
}
const track = subtitleTracks.value.find(t => t.id === trackId)
if (!track) return
try {
const response = await fetch(track.src)
const content = await response.text()
// 根据文件扩展名选择解析器
if (track.src.endsWith('.vtt')) {
subtitleCues = SubtitleParser.parseVTT(content)
} else if (track.src.endsWith('.srt')) {
subtitleCues = SubtitleParser.parseSRT(content)
}
startSubtitleTracking()
} catch (error) {
ElMessage.error('字幕加载失败')
}
}
// 开始字幕跟踪
const startSubtitleTracking = () => {
clearSubtitleDisplay()
subtitleInterval = setInterval(() => {
if (!player.value) return
const currentTime = player.value.currentTime() + subtitleOffset.value
const currentCue = subtitleCues.find(cue =>
currentTime >= cue.startTime && currentTime <= cue.endTime
)
currentSubtitleText.value = currentCue?.text || ''
}, 100)
}
// 调整字幕时序
const adjustSubtitleTiming = (offset: number) => {
subtitleOffset.value += offset
ElMessage.success(`字幕时序调整: ${offset > 0 ? '+' : ''}${offset}秒`)
}
const resetSubtitleTiming = () => {
subtitleOffset.value = 0
ElMessage.success('字幕时序已重置')
}
// 处理字幕上传
const handleSubtitleUpload = (file: File) => {
uploadingSubtitle.value = file
newSubtitleLabel.value = file.name.replace(/\.[^/.]+$/, "") // 移除扩展名
}
const confirmSubtitleUpload = async () => {
if (!uploadingSubtitle.value) return
try {
const content = await uploadingSubtitle.value.text()
const extension = uploadingSubtitle.value.name.split('.').pop()?.toLowerCase()
// 验证文件格式
if (!['vtt', 'srt', 'ass'].includes(extension)) {
throw new Error('不支持的格式')
}
// 创建新的字幕轨道
const newTrack: SubtitleTrack = {
id: `custom-${Date.now()}`,
src: URL.createObjectURL(uploadingSubtitle.value),
srclang: newSubtitleLang.value || 'custom',
label: newSubtitleLabel.value || '自定义字幕',
kind: 'subtitles'
}
subtitleTracks.value.push(newTrack)
showUploadDialog.value = false
ElMessage.success('字幕上传成功')
} catch (error) {
ElMessage.error('字幕上传失败')
}
}
// 清理
const clearSubtitleDisplay = () => {
if (subtitleInterval) {
clearInterval(subtitleInterval)
subtitleInterval = null
}
currentSubtitleText.value = ''
}
onMounted(() => {
initDefaultSubtitles()
})
// 监听播放器销毁
watch(() => player.value, (newPlayer, oldPlayer) => {
if (!newPlayer && oldPlayer) {
clearSubtitleDisplay()
}
})
</script>
<style scoped>
.subtitle-manager {
margin-top: 20px;
}
.subtitle-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
.subtitle-display {
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.upload-options {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
@media (max-width: 768px) {
.subtitle-controls {
flex-direction: column;
align-items: stretch;
}
.subtitle-display {
font-size: 18px;
}
}
</style>
播放列表功能实现
- 播放列表数据结构
// types/playlist.ts
export interface PlaylistItem {
id: string
title: string
url: string
duration?: number
thumbnail?: string
description?: string
qualities?: VideoQuality[]
currentTime?: number // 播放进度
}
export interface VideoQuality {
id: string
label: string
url: string
bandwidth: number
}
export interface Playlist {
id: string
title: string
items: PlaylistItem[]
currentIndex: number
loop: boolean
shuffle: boolean
}
- 播放列表组件
<template>
<div class="playlist-manager">
<el-card header="播放列表" class="playlist-card">
<div class="playlist-controls">
<el-button-group>
<el-button
:disabled="!hasPrevious"
@click="playPrevious"
>
<el-icon><ArrowLeft /></el-icon> 上一个
</el-button>
<el-button
:disabled="!hasNext"
@click="playNext"
>
下一个 <el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
<el-switch
v-model="playlist.loop"
active-text="循环播放"
/>
<el-switch
v-model="playlist.shuffle"
active-text="随机播放"
/>
<el-button @click="clearPlaylist">清空列表</el-button>
</div>
<div class="playlist-content">
<el-table
:data="playlist.items"
highlight-current-row
@row-click="playItem"
style="width: 100%"
>
<el-table-column width="50">
<template #default="{ $index }">
<el-icon v-if="$index === playlist.currentIndex">
<VideoPlay />
</el-icon>
<span v-else>{{ $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column label="标题" min-width="200">
<template #default="{ row }">
<div class="video-title">
<el-image
v-if="row.thumbnail"
:src="row.thumbnail"
:preview-src-list="[row.thumbnail]"
fit="cover"
style="width: 60px; height: 40px; margin-right: 10px;"
/>
<div>
<div class="title-text">{{ row.title }}</div>
<div v-if="row.description" class="description">
{{ row.description }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="时长" width="100">
<template #default="{ row }">
{{ formatTime(row.duration || 0) }}
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress
v-if="row.currentTime && row.duration"
:percentage="Math.round((row.currentTime / row.duration) * 100)"
:show-text="false"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row, $index }">
<el-button-group size="small">
<el-button @click.stop="removeItem($index)">
<el-icon><Delete /></el-icon>
</el-button>
<el-button @click.stop="moveItem($index, $index - 1)" :disabled="$index === 0">
<el-icon><ArrowUp /></el-icon>
</el-button>
<el-button @click.stop="moveItem($index, $index + 1)" :disabled="$index === playlist.items.length - 1">
<el-icon><ArrowDown /></el-icon>
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- 添加视频对话框 -->
<el-dialog v-model="showAddDialog" title="添加视频到播放列表" width="600px">
<el-form :model="newVideoForm" label-width="80px">
<el-form-item label="视频标题">
<el-input v-model="newVideoForm.title" placeholder="输入视频标题" />
</el-form-item>
<el-form-item label="M3U8链接">
<el-input v-model="newVideoForm.url" placeholder="输入M3U8链接" />
</el-form-item>
<el-form-item label="缩略图">
<el-input v-model="newVideoForm.thumbnail" placeholder="输入缩略图URL" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="newVideoForm.description"
type="textarea"
:rows="3"
placeholder="输入视频描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="addVideo">添加</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowRight, VideoPlay, Delete, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import type { Playlist, PlaylistItem } from '../types/playlist'
// 播放列表数据
const playlist = ref<Playlist>({
id: 'default',
title: '默认播放列表',
items: [],
currentIndex: -1,
loop: false,
shuffle: false
})
const showAddDialog = ref(false)
const newVideoForm = ref({
title: '',
url: '',
thumbnail: '',
description: ''
})
// 计算属性
const hasPrevious = computed(() => playlist.value.currentIndex > 0)
const hasNext = computed(() => playlist.value.currentIndex < playlist.value.items.length - 1)
const currentItem = computed(() =>
playlist.value.items[playlist.value.currentIndex]
)
// 播放列表操作
const playItem = async (item: PlaylistItem, index: number) => {
const oldIndex = playlist.value.currentIndex
playlist.value.currentIndex = index
try {
// 更新播放器源
m3u8Url.value = item.url
videoTitle.value = item.title
await loadVideo()
// 恢复播放进度
if (item.currentTime && item.currentTime > 0) {
player.value.currentTime(item.currentTime)
}
} catch (error) {
ElMessage.error('视频播放失败')
playlist.value.currentIndex = oldIndex
}
}
const playNext = () => {
if (!hasNext.value) {
if (playlist.value.loop) {
playlist.value.currentIndex = 0
} else {
return
}
} else {
playlist.value.currentIndex++
}
const nextItem = playlist.value.items[playlist.value.currentIndex]
playItem(nextItem, playlist.value.currentIndex)
}
const playPrevious = () => {
if (!hasPrevious.value) return
playlist.value.currentIndex--
const prevItem = playlist.value.items[playlist.value.currentIndex]
playItem(prevItem, playlist.value.currentIndex)
}
const addVideo = () => {
if (!newVideoForm.value.title || !newVideoForm.value.url) {
ElMessage.warning('请填写标题和链接')
return
}
const newItem: PlaylistItem = {
id: `video-${Date.now()}`,
title: newVideoForm.value.title,
url: newVideoForm.value.url,
thumbnail: newVideoForm.value.thumbnail,
description: newVideoForm.value.description
}
playlist.value.items.push(newItem)
showAddDialog.value = false
resetNewVideoForm()
// 如果是第一个视频,自动播放
if (playlist.value.items.length === 1) {
playItem(newItem, 0)
}
ElMessage.success('视频添加成功')
}
const removeItem = (index: number) => {
if (index === playlist.value.currentIndex) {
ElMessage.warning('不能删除正在播放的视频')
return
}
playlist.value.items.splice(index, 1)
if (index < playlist.value.currentIndex) {
playlist.value.currentIndex--
}
}
const moveItem = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= playlist.value.items.length) return
const item = playlist.value.items.splice(fromIndex, 1)[0]
playlist.value.items.splice(toIndex, 0, item)
// 更新当前索引
if (playlist.value.currentIndex === fromIndex) {
playlist.value.currentIndex = toIndex
} else if (fromIndex < playlist.value.currentIndex && toIndex >= playlist.value.currentIndex) {
playlist.value.currentIndex--
} else if (fromIndex > playlist.value.currentIndex && toIndex <= playlist.value.currentIndex) {
playlist.value.currentIndex++
}
}
const clearPlaylist = () => {
playlist.value.items = []
playlist.value.currentIndex = -1
ElMessage.success('播放列表已清空')
}
const resetNewVideoForm = () => {
newVideoForm.value = {
title: '',
url: '',
thumbnail: '',
description: ''
}
}
// 保存播放进度
watch(() => player.value?.currentTime(), (currentTime) => {
if (currentItem.value && currentTime > 0) {
currentItem.value.currentTime = currentTime
}
})
// 自动播放下一集
watch(() => player.value?.ended(), (ended) => {
if (ended && hasNext.value) {
setTimeout(playNext, 2000) // 2秒后播放下一集
}
})
// 本地存储
const savePlaylistToLocal = () => {
localStorage.setItem('m3u8-playlist', JSON.stringify(playlist.value))
}
const loadPlaylistFromLocal = () => {
const saved = localStorage.getItem('m3u8-playlist')
if (saved) {
playlist.value = JSON.parse(saved)
}
}
// 初始化
onMounted(() => {
loadPlaylistFromLocal()
})
// 监听播放列表变化,自动保存
watch(playlist, savePlaylistToLocal, { deep: true })
</script>
<style scoped>
.playlist-manager {
margin-top: 20px;
}
.playlist-controls {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
.video-title {
display: flex;
align-items: center;
}
.title-text {
font-weight: 500;
margin-bottom: 4px;
}
.description {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
:deep(.el-table .current-row) {
background-color: #f0f9ff;
}
@media (max-width: 768px) {
.playlist-controls {
flex-direction: column;
align-items: stretch;
}
.video-title {
flex-direction: column;
align-items: flex-start;
}
}
</style>
总结
通过以上实现,我们为M3U8播放器添加了完整的字幕支持和播放列表管理功能。这些高级特性显著提升了用户体验,使播放器更加专业和实用。
关键特性包括:
- 多格式字幕支持(VTT、SRT、ASS)
- 实时字幕时序调整
- 动态字幕上传和管理
- 完整的播放列表操作
- 播放进度记忆
- 自动连续播放
- 本地数据持久化
这些功能可以进一步扩展,比如添加网络字幕搜索、播放列表导入导出、智能推荐等高级特性。
更多推荐


所有评论(0)