前言

基于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)
  • 实时字幕时序调整
  • 动态字幕上传和管理
  • 完整的播放列表操作
  • 播放进度记忆
  • 自动连续播放
  • 本地数据持久化
    这些功能可以进一步扩展,比如添加网络字幕搜索、播放列表导入导出、智能推荐等高级特性。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐