视频播放进度记忆:基于videojs-player实现历史记录
1. 痛点与解决方案概述
你是否遇到过这样的场景:观看教学视频时中途被打断,再次打开却需要重新拖动进度条寻找上次观看位置?或者在多设备间切换时,希望无缝接续视频播放进度?这些问题严重影响用户体验,尤其对于教育、培训类平台更为突出。
本文将详细介绍如何基于videojs-player组件实现视频播放进度的本地存储与恢复功能,核心解决以下问题:
- 自动记录用户观看位置(精确到秒)
- 页面刷新或重新打开时恢复上次进度
- 支持多视频独立记忆管理
- 提供手动清除记录功能
通过本文你将获得:
- 完整的进度记忆实现方案(含核心代码)
- 本地存储优化策略
- 与
videojs-player深度集成的实践经验 - 可直接复用的组件代码与演示案例
2. 技术原理与实现思路
2.1 核心技术栈
| 技术 | 版本 | 作用 |
|---|---|---|
| videojs-player | ^8.0.0 | 视频播放核心组件 |
| LocalStorage | HTML5标准 | 持久化存储播放进度 |
| TypeScript | ^4.5.0 | 类型安全开发 |
| Vue.js/React | 3.x | 前端框架支持 |
2.2 实现流程图
2.3 数据存储结构设计
interface PlaybackRecord {
videoId: string; // 视频唯一标识
currentTime: number; // 当前播放时间(秒)
duration: number; // 视频总时长(秒)
lastWatched: string; // 最后观看时间
completed: boolean; // 是否播放完成(>95%视为完成)
positionPercent: number; // 播放进度百分比
}
3. 核心功能实现
3.1 进度记录服务封装
首先实现一个通用的进度记录服务,负责与LocalStorage交互:
class PlaybackHistoryService {
private STORAGE_KEY = 'videojs_playback_history';
// 获取指定视频的播放记录
getRecord(videoId: string): PlaybackRecord | null {
const records = this.getAllRecords();
return records.find(item => item.videoId === videoId) || null;
}
// 保存播放记录
saveRecord(record: PlaybackRecord): void {
const records = this.getAllRecords();
// 移除旧记录
const filtered = records.filter(item => item.videoId !== record.videoId);
// 添加新记录
filtered.push({
...record,
lastWatched: new Date().toISOString()
});
// 限制最多存储100条记录
if (filtered.length > 100) {
filtered.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime());
filtered.pop();
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
// 获取所有记录
getAllRecords(): PlaybackRecord[] {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
}
// 删除指定记录
deleteRecord(videoId: string): void {
const records = this.getAllRecords();
const filtered = records.filter(item => item.videoId !== videoId);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
// 清空所有记录
clearAllRecords(): void {
localStorage.removeItem(this.STORAGE_KEY);
}
}
3.2 与videojs-player集成
Vue3组件实现
<template>
<div class="video-container">
<video-player
ref="videoPlayer"
:options="playerOptions"
@ready="onPlayerReady"
@timeupdate="onTimeUpdate"
@pause="onPause"
@ended="onEnded"
/>
<button @click="clearHistory" v-if="hasHistory">
清除播放记录
</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { VideoPlayer } from '@videojs-player/vue';
import { PlaybackHistoryService } from './services/PlaybackHistoryService';
const videoId = 'video_123456'; // 实际项目中应动态传入
const player = ref<VideoPlayer | null>(null);
const historyService = new PlaybackHistoryService();
const hasHistory = ref(false);
let lastSavedTime = 0;
const SAVE_INTERVAL = 30; // 30秒自动保存一次
const playerOptions = {
autoplay: false,
controls: true,
responsive: true,
fluid: true,
sources: [{
src: 'https://cdn.example.com/videos/sample.mp4',
type: 'video/mp4'
}]
};
const onPlayerReady = (player: any) => {
// 检查是否有历史记录
const record = historyService.getRecord(videoId);
if (record && !record.completed) {
hasHistory.value = true;
// 恢复播放进度
player.currentTime(record.currentTime);
// 显示提示信息
console.log(`已恢复上次播放进度: ${formatTime(record.currentTime)}`);
}
};
const onTimeUpdate = (currentTime: number, duration: number) => {
const now = Date.now() / 1000;
// 每30秒或进度变化超过10秒时保存
if (now - lastSavedTime > SAVE_INTERVAL ||
Math.abs(currentTime - lastSavedTime) > 10) {
saveProgress(currentTime, duration);
lastSavedTime = now;
}
};
const onPause = (currentTime: number, duration: number) => {
saveProgress(currentTime, duration);
};
const onEnded = (currentTime: number, duration: number) => {
// 播放完成(超过95%视为完成)
const isCompleted = currentTime / duration > 0.95;
saveProgress(currentTime, duration, isCompleted);
};
const saveProgress = (currentTime: number, duration: number, completed = false) => {
historyService.saveRecord({
videoId,
currentTime,
duration,
completed,
positionPercent: (currentTime / duration) * 100
});
};
const clearHistory = () => {
historyService.deleteRecord(videoId);
hasHistory.value = false;
player.value?.currentTime(0);
};
// 格式化时间为分:秒格式
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' + secs : secs}`;
};
</script>
3.3 React组件实现
import React, { useRef, useState, useEffect } from 'react';
import { VideoPlayer } from '@videojs-player/react';
import { PlaybackHistoryService } from './services/PlaybackHistoryService';
interface VideoPlayerWithHistoryProps {
videoId: string;
videoSrc: string;
}
export const VideoPlayerWithHistory: React.FC<VideoPlayerWithHistoryProps> = ({
videoId,
videoSrc
}) => {
const playerRef = useRef<VideoPlayer | null>(null);
const historyService = new PlaybackHistoryService();
const [hasHistory, setHasHistory] = useState(false);
let lastSavedTime = 0;
const SAVE_INTERVAL = 30;
useEffect(() => {
// 组件卸载时保存进度
return () => {
if (playerRef.current) {
const currentTime = playerRef.current.currentTime();
const duration = playerRef.current.duration();
saveProgress(currentTime, duration);
}
};
}, []);
const onPlayerReady = (player: any) => {
const record = historyService.getRecord(videoId);
if (record && !record.completed) {
setHasHistory(true);
player.currentTime(record.currentTime);
console.log(`已恢复上次播放进度: ${formatTime(record.currentTime)}`);
}
};
const onTimeUpdate = () => {
if (!playerRef.current) return;
const currentTime = playerRef.current.currentTime();
const duration = playerRef.current.duration();
const now = Date.now() / 1000;
if (now - lastSavedTime > SAVE_INTERVAL ||
Math.abs(currentTime - lastSavedTime) > 10) {
saveProgress(currentTime, duration);
lastSavedTime = now;
}
};
const saveProgress = (currentTime: number, duration: number, completed = false) => {
historyService.saveRecord({
videoId,
currentTime,
duration,
completed,
positionPercent: (currentTime / duration) * 100
});
};
const clearHistory = () => {
historyService.deleteRecord(videoId);
setHasHistory(false);
playerRef.current?.currentTime(0);
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' + secs : secs}`;
};
return (
<div className="video-player-container">
<VideoPlayer
ref={playerRef}
options={{
autoplay: false,
controls: true,
responsive: true,
fluid: true,
sources: [{ src: videoSrc, type: 'video/mp4' }]
}}
onReady={onPlayerReady}
onTimeUpdate={onTimeUpdate}
onPause={() => {
if (playerRef.current) {
saveProgress(
playerRef.current.currentTime(),
playerRef.current.duration()
);
}
}}
onEnded={() => {
if (playerRef.current) {
const currentTime = playerRef.current.currentTime();
const duration = playerRef.current.duration();
saveProgress(currentTime, duration, currentTime / duration > 0.95);
}
}}
/>
{hasHistory && (
<button onClick={clearHistory} className="clear-history-btn">
清除播放记录
</button>
)}
</div>
);
};
4. 高级优化策略
4.1 性能优化
| 优化点 | 实现方法 | 效果 |
|---|---|---|
| 减少存储次数 | 设置30秒自动保存间隔 | 降低LocalStorage操作频率 |
| 防抖处理 | 使用时间戳判断是否需要保存 | 避免频繁触发保存 |
| 批量存储 | 多视频记录合并存储 | 减少存储键值对数量 |
| 惰性加载 | 视频播放时才初始化记录 | 提高页面加载速度 |
4.2 数据安全与容量控制
// 改进的存储方法,增加容量控制
saveRecord(record: PlaybackRecord): void {
const records = this.getAllRecords();
const filtered = records.filter(item => item.videoId !== record.videoId);
// 只保留最近100条记录
if (filtered.length >= 100) {
// 按最后观看时间排序,保留最新的99条
filtered.sort((a, b) =>
new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()
);
filtered.splice(99);
}
filtered.push({
...record,
lastWatched: new Date().toISOString()
});
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
} catch (e) {
console.error('存储播放记录失败:', e);
// 处理存储满的情况,删除最旧的记录后重试
if (filtered.length > 0) {
filtered.shift();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
}
}
5. 浏览器兼容性处理
5.1 LocalStorage兼容性
| 浏览器 | 支持版本 | 特殊处理 |
|---|---|---|
| Chrome | 4+ | 无需处理 |
| Firefox | 3.5+ | 无需处理 |
| Safari | 4+ | 隐私模式下可能不可用 |
| IE | 8+ | 需要polyfill |
5.2 降级处理方案
// 兼容性处理增强版
class PlaybackHistoryService {
private STORAGE_KEY = 'videojs_playback_history';
private isStorageAvailable: boolean;
constructor() {
this.isStorageAvailable = this.checkStorageAvailability();
}
// 检查LocalStorage是否可用
private checkStorageAvailability(): boolean {
try {
const key = '__storage_test__';
localStorage.setItem(key, key);
localStorage.removeItem(key);
return true;
} catch (e) {
return false;
}
}
// 保存记录时的兼容性处理
saveRecord(record: PlaybackRecord): void {
if (!this.isStorageAvailable) {
console.warn('LocalStorage不可用,无法保存播放记录');
return;
}
// ... 正常存储逻辑 ...
}
// ... 其他方法 ...
}
6. 完整使用示例
6.1 Vue3完整页面
<template>
<div class="course-video-page">
<h1>Vue.js实战教程 - 第1章</h1>
<video-player-with-history
:video-id="videoId"
:video-src="videoSource"
/>
<div class="video-chapters">
<h2>课程章节</h2>
<ul>
<li v-for="chapter in chapters" :key="chapter.id">
<button @click="switchVideo(chapter.id, chapter.src)">
{{ chapter.title }}
<span v-if="chapter.id === currentVideoId && hasHistory">
(上次看到: {{ formatTime(lastPosition) }})
</span>
</button>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import VideoPlayerWithHistory from './components/VideoPlayerWithHistory.vue';
import { PlaybackHistoryService } from './services/PlaybackHistoryService';
const historyService = new PlaybackHistoryService();
const currentVideoId = ref('vue_intro_01');
const videoSource = ref('https://cdn.example.com/videos/vue-intro.mp4');
const hasHistory = ref(false);
const lastPosition = ref(0);
const chapters = reactive([
{ id: 'vue_intro_01', title: 'Vue3简介', src: 'https://cdn.example.com/videos/vue-intro.mp4' },
{ id: 'vue_intro_02', title: '响应式原理', src: 'https://cdn.example.com/videos/vue-reactivity.mp4' },
{ id: 'vue_intro_03', title: '组件基础', src: 'https://cdn.example.com/videos/vue-components.mp4' }
]);
// 切换视频时检查是否有历史记录
const switchVideo = (id: string, src: string) => {
currentVideoId.value = id;
videoSource.value = src;
const record = historyService.getRecord(id);
hasHistory.value = !!record;
if (record) {
lastPosition.value = record.currentTime;
}
};
// 格式化时间显示
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' + secs : secs}`;
};
// 初始化时检查当前视频的历史记录
switchVideo(currentVideoId.value, videoSource.value);
</script>
7. 总结与扩展
7.1 功能回顾
本文实现了一个功能完善的视频播放进度记忆组件,主要特点包括:
- 自动记录与恢复播放进度
- 高效的LocalStorage数据管理
- 完善的错误处理与兼容性考虑
- 支持Vue.js和React两种主流框架
7.2 未来扩展方向
- 云同步功能:结合用户账号系统,实现多设备间进度同步
- 智能预加载:根据历史记录预测用户可能观看的下一视频
- 观看行为分析:统计用户观看习惯,优化视频内容
- 离线播放支持:结合Service Worker实现离线进度记录
7.3 最佳实践建议
- 始终为视频提供唯一标识(videoId)
- 设置合理的自动保存间隔(建议20-60秒)
- 提供清晰的"清除记录"功能入口
- 对长视频考虑分段记录,提高恢复精度
- 在隐私模式下提供明确的功能不可用提示
8. 常见问题解答
Q1: 为什么有时进度记录不精确?
A1: 由于我们采用定时保存机制(默认30秒),极端情况下可能会丢失最后30秒的播放进度。可以通过减小保存间隔提高精度,但会增加存储操作频率。
Q2: 如何在多标签页同时播放时保持进度同步?
A2: 可以通过监听storage事件实现多标签页间的数据同步:
// 监听LocalStorage变化
window.addEventListener('storage', (e) => {
if (e.key === 'videojs_playback_history' && playerRef.current) {
const records = JSON.parse(e.newValue || '[]');
const currentVideoRecord = records.find(r => r.videoId === currentVideoId);
if (currentVideoRecord) {
playerRef.current.currentTime(currentVideoRecord.currentTime);
}
}
});
Q3: 如何迁移到IndexedDB以支持更大数据量?
A3: 可以实现一个抽象存储层,无缝切换LocalStorage和IndexedDB:
interface StorageAdapter {
getRecords(): Promise<PlaybackRecord[]>;
saveRecord(record: PlaybackRecord): Promise<void>;
deleteRecord(videoId: string): Promise<void>;
}
// 然后分别实现LocalStorageAdapter和IndexedDBAdapter
希望本文提供的方案能帮助你为用户打造更优质的视频观看体验!如果觉得有用,请点赞收藏,关注获取更多前端视频播放技术分享。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



