首页 > 应用开发 > IT综合 > 视频续播功能实现:从前端交互到后端处理
# 视频续播功能实现:从前端交互到后端处理
## 一、用户场景与功能价值
在观看长视频内容时,用户经常因各种原因中断播放。视频续播功能能够记录用户观看进度,在下次打开时自动定位到上次中断的时间点,显著提升观看体验。这项技术不仅适用于在线教育平台,也广泛应用于影视网站、知识分享平台等场景。
## 二、技术架构概览
完整的视频续播功能需要前后端协同工作:前端负责进度采集与请求发送,后端负责数据存储与响应处理。整体架构如下图所示:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 前端播放器 │ │ Spring Boot │ │ 数据存储 │
│ │ │ 后端 │ │ │
│ 1.监听播放进度 │───▶│ 2.接收进度数据 │───▶│ 3.持久化存储 │
│ │ │ │ │ │
│ 6.跳转播放位置 │◀───│ 5.返回进度信息 │◀───│ 4.查询进度 │
└─────────────┘ └─────────────┘ └─────────────┘
```
## 三、前端实现策略
### 3.1 进度采集机制
前端播放器需要定期采集播放进度,但过于频繁的请求会增加服务器负担。合理的策略是在特定事件触发时上报进度:
```javascript
class VideoProgressTracker {
constructor(videoElement, options = {}) {
this.video = videoElement;
this.videoId = options.videoId;
this.userId = options.userId;
this.reportInterval = options.interval || 10000; // 10秒上报一次
this.lastReportedTime = 0;
this.initializeListeners();
}
initializeListeners() {
// 定期上报进度
this.video.addEventListener('timeupdate', () => {
const currentTime = Math.floor(this.video.currentTime);
const timeDiff = Math.abs(currentTime - this.lastReportedTime);
// 满足时间间隔或视频暂停时上报
if (timeDiff >= this.reportInterval / 1000 || this.video.paused) {
this.reportProgress(currentTime);
}
});
// 页面关闭前上报
window.addEventListener('beforeunload', () => {
this.reportProgress(Math.floor(this.video.currentTime));
});
// 暂停时上报
this.video.addEventListener('pause', () => {
this.reportProgress(Math.floor(this.video.currentTime));
});
}
async reportProgress(currentTime) {
if (this.lastReportedTime === currentTime) return;
try {
const response = await fetch('/api/video/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: this.videoId,
userId: this.userId,
progress: currentTime,
duration: Math.floor(this.video.duration)
})
});
if (response.ok) {
this.lastReportedTime = currentTime;
}
} catch (error) {
console.warn('进度上报失败:', error);
}
}
// 获取续播位置
async getResumePosition() {
try {
const response = await fetch(
`/api/video/progress?videoId=${this.videoId}&userId=${this.userId}`
);
if (response.ok) {
const data = await response.json();
return data.progress || 0;
}
} catch (error) {
console.warn('获取续播位置失败:', error);
return 0;
}
}
}
```
### 3.2 播放器集成示例
```html
<video id="videoPlayer" controls>
<source src="/api/video/stream/123" type="video/mp4">
</video>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const video = document.getElementById('videoPlayer');
const userId = getCurrentUserId(); // 获取用户标识
const videoId = '123'; // 视频唯一标识
// 创建进度跟踪器
const tracker = new VideoProgressTracker(video, {
videoId,
userId,
interval: 15000 // 15秒上报一次
});
// 获取续播位置并设置
const resumePosition = await tracker.getResumePosition();
if (resumePosition > 0) {
// 询问用户是否续播
if (confirm(`检测到您上次观看至 ${formatTime(resumePosition)},是否从此处继续播放?`)) {
video.currentTime = resumePosition;
}
}
video.play();
});
function formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
</script>
```
## 四、Spring Boot后端实现
### 4.1 数据模型设计
```java
@Entity
@Table(name = "video_progress")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoProgress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String userId;
@Column(nullable = false)
private String videoId;
@Column(nullable = false)
private Integer progress; // 观看进度(秒)
@Column(nullable = false)
private Integer duration; // 视频总时长(秒)
@Column(nullable = false)
private Double progressPercentage; // 进度百分比
@Column(nullable = false)
@UpdateTimestamp
private LocalDateTime updatedAt;
// 计算进度百分比
public void calculatePercentage() {
if (duration > 0) {
this.progressPercentage = (double) progress / duration * 100;
} else {
this.progressPercentage = 0.0;
}
}
}
// 进度传输对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProgressDTO {
@NotBlank(message = "用户标识不能为空")
private String userId;
@NotBlank(message = "视频标识不能为空")
private String videoId;
@Min(value = 0, message = "进度不能为负数")
private Integer progress;
@Min(value = 1, message = "视频时长无效")
private Integer duration;
}
```
### 4.2 服务层实现
```java
@Service
@Slf4j
public class VideoProgressService {
@Autowired
private VideoProgressRepository progressRepository;
/**
* 保存或更新观看进度
*/
@Transactional
public VideoProgress saveProgress(ProgressDTO progressDTO) {
// 查找现有记录
Optional<VideoProgress> existingProgress = progressRepository
.findByUserIdAndVideoId(progressDTO.getUserId(), progressDTO.getVideoId());
vb.a6v1.cn\gs.a6v1.cn\ij.a6v1.cn
VideoProgress progress;
if (existingProgress.isPresent()) {
// 更新现有记录
progress = existingProgress.get();
// 避免频繁更新:仅当进度差异大于10秒或视频结束时更新
int timeDiff = Math.abs(progressDTO.getProgress() - progress.getProgress());
if (timeDiff < 10 && progressDTO.getProgress() < progressDTO.getDuration()) {
return progress;
}
progress.setProgress(progressDTO.getProgress());
progress.setDuration(progressDTO.getDuration());
} else {
// 创建新记录
progress = new VideoProgress();
progress.setUserId(progressDTO.getUserId());
progress.setVideoId(progressDTO.getVideoId());
progress.setProgress(progressDTO.getProgress());
progress.setDuration(progressDTO.getDuration());
}
// 计算进度百分比
progress.calculatePercentage();
// 保存到数据库
VideoProgress savedProgress = progressRepository.save(progress);
log.info("进度保存成功:用户{},视频{},进度{}秒",
progressDTO.getUserId(), progressDTO.getVideoId(), progressDTO.getProgress());
return savedProgress;
}
/**
* 获取观看进度
*/
public Optional<VideoProgress> getProgress(String userId, String videoId) {
Optional<VideoProgress> progress = progressRepository
.findByUserIdAndVideoId(userId, videoId);
// 如果进度接近视频末尾(95%),则返回0,重新开始
progress.ifPresent(p -> {
if (p.getProgressPercentage() > 95.0) {
p.setProgress(0);
p.calculatePercentage();
}
});
return progress;
}
/**
* 批量获取用户的所有视频进度
*/
public Map<String, Integer> getUserProgresses(String userId, List<String> videoIds) {
List<VideoProgress> progresses = progressRepository
.findByUserIdAndVideoIdIn(userId, videoIds);
return progresses.stream()
.collect(Collectors.toMap(
VideoProgress::getVideoId,
VideoProgress::getProgress
));
}
}
```
### 4.3 控制器层
```java
@RestController
@RequestMapping("/api/video")
@Validated
public class VideoProgressController {
@Autowired
private VideoProgressService progressService;
/**
* 上报观看进度
*/
@PostMapping("/progress")
public ResponseEntity<?> reportProgress(@Valid @RequestBody ProgressDTO progressDTO) {
try {
VideoProgress progress = progressService.saveProgress(progressDTO);
Map<String, Object> response = new HashMap<>();
response.put("code", 200);
response.put("message", "进度保存成功");
response.put("data", Map.of(
"progress", progress.getProgress(),
"percentage", progress.getProgressPercentage()
));
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("进度保存失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("code", 500, "message", "进度保存失败"));
}on.a6v1.cn\pk.a6v1.cn\yt.a6v1.cn
}
/**
* 获取续播位置
*/
@GetMapping("/progress")
public ResponseEntity<?> getProgress(
@RequestParam @NotBlank String userId,
@RequestParam @NotBlank String videoId) {
Optional<VideoProgress> progressOpt = progressService.getProgress(userId, videoId);
Map<String, Object> response = new HashMap<>();
response.put("code", 200);
response.put("message", "获取成功");
if (progressOpt.isPresent()) {
VideoProgress progress = progressOpt.get();
response.put("data", Map.of(
"progress", progress.getProgress(),
"duration", progress.getDuration(),
"percentage", progress.getProgressPercentage(),
"updatedAt", progress.getUpdatedAt()
));
} else {
response.put("data", Map.of("progress", 0));
}
return ResponseEntity.ok(response);
}
/**
* 批量获取视频进度
*/
@GetMapping("/progress/batch")
public ResponseEntity<?> getBatchProgress(
@RequestParam @NotBlank String userId,
@RequestParam List<String> videoIds) {
Map<String, Integer> progresses = progressService
.getUserProgresses(userId, videoIds);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "获取成功",
"data", progresses
));
}
}
```
### 4.4 数据访问层
```java
@Repository
public interface VideoProgressRepository extends JpaRepository<VideoProgress, Long> {
Optional<VideoProgress> findByUserIdAndVideoId(String userId, String videoId);
List<VideoProgress> findByUserIdAndVideoIdIn(String userId, List<String> videoIds);
@Modifying
@Query("DELETE FROM VideoProgress vp WHERE vp.updatedAt < :threshold")
int deleteOldRecords(@Param("threshold") LocalDateTime threshold);
@Query("SELECT vp FROM VideoProgress vp WHERE vp.userId = :userId ORDER BY vp.updatedAt DESC")
Page<VideoProgress> findRecentProgress(
@Param("userId") String userId,
Pageable pageable
);
}
```
## 五、进阶优化方案
### 5.1 性能优化策略
```java
@Service
@Slf4j
public class ProgressCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private VideoProgressService progressService;
private static final String PROGRESS_KEY_PREFIX = "video:progress:";
private static final long CACHE_TTL = 3600; // 1小时
/**
* 获取带缓存的进度
*/
public Optional<Integer> getProgressWithCache(String userId, String videoId) {
String cacheKey = buildCacheKey(userId, videoId);
try {
// 尝试从缓存读取
Object cachedProgress = redisTemplate.opsForValue().get(cacheKey);
if (cachedProgress != null) {
return Optional.of((Integer) cachedProgress);
}
// 缓存未命中,查询数据库
Optional<VideoProgress> progressOpt = progressService
.getProgress(userId, videoId);
if (progressOpt.isPresent()) {
Integer progress = progressOpt.get().getProgress();
// 写入缓存
redisTemplate.opsForValue().set(
cacheKey,
progress,
CACHE_TTL,
TimeUnit.SECONDS
);
return Optional.of(progress);
}
} catch (Exception e) {
log.warn("缓存读取失败,降级到数据库查询", e);
return progressService.getProgress(userId, videoId)
.map(VideoProgress::getProgress);
}
return Optional.empty();
}
private String buildCacheKey(String userId, String videoId) {
return PROGRESS_KEY_PREFIX + userId + ":" + videoId;
}
}
```
### 5.2 数据清理任务
```java
@Component
@Slf4j
public class ProgressCleanupTask {
@Autowired
private VideoProgressRepository progressRepository;
/**
* 每天凌晨清理90天前的进度记录
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void cleanupOldProgress() {
LocalDateTime threshold = LocalDateTime.now().minusDays(90);
try {
int deletedCount = progressRepository.deleteOldRecords(threshold);
log.info("清理进度记录完成,共删除{}条数据", deletedCount);
} catch (Exception e) {
log.error("清理进度记录失败", e);
}
}
}
```
## 六、总结与展望
视频续播功能通过合理的前后端协作,为用户提供流畅的观看体验。本文介绍了从进度采集、上报到存储、查询的完整实现方案。实际应用中还可以考虑以下扩展方向:
1. **多设备同步**:允许用户在不同设备间同步观看进度
2. **智能续播**:根据视频类型和用户习惯调整续播策略
3. **分布式存储**:对海量用户进度数据采用分布式存储方案
4. **进度分享**:允许用户分享特定时间点的视频内容
通过持续优化和改进,视频续播功能能够成为提升用户黏性的重要特性,为用户创造更加个性化、便捷的视频观看体验。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/19545/viewspace-3105006/ ,如需转载,请注明出处,否则将追究法律责任。