Java 视频在线播放功能教程,包含播放历史记录功能。使用 Spring Boot + Vue.js 技术栈,实现一个简单的视频播放系统。
功能点
- 断点续播 - 自动记录上次观看位置,支持继续观看提示
- 多设备同步 - 通过 WebSocket 实现播放进度实时同步
- 观看历史 - 完整的观看记录管理,支持进度可视化
- 智能完成检测 - 观看 95% 自动标记为已完成
技术架构
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端 (Vue3) │ ←→ │ Spring Boot │ ←→ │ MySQL/Redis │
│ Video.js播放器 │ │ REST API │ │ 视频元数据/进度 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
↓
┌─────────────────┐
│ 视频存储 (OSS) │
│ 或本地文件系统 │
└─────────────────┘
- 高性能 - Redis 缓存 + 异步批量写入 MySQL,支持高并发
- 防抖保存 - 前端 500ms 防抖,避免频繁请求
- 乐观锁 - 数据库版本号控制,防止并发更新冲突
- 断点续传 - HTTP 206 支持视频拖动跳转
环境准备
- 后端
- Spring Boot :3.2.0、MyBatis-Plus :3.5.5、MySQL Connector :8.2.0、JDK :17、Redis Server:7.2.x 或 7.4.x
- 前端
- Vue :3.3.8、Vue Router :4.2.5、Vite :5.0.0、Axios :1.6.2、Node.js:18.x+
1. 后端实现 (Spring Boot)
完整项目结构
video-player-mp/
├── src/main/java/com/example/videoplayer/
│ ├── VideoPlayerApplication.java
│ ├── config/
│ │ ├── MyBatisPlusConfig.java
│ │ ├── WebConfig.java
│ │ └── WebSocketConfig.java
│ ├── controller/
│ │ ├── VideoController.java
│ │ └── VideoProgressController.java
│ ├── dto/
│ │ ├── ProgressUpdateRequest.java
│ │ ├── VideoHistoryDTO.java
│ │ └── ApiResponse.java
│ ├── entity/
│ │ ├── Video.java
│ │ └── VideoProgress.java
│ ├── handler/
│ │ └── MyMetaObjectHandler.java
│ ├── interceptor/
│ │ └── AuthInterceptor.java
│ ├── mapper/
│ │ ├── VideoMapper.java
│ │ └── VideoProgressMapper.java
│ ├── service/
│ │ ├── VideoService.java
│ │ └── VideoProgressService.java
│ └── websocket/
│ └── VideoProgressWebSocketHandler.java
├── src/main/resources/
│ ├── application.yml
│ ├── mapper/
│ │ └── VideoProgressMapper.xml
│ └── static/ (前端文件)
└── pom.xml
项目依赖 (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>video-player-mp</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis-Plus 分页插件(必须) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 连接池(推荐HikariCP,SpringBoot默认) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
yaml配置
# resources/application.yml
server:
port: 8080
spring:
application:
name: video-player-mp
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/video_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: your_password
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
redis:
host: localhost
port: 6379
database: 0
timeout: 5000
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
cache-enabled: true
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.example.videoplayer.entity
video:
storage:
path: /var/www/videos
logging:
level:
com.example.videoplayer: DEBUG
1.1 启动类与配置
// VideoPlayerApplication.java
package com.example.videoplayer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@MapperScan("com.example.videoplayer.mapper")
@EnableCaching
@EnableScheduling
public class VideoPlayerApplication {
public static void main(String[] args) {
SpringApplication.run(VideoPlayerApplication.class, args);
}
}
// config/MyBatisPlusConfig.java
package com.example.videoplayer.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableTransactionManagement
@MapperScan("com.example.videoplayer.mapper")
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件(必须放在最前面)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件(并发更新播放进度时用)
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
// config/WebConfig.java
package com.example.videoplayer.config;
import com.example.videoplayer.interceptor.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/api/videos/stream/**", "/api/auth/**");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:${video.storage.path}/");
}
}
// config/WebSocketConfig.java
package com.example.videoplayer.config;
import com.example.videoplayer.websocket.VideoProgressWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new VideoProgressWebSocketHandler(), "/ws/video-progress")
.setAllowedOrigins("*");
}
}
1.2 实体类
// entity/Video.java
package com.example.videoplayer.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("videos")
public class Video {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("title")
private String title;
@TableField("description")
private String description;
@TableField("file_path")
private String filePath;
@TableField("thumbnail_url")
private String thumbnailUrl;
@TableField("duration")
private Long duration;
@TableField("resolution")
private String resolution;
@TableField("status")
private VideoStatus status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableLogic
@TableField("deleted")
private Integer deleted;
public enum VideoStatus {
ACTIVE, INACTIVE, DELETED
}
}
// entity/VideoProgress.java
package com.example.videoplayer.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@TableName("video_progress")
public class VideoProgress {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("user_id")
private Long userId;
@TableField("video_id")
private Long videoId;
@TableField("current_time")
private Long currentTime;
@TableField("total_duration")
private Long totalDuration;
@TableField("completed")
private Boolean completed;
@TableField("watch_count")
private Integer watchCount;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("last_watched_at")
private LocalDateTime lastWatchedAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
// 乐观锁版本号(防止并发更新冲突)
@Version
@TableField("version")
private Integer version;
// 扩展参数(关联查询时存放额外字段)
@TableField(exist = false)
private Map<String, Object> param;
// 计算观看百分比
public Integer getProgressPercentage() {
if (totalDuration == null || totalDuration == 0) return 0;
return (int) ((currentTime * 100) / totalDuration);
}
}
1.3 DTO 类
// dto/ProgressUpdateRequest.java
package com.example.videoplayer.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import lombok.Data;
@Data
public class ProgressUpdateRequest {
@NotNull(message = "视频ID不能为空")
private Long videoId;
@NotNull(message = "当前播放时间不能为空")
@Min(value = 0, message = "播放时间不能为负数")
private Long currentTime;
@Min(value = 0, message = "总时长不能为负数")
private Long duration;
private Boolean completed = false;
}
// dto/VideoHistoryDTO.java
package com.example.videoplayer.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class VideoHistoryDTO {
private Long videoId;
private String title;
private String thumbnailUrl;
private Long currentTime;
private Long duration;
private Integer progressPercentage;
private Boolean completed;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastWatchedAt;
private String formattedTime;
private String lastWatchedText;
private String filePath;
}
// dto/ApiResponse.java
package com.example.videoplayer.dto;
import lombok.Data;
@Data
public class ApiResponse<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public ApiResponse() {
this.timestamp = System.currentTimeMillis();
}
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(500);
response.setMessage(message);
return response;
}
public static <T> ApiResponse<T> error(Integer code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
}
1.4 自动填充处理器
// handler/MyMetaObjectHandler.java
package com.example.videoplayer.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.debug("开始插入填充...");
LocalDateTime now = LocalDateTime.now();
// 创建时间
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
// 更新时间
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
// 最后观看时间
this.strictInsertFill(metaObject, "lastWatchedAt", LocalDateTime.class, now);
// 观看次数
this.strictInsertFill(metaObject, "watchCount", Integer.class, 1);
// 是否完成
this.strictInsertFill(metaObject, "completed", Boolean.class, false);
// 版本号(乐观锁)
this.strictInsertFill(metaObject, "version", Integer.class, 1);
}
@Override
public void updateFill(MetaObject metaObject) {
log.debug("开始更新填充...");
LocalDateTime now = LocalDateTime.now();
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, now);
this.strictUpdateFill(metaObject, "lastWatchedAt", LocalDateTime.class, now);
}
}
1.5 Mapper 接口
// mapper/VideoMapper.java
package com.example.videoplayer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.videoplayer.entity.Video;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface VideoMapper extends BaseMapper<Video> {
/**
* 自定义搜索分页(带视频统计信息)
*/
@Select("SELECT v.*, " +
"(SELECT COUNT(*) FROM video_progress WHERE video_id = v.id) as play_count " +
"FROM videos v " +
"WHERE v.deleted = 0 AND v.status = 'ACTIVE' AND " +
"(v.title LIKE CONCAT('%', #{keyword}, '%') OR v.description LIKE CONCAT('%', #{keyword}, '%')) " +
"ORDER BY v.created_at DESC")
IPage<Video> searchVideos(Page<Video> page, @Param("keyword") String keyword);
/**
* 查询热门视频(按观看次数排序)
*/
@Select("SELECT v.*, COUNT(vp.id) as view_count " +
"FROM videos v " +
"LEFT JOIN video_progress vp ON v.id = vp.video_id " +
"WHERE v.deleted = 0 AND v.status = 'ACTIVE' " +
"GROUP BY v.id " +
"ORDER BY view_count DESC " +
"LIMIT #{limit}")
List<Video> selectHotVideos(@Param("limit") int limit);
/**
* 增加播放次数(原子更新)
*/
@Update("UPDATE videos SET play_count = play_count + 1 WHERE id = #{videoId}")
int incrementPlayCount(@Param("videoId") Long videoId);
/**
* 根据ID列表批量查询
*/
List<Video> selectByIds(@Param("ids") List<Long> ids);
}
// mapper/VideoProgressMapper.java
package com.example.videoplayer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.videoplayer.entity.VideoProgress;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface VideoProgressMapper extends BaseMapper<VideoProgress> {
/**
* 查询用户的特定视频进度(带视频信息)
*/
@Select("SELECT vp.*, v.title, v.thumbnail_url, v.duration as video_duration, v.file_path " +
"FROM video_progress vp " +
"LEFT JOIN videos v ON vp.video_id = v.id " +
"WHERE vp.user_id = #{userId} AND vp.video_id = #{videoId}")
VideoProgress selectProgressWithVideo(@Param("userId") Long userId,
@Param("videoId") Long videoId);
/**
* 获取用户观看历史(分页,带视频信息)
*/
@Select("SELECT vp.*, v.title, v.thumbnail_url, v.file_path " +
"FROM video_progress vp " +
"LEFT JOIN videos v ON vp.video_id = v.id " +
"WHERE vp.user_id = #{userId} " +
"ORDER BY vp.last_watched_at DESC")
IPage<VideoProgress> selectHistoryPage(Page<VideoProgress> page,
@Param("userId") Long userId);
/**
* 获取最近观看的视频ID列表
*/
@Select("SELECT video_id FROM video_progress " +
"WHERE user_id = #{userId} " +
"ORDER BY last_watched_at DESC " +
"LIMIT #{limit}")
List<Long> selectRecentVideoIds(@Param("userId") Long userId,
@Param("limit") int limit);
/**
* 原子性更新进度(使用乐观锁)
*/
@Update("UPDATE video_progress " +
"SET current_time = #{currentTime}, " +
" last_watched_at = #{lastWatchedAt}, " +
" watch_count = watch_count + 1, " +
" completed = #{completed}, " +
" version = version + 1 " +
"WHERE user_id = #{userId} AND video_id = #{videoId}")
int updateProgressAtomic(@Param("userId") Long userId,
@Param("videoId") Long videoId,
@Param("currentTime") Long currentTime,
@Param("lastWatchedAt") LocalDateTime lastWatchedAt,
@Param("completed") Boolean completed);
/**
* 获取未完成的最近观看记录(继续观看功能)
*/
@Select("SELECT vp.*, v.title, v.thumbnail_url, v.file_path " +
"FROM video_progress vp " +
"LEFT JOIN videos v ON vp.video_id = v.id " +
"WHERE vp.user_id = #{userId} " +
"AND vp.completed = false " +
"AND vp.current_time > 10 " +
"ORDER BY vp.last_watched_at DESC " +
"LIMIT 1")
VideoProgress selectContinueWatching(@Param("userId") Long userId);
/**
* 批量插入或更新(MySQL ON DUPLICATE KEY UPDATE)
*/
int upsertBatch(@Param("list") List<VideoProgress> list);
/**
* 获取用户观看统计
*/
@Select("SELECT " +
"COUNT(*) as total_count, " +
"SUM(CASE WHEN completed = true THEN 1 ELSE 0 END) as completed_count, " +
"SUM(current_time) as total_watch_time " +
"FROM video_progress " +
"WHERE user_id = #{userId}")
Map<String, Object> selectUserStats(@Param("userId") Long userId);
}
1.6 XML Mapper
<!-- resources/mapper/VideoMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.videoplayer.mapper.VideoMapper">
<select id="selectByIds" resultType="com.example.videoplayer.entity.Video">
SELECT * FROM videos
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND deleted = 0
</select>
</mapper>
<!-- resources/mapper/VideoProgressMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.videoplayer.mapper.VideoProgressMapper">
<!-- 批量插入或更新(MySQL语法) -->
<insert id="upsertBatch">
INSERT INTO video_progress
(user_id, video_id, current_time, total_duration, completed, watch_count, last_watched_at, created_at, version)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.videoId}, #{item.currentTime}, #{item.totalDuration},
#{item.completed}, #{item.watchCount}, #{item.lastWatchedAt}, #{item.createdAt}, #{item.version})
</foreach>
ON DUPLICATE KEY UPDATE
current_time = VALUES(current_time),
total_duration = VALUES(total_duration),
completed = VALUES(completed),
watch_count = video_progress.watch_count + 1,
last_watched_at = VALUES(last_watched_at),
version = video_progress.version + 1
</insert>
<!-- 复杂条件查询示例 -->
<select id="selectByComplexCondition" resultType="com.example.videoplayer.entity.VideoProgress">
SELECT * FROM video_progress
<where>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="completed != null">
AND completed = #{completed}
</if>
<if test="startTime != null">
AND last_watched_at >= #{startTime}
</if>
<if test="endTime != null">
AND last_watched_at <= #{endTime}
</if>
</where>
ORDER BY last_watched_at DESC
</select>
</mapper>
1.7 Service 层
// service/VideoService.java
package com.example.videoplayer.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.mapper.VideoMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoService extends ServiceImpl<VideoMapper, Video> {
@Value("${video.storage.path:/tmp/videos}")
private String videoStoragePath;
private final VideoMapper videoMapper;
/**
* 获取视频列表(MP分页)
*/
public IPage<Video> getVideoList(long current, long size) {
Page<Video> page = new Page<>(current, size);
LambdaQueryWrapper<Video> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Video::getStatus, Video.VideoStatus.ACTIVE)
.eq(Video::getDeleted, 0)
.orderByDesc(Video::getCreatedAt);
return videoMapper.selectPage(page, wrapper);
}
/**
* 搜索视频(自定义SQL分页)
*/
public IPage<Video> searchVideos(String keyword, long current, long size) {
Page<Video> page = new Page<>(current, size);
return videoMapper.searchVideos(page, keyword);
}
/**
* 获取视频详情
*/
public Video getVideoById(Long id) {
Video video = videoMapper.selectById(id);
if (video == null || video.getDeleted() == 1) {
throw new RuntimeException("视频不存在或已被删除");
}
return video;
}
/**
* 获取热门视频
*/
public List<Video> getHotVideos(int limit) {
return videoMapper.selectHotVideos(limit);
}
/**
* 上传视频
*/
public Video uploadVideo(MultipartFile file, String title, String description) {
try {
// 确保目录存在
Path storagePath = Paths.get(videoStoragePath);
if (!Files.exists(storagePath)) {
Files.createDirectories(storagePath);
}
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename != null ?
originalFilename.substring(originalFilename.lastIndexOf(".")) : ".mp4";
String filename = UUID.randomUUID().toString() + extension;
// 保存文件
Path targetPath = storagePath.resolve(filename);
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
// 创建视频记录
Video video = new Video();
video.setTitle(title);
video.setDescription(description);
video.setFilePath(filename);
video.setStatus(Video.VideoStatus.ACTIVE);
// TODO: 使用FFmpeg获取视频时长和生成缩略图
videoMapper.insert(video);
return video;
} catch (IOException e) {
log.error("视频上传失败", e);
throw new RuntimeException("视频上传失败: " + e.getMessage());
}
}
/**
* 获取视频资源(支持断点续传)
*/
public Resource loadVideoAsResource(String filename) {
try {
Path filePath = Paths.get(videoStoragePath).resolve(filename).normalize();
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw new RuntimeException("视频文件不存在或无法读取: " + filename);
}
} catch (MalformedURLException e) {
throw new RuntimeException("视频文件路径错误: " + filename, e);
}
}
/**
* 更新视频信息
*/
public boolean updateVideo(Video video) {
return videoMapper.updateById(video) > 0;
}
/**
* 删除视频(逻辑删除,MP自动处理@TableLogic)
*/
public boolean deleteVideo(Long id) {
return videoMapper.deleteById(id) > 0;
}
/**
* 批量删除
*/
public boolean deleteBatch(List<Long> ids) {
return videoMapper.deleteBatchIds(ids) > 0;
}
}
// service/VideoProgressService.java
package com.example.videoplayer.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.videoplayer.dto.ProgressUpdateRequest;
import com.example.videoplayer.dto.VideoHistoryDTO;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.entity.VideoProgress;
import com.example.videoplayer.mapper.VideoMapper;
import com.example.videoplayer.mapper.VideoProgressMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoProgressService extends ServiceImpl<VideoProgressMapper, VideoProgress> {
private final VideoProgressMapper progressMapper;
private final VideoMapper videoMapper;
private final RedisTemplate<String, Object> redisTemplate;
private static final String PROGRESS_KEY_PREFIX = "video:progress:";
private static final String PROGRESS_SYNC_KEY = "video:progress:sync:queue";
private static final long CACHE_TTL = 7;
/**
* 获取播放进度(优先Redis)
*/
public VideoProgress getProgress(Long userId, Long videoId) {
String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
// 1. 查Redis
@SuppressWarnings("unchecked")
VideoProgress cached = (VideoProgress) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.debug("从缓存获取进度: user={}, video={}", userId, videoId);
return cached;
}
// 2. 查数据库(带视频信息)
VideoProgress progress = progressMapper.selectProgressWithVideo(userId, videoId);
if (progress != null) {
redisTemplate.opsForValue().set(cacheKey, progress, CACHE_TTL, TimeUnit.DAYS);
}
return progress;
}
/**
* 更新播放进度(双写策略)
*/
@Transactional(rollbackFor = Exception.class)
public void updateProgress(Long userId, ProgressUpdateRequest request) {
Long videoId = request.getVideoId();
Long currentTime = request.getCurrentTime();
Long duration = request.getDuration();
// 判断是否看完(95%进度或收到completed标记)
boolean isCompleted = request.getCompleted() ||
(duration != null && currentTime >= duration * 0.95);
String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
// 1. 更新Redis(高频操作,保证性能)
VideoProgress cacheProgress = new VideoProgress();
cacheProgress.setUserId(userId);
cacheProgress.setVideoId(videoId);
cacheProgress.setCurrentTime(currentTime);
cacheProgress.setTotalDuration(duration);
cacheProgress.setCompleted(isCompleted);
cacheProgress.setLastWatchedAt(LocalDateTime.now());
cacheProgress.setVersion(1);
redisTemplate.opsForValue().set(cacheKey, cacheProgress, CACHE_TTL, TimeUnit.DAYS);
// 2. 加入同步队列(延迟写入MySQL)
redisTemplate.opsForSet().add(PROGRESS_SYNC_KEY, cacheKey);
// 3. 立即同步到数据库(关键节点:暂停、完成、每30秒)
if (isCompleted || request.getCompleted() != null) {
syncToDatabase(userId, videoId, currentTime, duration, isCompleted);
}
log.info("更新播放进度: user={}, video={}, time={}, completed={}",
userId, videoId, currentTime, isCompleted);
}
/**
* 同步到数据库
*/
@Transactional
protected void syncToDatabase(Long userId, Long videoId, Long currentTime,
Long duration, boolean isCompleted) {
// 尝试原子更新
int updated = progressMapper.updateProgressAtomic(
userId, videoId, currentTime, LocalDateTime.now(), isCompleted
);
// 如果没有记录,插入新记录
if (updated == 0) {
VideoProgress newProgress = new VideoProgress();
newProgress.setUserId(userId);
newProgress.setVideoId(videoId);
newProgress.setCurrentTime(currentTime);
newProgress.setTotalDuration(duration);
newProgress.setCompleted(isCompleted);
// created_at, last_watched_at, watch_count由自动填充处理
try {
progressMapper.insert(newProgress);
} catch (Exception e) {
// 如果插入失败(并发情况下可能已存在),再尝试更新
progressMapper.updateProgressAtomic(
userId, videoId, currentTime, LocalDateTime.now(), isCompleted
);
}
}
}
/**
* 获取观看历史(MP分页)
*/
public IPage<VideoHistoryDTO> getUserHistory(Long userId, long current, long size) {
// 先同步该用户的Redis数据到数据库(保证数据一致性)
syncUserProgressToDb(userId);
Page<VideoProgress> page = new Page<>(current, size);
IPage<VideoProgress> progressPage = progressMapper.selectHistoryPage(page, userId);
// 转换为DTO
List<VideoHistoryDTO> records = progressPage.getRecords().stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
// 构建新的分页对象
Page<VideoHistoryDTO> resultPage = new Page<>();
resultPage.setCurrent(progressPage.getCurrent());
resultPage.setSize(progressPage.getSize());
resultPage.setTotal(progressPage.getTotal());
resultPage.setPages(progressPage.getPages());
resultPage.setRecords(records);
return resultPage;
}
/**
* 继续观看
*/
public VideoHistoryDTO getContinueWatching(Long userId) {
// 先同步
syncUserProgressToDb(userId);
VideoProgress progress = progressMapper.selectContinueWatching(userId);
return progress != null ? convertToDTO(progress) : null;
}
/**
* 删除单条记录
*/
@Transactional
public boolean deleteProgress(Long userId, Long videoId) {
// 1. 删Redis
String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
redisTemplate.delete(cacheKey);
// 2. 删数据库
LambdaQueryWrapper<VideoProgress> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(VideoProgress::getUserId, userId)
.eq(VideoProgress::getVideoId, videoId);
return progressMapper.delete(wrapper) > 0;
}
/**
* 清空用户历史
*/
@Transactional
public boolean clearUserHistory(Long userId) {
// 1. 清理Redis
String pattern = PROGRESS_KEY_PREFIX + userId + ":*";
// 使用keys扫描(生产环境建议用scan)
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
// 2. 删数据库
LambdaQueryWrapper<VideoProgress> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(VideoProgress::getUserId, userId);
return progressMapper.delete(wrapper) > 0;
}
/**
* 获取用户观看统计
*/
public UserWatchStats getUserStats(Long userId) {
// 先同步
syncUserProgressToDb(userId);
Map<String, Object> stats = progressMapper.selectUserStats(userId);
UserWatchStats result = new UserWatchStats();
if (stats != null) {
result.setTotalWatched(((Number) stats.get("total_count")).longValue());
result.setCompletedCount(((Number) stats.get("completed_count")).longValue());
result.setTotalWatchTime(((Number) stats.get("total_watch_time")).longValue());
long total = result.getTotalWatched();
result.setCompletionRate(total > 0 ? (int)(result.getCompletedCount() * 100 / total) : 0);
}
return result;
}
/**
* 定时任务:批量同步Redis数据到MySQL(每5分钟)
*/
@Scheduled(fixedDelay = 300000) // 5分钟
@Transactional
public void batchSyncToDatabase() {
log.info("开始批量同步播放进度到数据库...");
Set<String> keys = redisTemplate.opsForSet().members(PROGRESS_SYNC_KEY);
if (keys == null || keys.isEmpty()) {
return;
}
List<VideoProgress> list = new ArrayList<>();
for (String key : keys) {
VideoProgress progress = (VideoProgress) redisTemplate.opsForValue().get(key);
if (progress != null) {
list.add(progress);
}
}
if (!list.isEmpty()) {
// 使用批量upsert
progressMapper.upsertBatch(list);
log.info("批量同步完成,共{}条记录", list.size());
}
// 清空同步队列
redisTemplate.delete(PROGRESS_SYNC_KEY);
}
/**
* 同步指定用户的进度到数据库
*/
private void syncUserProgressToDb(Long userId) {
String pattern = PROGRESS_KEY_PREFIX + userId + ":*";
Set<String> keys = redisTemplate.keys(pattern);
if (keys == null || keys.isEmpty()) return;
for (String key : keys) {
VideoProgress progress = (VideoProgress) redisTemplate.opsForValue().get(key);
if (progress != null) {
syncToDatabase(
progress.getUserId(),
progress.getVideoId(),
progress.getCurrentTime(),
progress.getTotalDuration(),
progress.getCompleted()
);
}
}
}
/**
* 转换为DTO
*/
private VideoHistoryDTO convertToDTO(VideoProgress progress) {
VideoHistoryDTO dto = new VideoHistoryDTO();
dto.setVideoId(progress.getVideoId());
dto.setCurrentTime(progress.getCurrentTime());
dto.setDuration(progress.getTotalDuration());
dto.setProgressPercentage(progress.getProgressPercentage());
dto.setCompleted(progress.getCompleted());
dto.setLastWatchedAt(progress.getLastWatchedAt());
dto.setFormattedTime(formatDuration(progress.getCurrentTime()));
dto.setLastWatchedText(formatRelativeTime(progress.getLastWatchedAt()));
// 从param中获取关联的视频信息(如果使用了selectProgressWithVideo)
if (progress.getParam() != null) {
dto.setTitle((String) progress.getParam().get("title"));
dto.setThumbnailUrl((String) progress.getParam().get("thumbnail_url"));
dto.setFilePath((String) progress.getParam().get("file_path"));
}
return dto;
}
private String formatDuration(Long seconds) {
if (seconds == null) return "00:00";
long hours = seconds / 3600;
long minutes = (seconds % 3600) / 60;
long secs = seconds % 60;
if (hours > 0) {
return String.format("%02d:%02d:%02d", hours, minutes, secs);
}
return String.format("%02d:%02d", minutes, secs);
}
private String formatRelativeTime(LocalDateTime time) {
if (time == null) return "未知";
long minutes = ChronoUnit.MINUTES.between(time, LocalDateTime.now());
if (minutes < 1) return "刚刚";
if (minutes < 60) return minutes + "分钟前";
long hours = ChronoUnit.HOURS.between(time, LocalDateTime.now());
if (hours < 24) return hours + "小时前";
long days = ChronoUnit.DAYS.between(time, LocalDateTime.now());
if (days < 30) return days + "天前";
return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
@Data
public static class UserWatchStats {
private long totalWatched;
private long completedCount;
private long totalWatchTime;
private int completionRate;
}
}
1.8 Controller 层
// controller/VideoController.java
package com.example.videoplayer.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.videoplayer.dto.ApiResponse;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.service.VideoService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoController {
private final VideoService videoService;
/**
* 获取视频列表(MP分页,current从1开始)
*/
@GetMapping
public ApiResponse<IPage<Video>> listVideos(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "12") long size) {
IPage<Video> page = videoService.getVideoList(current, size);
return ApiResponse.success(page);
}
/**
* 获取视频详情
*/
@GetMapping("/{id}")
public ApiResponse<Video> getVideo(@PathVariable Long id) {
Video video = videoService.getVideoById(id);
return ApiResponse.success(video);
}
/**
* 搜索视频(MP分页)
*/
@GetMapping("/search")
public ApiResponse<IPage<Video>> searchVideos(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "12") long size) {
IPage<Video> page = videoService.searchVideos(keyword, current, size);
return ApiResponse.success(page);
}
/**
* 获取热门视频
*/
@GetMapping("/hot")
public ApiResponse<List<Video>> getHotVideos(
@RequestParam(defaultValue = "10") int limit) {
List<Video> videos = videoService.getHotVideos(limit);
return ApiResponse.success(videos);
}
/**
* 流式播放视频(支持断点续传)
*/
@GetMapping("/stream/{filename}")
public ResponseEntity<Resource> streamVideo(
@PathVariable String filename,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
Resource videoResource = videoService.loadVideoAsResource(filename);
// 处理Range请求(断点续传)
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
if (rangeHeader != null) {
return handlePartialContent(videoResource, rangeHeader, request, response);
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "video/mp4")
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.body(videoResource);
}
/**
* 上传视频
*/
@PostMapping("/upload")
public ApiResponse<Video> uploadVideo(
@RequestParam("file") MultipartFile file,
@RequestParam("title") String title,
@RequestParam(value = "description", required = false) String description) {
Video video = videoService.uploadVideo(file, title, description);
return ApiResponse.success(video);
}
/**
* 更新视频信息
*/
@PutMapping("/{id}")
public ApiResponse<Boolean> updateVideo(@PathVariable Long id, @RequestBody Video video) {
video.setId(id);
boolean success = videoService.updateVideo(video);
return ApiResponse.success(success);
}
/**
* 删除视频(逻辑删除)
*/
@DeleteMapping("/{id}")
public ApiResponse<Boolean> deleteVideo(@PathVariable Long id) {
boolean success = videoService.deleteVideo(id);
return ApiResponse.success(success);
}
/**
* 处理断点续传(HTTP 206)
*/
private ResponseEntity<Resource> handlePartialContent(
Resource resource, String rangeHeader,
HttpServletRequest request, HttpServletResponse response) throws IOException {
// 简化实现,实际生产环境需要完整实现Range解析
long fileSize = resource.contentLength();
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
long start = Long.parseLong(ranges[0]);
long end = ranges.length > 1 && !ranges[1].isEmpty() ?
Long.parseLong(ranges[1]) : fileSize - 1;
long contentLength = end - start + 1;
response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
response.setHeader(HttpHeaders.CONTENT_TYPE, "video/mp4");
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
response.setHeader(HttpHeaders.CONTENT_RANGE,
String.format("bytes %d-%d/%d", start, end, fileSize));
// 实际实现需要使用RandomAccessFile或InputStream.skip()
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.body(resource);
}
}
// controller/VideoProgressController.java
package com.example.videoplayer.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.videoplayer.dto.ApiResponse;
import com.example.videoplayer.dto.ProgressUpdateRequest;
import com.example.videoplayer.dto.VideoHistoryDTO;
import com.example.videoplayer.entity.VideoProgress;
import com.example.videoplayer.service.VideoProgressService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/progress")
@RequiredArgsConstructor
public class VideoProgressController {
private final VideoProgressService progressService;
/**
* 获取当前视频的播放进度
*/
@GetMapping("/{videoId}")
public ApiResponse<Map<String, Object>> getProgress(
@RequestAttribute("userId") Long userId,
@PathVariable Long videoId) {
VideoProgress progress = progressService.getProgress(userId, videoId);
Map<String, Object> response = new HashMap<>();
if (progress != null) {
response.put("currentTime", progress.getCurrentTime());
response.put("duration", progress.getTotalDuration());
response.put("percentage", progress.getProgressPercentage());
response.put("completed", progress.getCompleted());
response.put("lastWatchedAt", progress.getLastWatchedAt());
response.put("watchCount", progress.getWatchCount());
} else {
response.put("currentTime", 0);
response.put("percentage", 0);
response.put("completed", false);
}
return ApiResponse.success(response);
}
/**
* 更新播放进度(心跳包,每5-10秒调用一次)
*/
@PostMapping("/update")
public ApiResponse<Void> updateProgress(
@RequestAttribute("userId") Long userId,
@Valid @RequestBody ProgressUpdateRequest request) {
progressService.updateProgress(userId, request);
return ApiResponse.success(null);
}
/**
* 获取观看历史(MP分页,current从1开始)
*/
@GetMapping("/history")
public ApiResponse<IPage<VideoHistoryDTO>> getHistory(
@RequestAttribute("userId") Long userId,
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "20") long size) {
IPage<VideoHistoryDTO> page = progressService.getUserHistory(userId, current, size);
return ApiResponse.success(page);
}
/**
* 获取"继续观看"(上次未看完的视频)
*/
@GetMapping("/continue")
public ApiResponse<VideoHistoryDTO> getContinueWatching(
@RequestAttribute("userId") Long userId) {
VideoHistoryDTO dto = progressService.getContinueWatching(userId);
return ApiResponse.success(dto);
}
/**
* 标记为已完成
*/
@PostMapping("/complete/{videoId}")
public ApiResponse<Void> markCompleted(
@RequestAttribute("userId") Long userId,
@PathVariable Long videoId) {
ProgressUpdateRequest request = new ProgressUpdateRequest();
request.setVideoId(videoId);
request.setCurrentTime(999999L); // 大数字确保被视为完成
request.setCompleted(true);
progressService.updateProgress(userId, request);
return ApiResponse.success(null);
}
/**
* 删除单条历史记录
*/
@DeleteMapping("/{videoId}")
public ApiResponse<Boolean> deleteProgress(
@RequestAttribute("userId") Long userId,
@PathVariable Long videoId) {
boolean success = progressService.deleteProgress(userId, videoId);
return ApiResponse.success(success);
}
/**
* 清空所有历史记录
*/
@DeleteMapping("/history")
public ApiResponse<Boolean> clearHistory(
@RequestAttribute("userId") Long userId) {
boolean success = progressService.clearUserHistory(userId);
return ApiResponse.success(success);
}
/**
* 获取用户观看统计
*/
@GetMapping("/stats")
public ApiResponse<VideoProgressService.UserWatchStats> getStats(
@RequestAttribute("userId") Long userId) {
VideoProgressService.UserWatchStats stats = progressService.getUserStats(userId);
return ApiResponse.success(stats);
}
}
1.9 拦截器与 WebSocket
// interceptor/AuthInterceptor.java
package com.example.videoplayer.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 简化版:从Header获取userId,实际应使用JWT解析
String userIdStr = request.getHeader("X-User-Id");
if (userIdStr == null) {
// 开发测试用默认值
userIdStr = "1";
}
try {
Long userId = Long.parseLong(userIdStr);
request.setAttribute("userId", userId);
return true;
} catch (NumberFormatException e) {
response.setStatus(401);
return false;
}
}
}
// websocket/VideoProgressWebSocketHandler.java
package com.example.videoplayer.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
public class VideoProgressWebSocketHandler extends TextWebSocketHandler {
// 存储用户会话:userId -> session
private static final Map<Long, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Long userId = getUserIdFromSession(session);
if (userId != null) {
sessions.put(userId, session);
log.info("WebSocket连接建立: userId={}", userId);
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 接收实时进度更新,广播到其他设备
Long userId = getUserIdFromSession(session);
if (userId == null) return;
String payload = message.getPayload();
log.debug("收到进度更新: userId={}, data={}", userId, payload);
// 解析消息
Map<String, Object> data = objectMapper.readValue(payload, Map.class);
// 广播给该用户的其他设备(实现多设备同步)
broadcastToUserDevices(userId, payload, session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
Long userId = getUserIdFromSession(session);
if (userId != null) {
sessions.remove(userId);
log.info("WebSocket连接关闭: userId={}", userId);
}
}
/**
* 广播给用户的所有设备(除了发送者)
*/
private void broadcastToUserDevices(Long userId, String message, WebSocketSession excludeSession) {
// 实际实现中,一个用户可能有多个设备,需要更复杂的session管理
// 这里简化处理
WebSocketSession targetSession = sessions.get(userId);
if (targetSession != null && targetSession.isOpen() && targetSession != excludeSession) {
try {
targetSession.sendMessage(new TextMessage(message));
} catch (IOException e) {
log.error("WebSocket广播失败", e);
}
}
}
private Long getUserIdFromSession(WebSocketSession session) {
// 从URL参数或header获取userId
String query = session.getUri().getQuery();
if (query != null && query.contains("userId=")) {
String userIdStr = query.split("userId=")[1].split("&")[0];
return Long.parseLong(userIdStr);
}
return null;
}
}
2. 前端实现(Vue 3 + Vite)
项目结构
video-player-frontend/
├── src/
│ ├── api/
│ │ ├── request.js
│ │ ├── video.js
│ │ └── progress.js
│ ├── components/
│ │ ├── VideoPlayer.vue # 视频播放器组件
│ │ ├── VideoCard.vue # 视频卡片组件
│ │ ├── HistoryList.vue # 历史记录列表
│ │ └── ContinueWatchingCard.vue # 继续观看卡片
│ ├── composables/
│ │ └── useVideoProgress.js # 播放进度组合式函数
│ ├── views/
│ │ ├── Home.vue # 首页
│ │ ├── VideoDetail.vue # 视频详情页
│ │ ├── History.vue # 观看历史页
│ │ └── Search.vue # 搜索结果页
│ ├── router/
│ │ └── index.js
│ ├── utils/
│ │ └── format.js # 格式化工具
│ ├── App.vue
│ └── main.js
├── public/
├── package.json
└── vite.config.js
2.1 基础配置
// src/api/request.js
import axios from 'axios'
const request = axios.create({
baseURL: 'http://localhost:8080/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加用户ID(实际应从登录状态/JWT获取)
request.interceptors.request.use(
config => {
// 从localStorage获取用户ID,默认1
const userId = localStorage.getItem('userId') || '1'
config.headers['X-User-Id'] = userId
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器 - 统一处理MP分页格式
request.interceptors.response.use(
response => {
const res = response.data
// 处理后端统一响应格式 { code, message, data, timestamp }
if (res.code !== 200) {
console.error('请求错误:', res.message)
return Promise.reject(new Error(res.message || '请求失败'))
}
// 返回data部分
return res.data
},
error => {
console.error('网络错误:', error.message)
return Promise.reject(error)
}
)
export default request
JavaScript
复制
// src/api/video.js
import request from './request'
export const videoApi = {
// 获取视频列表(MP分页参数:current从1开始)
getList(current = 1, size = 12) {
return request.get('/videos', {
params: { current, size }
})
},
// 获取视频详情
getById(id) {
return request.get(`/videos/${id}`)
},
// 搜索视频(MP分页)
search(keyword, current = 1, size = 12) {
return request.get('/videos/search', {
params: { keyword, current, size }
})
},
// 获取热门视频
getHot(limit = 10) {
return request.get('/videos/hot', { params: { limit } })
},
// 获取视频流URL(用于video标签src)
getStreamUrl(filename) {
return `http://localhost:8080/api/videos/stream/${filename}`
}
}
JavaScript
复制
// src/api/progress.js
import request from './request'
export const progressApi = {
// 获取播放进度
getProgress(videoId) {
return request.get(`/progress/${videoId}`)
},
// 更新播放进度
updateProgress(data) {
return request.post('/progress/update', {
videoId: data.videoId,
currentTime: Math.floor(data.currentTime),
duration: Math.floor(data.duration),
completed: data.completed || false
})
},
// 获取观看历史(MP分页:current从1开始)
getHistory(current = 1, size = 20) {
return request.get('/progress/history', {
params: { current, size }
})
},
// 获取继续观看
getContinueWatching() {
return request.get('/progress/continue')
},
// 标记完成
markComplete(videoId) {
return request.post(`/progress/complete/${videoId}`)
},
// 删除单条历史
deleteProgress(videoId) {
return request.delete(`/progress/${videoId}`)
},
// 清空历史
clearHistory() {
return request.delete('/progress/history')
},
// 获取统计
getStats() {
return request.get('/progress/stats')
}
}
2.2 工具函数
// src/utils/format.js
// 格式化时长(秒 -> HH:MM:SS或MM:SS)
export function formatDuration(seconds) {
if (!seconds || seconds < 0) return '00:00'
const hours = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 格式化相对时间
export function formatRelativeTime(timeStr) {
if (!timeStr) return '从未'
const date = new Date(timeStr)
const now = new Date()
const diffMs = now - date
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
const diffMonths = Math.floor(diffDays / 30)
const diffYears = Math.floor(diffDays / 365)
if (diffSecs < 60) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 30) return `${diffDays}天前`
if (diffMonths < 12) return `${diffMonths}个月前`
return `${diffYears}年前`
}
// 格式化数字(千分位)
export function formatNumber(num) {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toString()
}
// 格式化百分比
export function formatPercent(value, total) {
if (!total) return 0
return Math.min(100, Math.round((value / total) * 100))
}
2.3 组合式函数
// src/composables/useVideoProgress.js
import { ref, computed } from 'vue'
import { progressApi } from '@/api/progress'
import { formatDuration, formatRelativeTime } from '@/utils/format'
export function useVideoProgress(userId, videoId) {
const progress = ref({
currentTime: 0,
duration: 0,
percentage: 0,
completed: false,
lastWatchedAt: null,
watchCount: 0
})
const isLoading = ref(false)
const saveError = ref(null)
// 是否有观看历史(观看超过10秒)
const hasHistory = computed(() => progress.value.currentTime > 10)
// 格式化后的当前时间
const formattedCurrentTime = computed(() => formatDuration(progress.value.currentTime))
// 格式化后的总时长
const formattedDuration = computed(() => formatDuration(progress.value.duration))
// 格式化后的上次观看时间
const lastWatchedText = computed(() => formatRelativeTime(progress.value.lastWatchedAt))
// 加载进度
const loadProgress = async () => {
try {
isLoading.value = true
saveError.value = null
const data = await progressApi.getProgress(videoId)
progress.value = {
currentTime: data.currentTime || 0,
duration: data.duration || 0,
percentage: data.percentage || 0,
completed: data.completed || false,
lastWatchedAt: data.lastWatchedAt,
watchCount: data.watchCount || 0
}
return progress.value
} catch (error) {
console.error('加载进度失败:', error)
saveError.value = error.message
// 尝试从localStorage恢复
const localData = localStorage.getItem(`video_progress_${videoId}`)
if (localData) {
const parsed = JSON.parse(localData)
progress.value.currentTime = parsed.currentTime || 0
progress.value.duration = parsed.duration || 0
}
return null
} finally {
isLoading.value = false
}
}
// 保存进度(防抖处理)
let saveTimeout = null
const pendingSave = ref(null)
const saveProgress = async (currentTime, duration, completed = false, immediate = false) => {
// 更新本地状态
progress.value.currentTime = currentTime
progress.value.duration = duration
progress.value.percentage = duration ? Math.round((currentTime / duration) * 100) : 0
if (completed) progress.value.completed = true
// 准备保存的数据
const saveData = {
videoId,
currentTime: Math.floor(currentTime),
duration: Math.floor(duration),
completed
}
// 备份到localStorage
localStorage.setItem(`video_progress_${videoId}`, JSON.stringify({
...saveData,
timestamp: Date.now()
}))
// 清除之前的定时器
if (saveTimeout) {
clearTimeout(saveTimeout)
}
// 立即保存或延迟保存
const doSave = async () => {
try {
saveError.value = null
pendingSave.value = saveData
await progressApi.updateProgress(saveData)
// 清除localStorage备份
localStorage.removeItem(`video_progress_${videoId}`)
pendingSave.value = null
} catch (error) {
console.error('保存进度失败:', error)
saveError.value = error.message
}
}
if (immediate) {
await doSave()
} else {
saveTimeout = setTimeout(doSave, 500) // 500ms防抖
}
}
// 标记完成
const markComplete = async () => {
try {
await progressApi.markComplete(videoId)
progress.value.completed = true
progress.value.percentage = 100
} catch (error) {
console.error('标记完成失败:', error)
}
}
return {
progress,
isLoading,
saveError,
hasHistory,
formattedCurrentTime,
formattedDuration,
lastWatchedText,
loadProgress,
saveProgress,
markComplete
}
}
2.4 组件
<!-- src/components/VideoPlayer.vue -->
<template>
<div class="video-player-container">
<!-- 继续观看提示 -->
<div v-if="showResumePrompt" class="resume-prompt">
<div class="prompt-content">
<div class="prompt-icon">▶</div>
<div class="prompt-text">
<div class="prompt-title">上次观看到 {{ formattedResumeTime }}</div>
<div class="prompt-subtitle">进度 {{ progress.percentage }}%</div>
</div>
<div class="prompt-actions">
<button @click="resumeFromLast" class="btn-primary">继续观看</button>
<button @click="startFromBeginning" class="btn-secondary">从头开始</button>
</div>
<button @click="closePrompt" class="btn-close">×</button>
</div>
</div>
<!-- 视频容器 -->
<div class="video-wrapper" ref="containerRef">
<video
ref="videoRef"
class="video-element"
:poster="video.thumbnailUrl"
preload="metadata"
playsinline
webkit-playsinline
x5-playsinline
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@waiting="onWaiting"
@playing="onPlaying"
@click="togglePlay"
>
<source :src="videoStreamUrl" type="video/mp4" />
您的浏览器不支持视频播放
</video>
<!-- 加载动画 -->
<div class="video-loading" v-show="loading">
<div class="spinner"></div>
<span>加载中...</span>
</div>
<!-- 大播放按钮(暂停时显示) -->
<div class="big-play-btn" v-show="!isPlaying && !loading && !showResumePrompt" @click="play">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
<!-- 自定义控制栏 -->
<div class="custom-controls" v-show="showControls" @click.stop>
<!-- 进度条 -->
<div class="progress-container" @click="seek">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
<div class="progress-handle" :style="{ left: progressPercent + '%' }"></div>
</div>
</div>
<div class="controls-row">
<button @click="togglePlay" class="control-btn play-btn">
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<div class="time-display">
{{ currentTimeDisplay }} / {{ durationDisplay }}
</div>
<div class="spacer"></div>
<!-- 倍速播放 -->
<select v-model="playbackRate" @change="setPlaybackRate" class="speed-select">
<option value="0.5">0.5x</option>
<option value="1">1.0x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2.0x</option>
</select>
<button @click="toggleMute" class="control-btn">
<svg v-if="isMuted" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
</button>
<button @click="toggleFullscreen" class="control-btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- 视频信息 -->
<div class="video-info">
<h1>{{ video.title }}</h1>
<p class="description">{{ video.description }}</p>
<div class="meta-bar">
<span class="meta-item" v-if="progress.lastWatchedAt">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
上次观看: {{ lastWatchedText }}
</span>
<span class="meta-item" v-if="progress.watchCount > 0">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
{{ progress.watchCount }} 次观看
</span>
<span class="meta-item" :class="{ 'completed': progress.completed }">
<svg v-if="progress.completed" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
{{ progress.completed ? '已完成' : `观看进度: ${progress.percentage}%` }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { videoApi } from '@/api/video'
import { useVideoProgress } from '@/composables/useVideoProgress'
import { formatDuration, formatRelativeTime } from '@/utils/format'
const props = defineProps({
video: {
type: Object,
required: true
},
userId: {
type: Number,
default: 1
}
})
const emit = defineEmits(['progress-updated', 'video-completed'])
const videoRef = ref(null)
const containerRef = ref(null)
const isPlaying = ref(false)
const isMuted = ref(false)
const isFullscreen = ref(false)
const showControls = ref(true)
const loading = ref(false)
const showResumePrompt = ref(false)
const playbackRate = ref(1)
let controlsTimeout = null
let progressInterval = null
// 播放进度管理
const {
progress,
hasHistory,
lastWatchedText,
loadProgress,
saveProgress
} = useVideoProgress(props.userId, props.video.id)
// 计算属性
const videoStreamUrl = computed(() => {
return videoApi.getStreamUrl(props.video.filePath)
})
const resumeTime = computed(() => progress.value.currentTime)
const formattedResumeTime = computed(() => {
return formatDuration(resumeTime.value)
})
const progressPercent = computed(() => {
if (!videoRef.value || !videoRef.value.duration) return progress.value.percentage || 0
return (videoRef.value.currentTime / videoRef.value.duration) * 100
})
const currentTimeDisplay = computed(() => formatDuration(videoRef.value?.currentTime || 0))
const durationDisplay = computed(() => formatDuration(videoRef.value?.duration || 0))
// 初始化
onMounted(async () => {
// 加载历史进度
await loadProgress()
// 检查是否需要显示继续观看提示
if (hasHistory.value && !progress.value.completed && progress.value.currentTime > 10) {
showResumePrompt.value = true
} else if (progress.value.currentTime > 0) {
// 直接恢复位置
videoRef.value.currentTime = progress.value.currentTime
}
// 键盘快捷键
document.addEventListener('keydown', handleKeydown)
})
// 视频事件处理
const onLoadedMetadata = () => {
console.log('视频时长:', videoRef.value.duration)
// 如果视频有记录的总时长,但播放器获取不到,使用记录的
if (!videoRef.value.duration && progress.value.duration) {
// 部分浏览器可能无法获取时长,这里可以做一些兼容性处理
}
}
const onWaiting = () => {
loading.value = true
}
const onPlaying = () => {
loading.value = false
}
const onPlay = () => {
isPlaying.value = true
hideControlsDelay()
startProgressTracking()
}
const onPause = () => {
isPlaying.value = false
showControls.value = true
stopProgressTracking()
// 立即保存进度
saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
emit('progress-updated', progress.value)
}
const onEnded = () => {
isPlaying.value = false
stopProgressTracking()
showControls.value = true
// 标记完成
saveProgress(videoRef.value.currentTime, videoRef.value.duration, true, true)
emit('video-completed', props.video.id)
}
const onTimeUpdate = () => {
// 可以在这里做更精细的进度跟踪
}
// 控制方法
const togglePlay = () => {
if (videoRef.value.paused) {
videoRef.value.play()
} else {
videoRef.value.pause()
}
}
const play = () => {
videoRef.value.play()
}
const seek = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = percent * videoRef.value.duration
videoRef.value.currentTime = newTime
// 拖动后立即保存
saveProgress(newTime, videoRef.value.duration)
}
const toggleMute = () => {
videoRef.value.muted = !videoRef.value.muted
isMuted.value = videoRef.value.muted
}
const setPlaybackRate = () => {
videoRef.value.playbackRate = parseFloat(playbackRate.value)
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
containerRef.value.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const hideControlsDelay = () => {
clearTimeout(controlsTimeout)
controlsTimeout = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false
}
}, 3000)
}
// 进度跟踪
const startProgressTracking = () => {
// 每5秒保存一次进度
progressInterval = setInterval(() => {
if (videoRef.value && !videoRef.value.paused) {
saveProgress(videoRef.value.currentTime, videoRef.value.duration)
}
}, 5000)
}
const stopProgressTracking = () => {
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
// 继续观看逻辑
const resumeFromLast = () => {
videoRef.value.currentTime = resumeTime.value
showResumePrompt.value = false
play()
}
const startFromBeginning = () => {
videoRef.value.currentTime = 0
showResumePrompt.value = false
play()
}
const closePrompt = () => {
showResumePrompt.value = false
}
// 键盘快捷键
const handleKeydown = (e) => {
// 如果在输入框中,不处理
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
switch(e.key) {
case ' ':
case 'k':
e.preventDefault()
togglePlay()
break
case 'ArrowLeft':
e.preventDefault()
videoRef.value.currentTime -= 5
break
case 'ArrowRight':
e.preventDefault()
videoRef.value.currentTime += 5
break
case 'ArrowUp':
e.preventDefault()
videoRef.value.volume = Math.min(1, videoRef.value.volume + 0.1)
break
case 'ArrowDown':
e.preventDefault()
videoRef.value.volume = Math.max(0, videoRef.value.volume - 0.1)
break
case 'f':
toggleFullscreen()
break
case 'm':
toggleMute()
break
case 'j':
videoRef.value.currentTime -= 10
break
case 'l':
videoRef.value.currentTime += 10
break
}
}
// 页面关闭前保存
const beforeUnload = () => {
if (videoRef.value) {
saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
}
}
window.addEventListener('beforeunload', beforeUnload)
onUnmounted(() => {
stopProgressTracking()
clearTimeout(controlsTimeout)
window.removeEventListener('beforeunload', beforeUnload)
document.removeEventListener('keydown', handleKeydown)
// 保存最终进度
if (videoRef.value) {
saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
}
})
</script>
<style scoped>
.video-player-container {
width: 100%;
background: #0f0f0f;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
/* 继续观看提示 */
.resume-prompt {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 16px 24px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-100%); }
to { opacity: 1; transform: translateY(0); }
}
.prompt-content {
display: flex;
align-items: center;
gap: 16px;
color: white;
max-width: 1200px;
margin: 0 auto;
}
.prompt-icon {
width: 48px;
height: 48px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.prompt-text {
flex: 1;
}
.prompt-title {
font-weight: 600;
font-size: 16px;
}
.prompt-subtitle {
font-size: 13px;
opacity: 0.9;
margin-top: 4px;
}
.prompt-actions {
display: flex;
gap: 12px;
}
.btn-primary, .btn-secondary {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: white;
color: #667eea;
}
.btn-primary:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.btn-secondary {
background: transparent;
color: white;
border: 1px solid rgba(255,255,255,0.5);
}
.btn-secondary:hover {
background: rgba(255,255,255,0.1);
}
.btn-close {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.btn-close:hover {
background: rgba(255,255,255,0.2);
}
/* 视频区域 */
.video-wrapper {
position: relative;
aspect-ratio: 16/9;
background: #000;
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
/* 加载动画 */
.video-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
color: white;
gap: 12px;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: #ff6b6b;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 大播放按钮 */
.big-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
background: rgba(255,107,107,0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.big-play-btn:hover {
transform: translate(-50%, -50%) scale(1.1);
background: #ff5252;
}
.big-play-btn svg {
width: 40px;
height: 40px;
color: white;
margin-left: 4px;
}
/* 控制栏 */
.custom-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
padding: 20px;
opacity: 0;
transition: opacity 0.3s;
}
.video-wrapper:hover .custom-controls {
opacity: 1;
}
.video-wrapper.paused .custom-controls {
opacity: 1;
}
/* 进度条 */
.progress-container {
width: 100%;
height: 24px;
display: flex;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
padding: 10px 0;
}
.progress-bar {
width: 100%;
height: 4px;
background: rgba(255,255,255,0.3);
border-radius: 2px;
position: relative;
transition: height 0.2s;
}
.progress-container:hover .progress-bar {
height: 6px;
}
.progress-fill {
height: 100%;
background: #ff6b6b;
border-radius: 2px;
transition: width 0.1s linear;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translate(50%, -50%);
width: 12px;
height: 12px;
background: #ff6b6b;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
}
.progress-container:hover .progress-fill::after {
opacity: 1;
}
.progress-handle {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transform: translate(-50%, -50%);
margin-top: -1px;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.progress-container:hover .progress-handle {
opacity: 1;
}
/* 控制按钮行 */
.controls-row {
display: flex;
align-items: center;
gap: 12px;
color: white;
}
.control-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
border-radius: 4px;
}
.control-btn:hover {
transform: scale(1.1);
background: rgba(255,255,255,0.1);
}
.control-btn svg {
width: 24px;
height: 24px;
}
.play-btn svg {
width: 28px;
height: 28px;
}
.time-display {
font-size: 13px;
font-variant-numeric: tabular-nums;
min-width: 100px;
color: rgba(255,255,255,0.9);
}
.spacer {
flex: 1;
}
.speed-select {
background: rgba(255,255,255,0.1);
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
outline: none;
}
.speed-select option {
background: #333;
color: white;
}
/* 视频信息 */
.video-info {
padding: 24px;
background: #f8f9fa;
}
.video-info h1 {
margin: 0 0 12px 0;
font-size: 24px;
color: #1a1a1a;
font-weight: 600;
}
.description {
color: #666;
line-height: 1.6;
margin: 0 0 16px 0;
font-size: 14px;
}
.meta-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #888;
}
.meta-item svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.meta-item.completed {
color: #52c41a;
font-weight: 500;
}
</style>
vue
复制
<!-- src/components/VideoCard.vue -->
<template>
<div class="video-card" @click="goToVideo">
<div class="thumbnail-wrapper">
<img
:src="video.thumbnailUrl || '/default-thumb.jpg'"
:alt="video.title"
class="thumbnail"
loading="lazy"
/>
<!-- 时长标签 -->
<span class="duration-badge" v-if="video.duration">
{{ formatDuration(video.duration) }}
</span>
<!-- 进度条(如果观看过) -->
<div class="progress-overlay" v-if="progress > 0 && progress < 100">
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
</div>
<!-- 已完成标记 -->
<div class="completed-badge" v-if="progress === 100">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
已看完
</div>
</div>
<div class="card-info">
<h3 class="title">{{ video.title }}</h3>
<p class="description" v-if="video.description">{{ truncateDesc(video.description) }}</p>
<div class="meta">
<span class="time" v-if="video.createdAt">{{ formatRelativeTime(video.createdAt) }}</span>
<span class="dot" v-if="video.createdAt && video.resolution">·</span>
<span class="resolution" v-if="video.resolution">{{ video.resolution }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDuration, formatRelativeTime } from '@/utils/format'
const props = defineProps({
video: {
type: Object,
required: true
},
watchProgress: {
type: Number,
default: 0 // 0-100
}
})
const router = useRouter()
const progress = computed(() => props.watchProgress)
const goToVideo = () => {
router.push(`/video/${props.video.id}`)
}
const truncateDesc = (desc) => {
if (!desc) return ''
return desc.length > 60 ? desc.substring(0, 60) + '...' : desc
}
</script>
<style scoped>
.video-card {
background: white;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.12);
}
.thumbnail-wrapper {
position: relative;
aspect-ratio: 16/9;
background: #f0f0f0;
overflow: hidden;
}
.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.video-card:hover .thumbnail {
transform: scale(1.05);
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.progress-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(0,0,0,0.2);
}
.progress-bar {
height: 100%;
background: #ff6b6b;
transition: width 0.3s;
}
.completed-badge {
position: absolute;
top: 8px;
right: 8px;
background: #52c41a;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.completed-badge svg {
width: 14px;
height: 14px;
}
.card-info {
padding: 16px;
}
.title {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.description {
margin: 0 0 12px 0;
font-size: 13px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #999;
}
.dot {
opacity: 0.5;
}
.resolution {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
</style>
vue
复制
<!-- src/components/ContinueWatchingCard.vue -->
<template>
<div v-if="item" class="continue-card" @click="goToVideo">
<div class="thumbnail">
<img :src="item.thumbnailUrl || '/default-thumb.jpg'" :alt="item.title" />
<div class="progress-bar">
<div class="progress-fill" :style="{ width: item.progressPercentage + '%' }"></div>
</div>
<div class="play-overlay">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
<div class="info">
<h3>继续观看</h3>
<h4>{{ item.title }}</h4>
<p>观看到 {{ item.formattedTime }} · 剩余 {{ remainingTime }}</p>
</div>
<button class="btn-dismiss" @click.stop="dismiss">×</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDuration } from '@/utils/format'
const props = defineProps({
item: {
type: Object,
default: null
}
})
const emit = defineEmits(['dismiss'])
const router = useRouter()
const remainingTime = computed(() => {
if (!props.item) return ''
const remaining = props.item.duration - props.item.currentTime
return formatDuration(remaining) + ''
})
const goToVideo = () => {
if (props.item) {
router.push(`/video/${props.item.videoId}`)
}
}
const dismiss = () => {
emit('dismiss')
}
</script>
<style scoped>
.continue-card {
display: flex;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-bottom: 24px;
position: relative;
}
.continue-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(102, 126, 234, 0.3);
}
.thumbnail {
position: relative;
width: 280px;
flex-shrink: 0;
aspect-ratio: 16/9;
overflow: hidden;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(255,255,255,0.3);
}
.progress-fill {
height: 100%;
background: #ff6b6b;
transition: width 0.3s;
}
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background: rgba(255,255,255,0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.continue-card:hover .play-overlay {
opacity: 1;
}
.play-overlay svg {
width: 30px;
height: 30px;
color: #667eea;
margin-left: 4px;
}
.info {
padding: 24px;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
.info h3 {
margin: 0 0 8px 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.9;
}
.info h4 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.info p {
margin: 0;
font-size: 14px;
opacity: 0.9;
}
.btn-dismiss {
position: absolute;
top: 12px;
right: 12px;
background: rgba(0,0,0,0.3);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
opacity: 0;
}
.continue-card:hover .btn-dismiss {
opacity: 1;
}
.btn-dismiss:hover {
background: rgba(0,0,0,0.5);
}
@media (max-width: 768px) {
.continue-card {
flex-direction: column;
}
.thumbnail {
width: 100%;
}
}
</style>
vue
复制
<!-- src/components/HistoryList.vue -->
<template>
<div class="history-list">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<div v-else-if="history.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<p>暂无观看记录</p>
<router-link to="/" class="btn-browse">去浏览视频</router-link>
</div>
<div v-else class="history-grid">
<div
v-for="item in history"
:key="item.videoId"
class="history-item"
>
<div class="thumbnail" @click="playVideo(item.videoId)">
<img :src="item.thumbnailUrl" :alt="item.title" />
<div class="progress-overlay" v-if="!item.completed && item.progressPercentage > 0">
<div class="progress-bar" :style="{ width: item.progressPercentage + '%' }"></div>
</div>
<div class="completed-badge" v-if="item.completed">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
<div class="play-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
<div class="item-info">
<h4 @click="playVideo(item.videoId)">{{ item.title }}</h4>
<div class="meta">
<span class="progress-text" v-if="!item.completed">
看到 {{ item.formattedTime }}
</span>
<span class="completed-text" v-else>已完成观看</span>
<span class="dot">·</span>
<span class="time">{{ item.lastWatchedText }}</span>
</div>
</div>
<button class="btn-delete" @click="deleteItem(item.videoId)" title="删除记录">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
</button>
</div>
</div>
<!-- 分页加载更多 -->
<div v-if="hasMore" class="load-more">
<button @click="loadMore" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { progressApi } from '@/api/progress'
const router = useRouter()
const history = ref([])
const loading = ref(true)
const loadingMore = ref(false)
const current = ref(1)
const size = ref(20)
const hasMore = ref(true)
const total = ref(0)
const loadHistory = async () => {
try {
loading.value = true
const res = await progressApi.getHistory(1, size.value)
history.value = res.records || []
total.value = res.total || 0
hasMore.value = history.value.length < total.value
current.value = 1
} catch (error) {
console.error('加载历史失败:', error)
} finally {
loading.value = false
}
}
const loadMore = async () => {
if (loadingMore.value || !hasMore.value) return
try {
loadingMore.value = true
current.value++
const res = await progressApi.getHistory(current.value, size.value)
const newRecords = res.records || []
history.value.push(...newRecords)
hasMore.value = history.value.length < (res.total || 0)
} catch (error) {
console.error('加载更多失败:', error)
} finally {
loadingMore.value = false
}
}
const playVideo = (videoId) => {
router.push(`/video/${videoId}`)
}
const deleteItem = async (videoId) => {
if (!confirm('确定要删除这条观看记录吗?')) return
try {
await progressApi.deleteProgress(videoId)
history.value = history.value.filter(item => item.videoId !== videoId)
} catch (error) {
console.error('删除失败:', error)
alert('删除失败,请重试')
}
}
onMounted(loadHistory)
defineExpose({
refresh: loadHistory
})
</script>
<style scoped>
.history-list {
width: 100%;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px;
color: #999;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 20px;
color: #999;
}
.empty-state svg {
width: 64px;
height: 64px;
fill: #ddd;
margin-bottom: 16px;
}
.empty-state p {
margin: 0 0 20px 0;
font-size: 16px;
}
.btn-browse {
padding: 10px 24px;
background: #667eea;
color: white;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
}
.btn-browse:hover {
background: #5a6fd6;
}
.history-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.history-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: white;
border-radius: 12px;
transition: background 0.2s;
}
.history-item:hover {
background: #f5f5f5;
}
.thumbnail {
position: relative;
width: 160px;
aspect-ratio: 16/9;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.thumbnail:hover img {
transform: scale(1.05);
}
.progress-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(0,0,0,0.2);
}
.progress-bar {
height: 100%;
background: #ff6b6b;
}
.completed-badge {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.completed-badge svg {
width: 16px;
height: 16px;
fill: white;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: rgba(0,0,0,0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.thumbnail:hover .play-icon {
opacity: 1;
}
.play-icon svg {
width: 20px;
height: 20px;
fill: white;
margin-left: 2px;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-info h4 {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
cursor: pointer;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-info h4:hover {
color: #667eea;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #888;
}
.progress-text {
color: #ff6b6b;
}
.completed-text {
color: #52c41a;
font-weight: 500;
}
.dot {
opacity: 0.5;
}
.btn-delete {
width: 36px;
height: 36px;
border: none;
background: transparent;
color: #999;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-delete:hover {
background: #ff4d4f;
color: white;
}
.btn-delete svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.load-more {
text-align: center;
padding: 40px;
}
.load-more button {
padding: 12px 32px;
background: #f0f0f0;
border: none;
border-radius: 8px;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.load-more button:hover:not(:disabled) {
background: #e0e0e0;
color: #333;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.history-item {
flex-direction: column;
align-items: stretch;
}
.thumbnail {
width: 100%;
}
.btn-delete {
align-self: flex-end;
}
}
</style>
2.5 页面视图
<!-- src/views/Home.vue -->
<template>
<div class="home-page">
<!-- 继续观看 -->
<section class="section continue-section" v-if="continueWatching">
<ContinueWatchingCard
:item="continueWatching"
@dismiss="continueWatching = null"
/>
</section>
<!-- 热门视频 -->
<section class="section">
<div class="section-header">
<h2>🔥 热门视频</h2>
<router-link to="/search" class="view-all">查看全部</router-link>
</div>
<div class="video-grid">
<VideoCard
v-for="video in hotVideos"
:key="video.id"
:video="video"
:watch-progress="getVideoProgress(video.id)"
/>
</div>
</section>
<!-- 最新视频 -->
<section class="section">
<div class="section-header">
<h2>🆕 最新上传</h2>
</div>
<div class="video-grid">
<VideoCard
v-for="video in latestVideos"
:key="video.id"
:video="video"
:watch-progress="getVideoProgress(video.id)"
/>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore">
<button @click="loadMore" :disabled="loading">
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import VideoCard from '@/components/VideoCard.vue'
import ContinueWatchingCard from '@/components/ContinueWatchingCard.vue'
import { videoApi } from '@/api/video'
import { progressApi } from '@/api/progress'
const hotVideos = ref([])
const latestVideos = ref([])
const continueWatching = ref(null)
const videoProgressMap = ref(new Map())
const current = ref(1)
const size = ref(12)
const hasMore = ref(true)
const loading = ref(false)
const getVideoProgress = (videoId) => {
return videoProgressMap.value.get(videoId) || 0
}
const loadData = async () => {
try {
// 并行加载数据
const [hotRes, latestRes, continueRes] = await Promise.all([
videoApi.getHot(6),
videoApi.getList(1, size.value),
progressApi.getContinueWatching().catch(() => null)
])
hotVideos.value = hotRes || []
latestVideos.value = latestRes.records || []
hasMore.value = latestRes.records.length < latestRes.total
continueWatching.value = continueRes
// 加载这些视频的观看进度
await loadVideoProgress([...hotVideos.value, ...latestVideos.value])
} catch (error) {
console.error('加载数据失败:', error)
}
}
const loadVideoProgress = async (videos) => {
// 这里可以批量获取进度,或者逐个获取
// 简化处理:在实际项目中可以设计一个批量查询接口
for (const video of videos) {
try {
const progress = await progressApi.getProgress(video.id)
if (progress && progress.percentage > 0) {
videoProgressMap.value.set(video.id, progress.percentage)
}
} catch (e) {
// 忽略错误
}
}
}
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
current.value++
try {
const res = await videoApi.getList(current.value, size.value)
latestVideos.value.push(...res.records)
hasMore.value = latestVideos.value.length < res.total
await loadVideoProgress(res.records)
} catch (error) {
console.error('加载更多失败:', error)
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.home-page {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.section {
margin-bottom: 40px;
}
.continue-section {
margin-top: -10px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
}
.view-all {
color: #667eea;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.view-all:hover {
color: #5a6fd6;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.load-more {
text-align: center;
padding: 40px;
}
.load-more button {
padding: 12px 40px;
background: #f0f0f0;
border: none;
border-radius: 8px;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.load-more button:hover:not(:disabled) {
background: #667eea;
color: white;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.home-page {
padding: 16px;
}
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
}
</style>
<!-- src/views/VideoDetail.vue -->
<template>
<div class="video-detail-page">
<div class="content-wrapper">
<!-- 视频播放器 -->
<VideoPlayer
v-if="video.id"
:video="video"
:userId="userId"
@progress-updated="onProgressUpdated"
@video-completed="onVideoCompleted"
/>
<!-- 加载状态 -->
<div v-else-if="loading" class="loading-state">
<div class="spinner"></div>
<p>加载视频中...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<p>{{ error }}</p>
<button @click="loadVideo">重试</button>
</div>
</div>
<!-- 推荐视频 -->
<aside class="sidebar" v-if="relatedVideos.length > 0">
<h3>相关推荐</h3>
<div class="related-list">
<div
v-for="item in relatedVideos"
:key="item.id"
class="related-item"
@click="goToVideo(item.id)"
>
<img :src="item.thumbnailUrl" :alt="item.title" />
<div class="info">
<h4>{{ item.title }}</h4>
<span>{{ formatDuration(item.duration) }}</span>
</div>
</div>
</div>
</aside>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VideoPlayer from '@/components/VideoPlayer.vue'
import { videoApi } from '@/api/video'
import { formatDuration } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const video = ref({})
const relatedVideos = ref([])
const loading = ref(true)
const error = ref(null)
const userId = ref(parseInt(localStorage.getItem('userId') || '1'))
const loadVideo = async () => {
const videoId = route.params.id
if (!videoId) {
error.value = '视频ID不能为空'
return
}
try {
loading.value = true
error.value = null
const res = await videoApi.getById(videoId)
video.value = res
// 加载相关视频(这里简化处理,实际应该根据标签/分类推荐)
const listRes = await videoApi.getList(1, 6)
relatedVideos.value = listRes.records.filter(v => v.id !== parseInt(videoId))
} catch (err) {
error.value = '视频加载失败: ' + (err.message || '未知错误')
} finally {
loading.value = false
}
}
const goToVideo = (id) => {
router.push(`/video/${id}`)
// 重新加载页面数据
loadVideo()
}
const onProgressUpdated = (progress) => {
console.log('进度更新:', progress)
}
const onVideoCompleted = (videoId) => {
console.log('视频完成:', videoId)
// 可以显示完成提示或推荐下一个视频
}
onMounted(loadVideo)
</script>
<style scoped>
.video-detail-page {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 1fr 360px;
gap: 24px;
}
.content-wrapper {
min-width: 0;
}
.loading-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 20px;
color: #999;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state button {
margin-top: 16px;
padding: 10px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.sidebar {
border-left: 1px solid #eee;
padding-left: 24px;
}
.sidebar h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
}
.related-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.related-item {
display: flex;
gap: 12px;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: background 0.2s;
}
.related-item:hover {
background: #f5f5f5;
}
.related-item img {
width: 120px;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 6px;
}
.related-item .info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.related-item h4 {
margin: 0 0 4px 0;
font-size: 14px;
color: #1a1a1a;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-item span {
font-size: 12px;
color: #999;
}
@media (max-width: 1024px) {
.video-detail-page {
grid-template-columns: 1fr;
}
.sidebar {
border-left: none;
padding-left: 0;
border-top: 1px solid #eee;
padding-top: 24px;
}
}
</style>
<!-- src/views/History.vue -->
<template>
<div class="history-page">
<div class="page-header">
<h1>📚 观看历史</h1>
<div class="header-actions">
<div class="stats-summary" v-if="stats">
<span>总观看: {{ stats.totalWatched }}部</span>
<span>已完成: {{ stats.completedCount }}部</span>
<span>完成率: {{ stats.completionRate }}%</span>
</div>
<button
class="btn-clear"
@click="clearAllHistory"
:disabled="historyList.length === 0"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
清空历史
</button>
</div>
</div>
<!-- 继续观看 -->
<section class="continue-section" v-if="continueWatching">
<h2>继续观看</h2>
<ContinueWatchingCard
:item="continueWatching"
@dismiss="continueWatching = null"
/>
</section>
<!-- 历史列表 -->
<section class="history-section">
<h2>全部记录</h2>
<HistoryList ref="historyListRef" />
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import HistoryList from '@/components/HistoryList.vue'
import ContinueWatchingCard from '@/components/ContinueWatchingCard.vue'
import { progressApi } from '@/api/progress'
const historyListRef = ref(null)
const continueWatching = ref(null)
const stats = ref(null)
const historyList = ref([])
const loadContinueWatching = async () => {
try {
const res = await progressApi.getContinueWatching()
continueWatching.value = res
} catch (error) {
console.error('加载继续观看失败:', error)
}
}
const loadStats = async () => {
try {
const res = await progressApi.getStats()
stats.value = res
} catch (error) {
console.error('加载统计失败:', error)
}
}
const clearAllHistory = async () => {
if (!confirm('确定要清空所有观看历史吗?此操作不可恢复!')) return
try {
await progressApi.clearHistory()
// 刷新列表
historyListRef.value?.refresh()
continueWatching.value = null
stats.value = null
alert('已清空所有观看历史')
} catch (error) {
console.error('清空失败:', error)
alert('清空失败,请重试')
}
}
onMounted(() => {
loadContinueWatching()
loadStats()
})
</script>
<style scoped>
.history-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h1 {
margin: 0;
font-size: 28px;
color: #1a1a1a;
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.stats-summary {
display: flex;
gap: 16px;
font-size: 14px;
color: #666;
}
.stats-summary span {
background: #f0f0f0;
padding: 6px 12px;
border-radius: 20px;
}
.btn-clear {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-clear:hover:not(:disabled) {
background: #ff7875;
}
.btn-clear:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-clear svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.continue-section {
margin-bottom: 32px;
}
.continue-section h2,
.history-section h2 {
font-size: 18px;
color: #333;
margin: 0 0 16px 0;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
width: 100%;
flex-direction: column;
align-items: stretch;
}
.stats-summary {
justify-content: center;
}
}
</style>
vue
复制
<!-- src/views/Search.vue -->
<template>
<div class="search-page">
<div class="search-header">
<div class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<input
v-model="keyword"
@keyup.enter="doSearch"
placeholder="搜索视频标题、描述..."
type="text"
/>
<button v-if="keyword" class="btn-clear-input" @click="keyword = ''">×</button>
</div>
<button class="btn-search" @click="doSearch" :disabled="!keyword.trim() || searching">
{{ searching ? '搜索中...' : '搜索' }}
</button>
</div>
<!-- 搜索结果 -->
<div v-if="hasSearched" class="search-results">
<div class="results-header">
<p v-if="total > 0">找到 {{ total }} 个相关视频</p>
<p v-else-if="!searching">未找到相关视频</p>
</div>
<div v-if="results.length > 0" class="video-grid">
<VideoCard
v-for="video in results"
:key="video.id"
:video="video"
:watch-progress="getVideoProgress(video.id)"
/>
</div>
<!-- 分页 -->
<div v-if="total > size" class="pagination">
<button
:disabled="current === 1 || searching"
@click="changePage(current - 1)"
>
上一页
</button>
<span class="page-info">第 {{ current }} / {{ totalPages }} 页</span>
<button
:disabled="current >= totalPages || searching"
@click="changePage(current + 1)"
>
下一页
</button>
</div>
</div>
<!-- 推荐搜索/热门标签 -->
<div v-else class="search-suggestions">
<h3>热门搜索</h3>
<div class="hot-tags">
<span
v-for="tag in hotTags"
:key="tag"
class="tag"
@click="quickSearch(tag)"
>
{{ tag }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VideoCard from '@/components/VideoCard.vue'
import { videoApi } from '@/api/video'
import { progressApi } from '@/api/progress'
const route = useRoute()
const router = useRouter()
const keyword = ref('')
const results = ref([])
const current = ref(1)
const size = ref(12)
const total = ref(0)
const searching = ref(false)
const hasSearched = ref(false)
const videoProgressMap = ref(new Map())
const hotTags = ['Java', 'Spring Boot', 'Vue', 'MyBatis', 'Redis', '微服务', '面试', '项目实战']
const totalPages = computed(() => Math.ceil(total.value / size.value))
const getVideoProgress = (videoId) => {
return videoProgressMap.value.get(videoId) || 0
}
const doSearch = async () => {
if (!keyword.value.trim()) return
searching.value = true
hasSearched.value = true
current.value = 1
try {
const res = await videoApi.search(keyword.value.trim(), current.value, size.value)
results.value = res.records || []
total.value = res.total || 0
// 更新URL
router.replace({ query: { q: keyword.value } })
// 加载观看进度
await loadVideoProgress(results.value)
} catch (error) {
console.error('搜索失败:', error)
} finally {
searching.value = false
}
}
const changePage = async (page) => {
current.value = page
searching.value = true
try {
const res = await videoApi.search(keyword.value, current.value, size.value)
results.value = res.records || []
// 加载进度
await loadVideoProgress(results.value)
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (error) {
console.error('翻页失败:', error)
} finally {
searching.value = false
}
}
const quickSearch = (tag) => {
keyword.value = tag
doSearch()
}
const loadVideoProgress = async (videos) => {
for (const video of videos) {
try {
const progress = await progressApi.getProgress(video.id)
if (progress && progress.percentage > 0) {
videoProgressMap.value.set(video.id, progress.percentage)
}
} catch (e) {
// 忽略
}
}
}
// 从URL初始化搜索词
watch(() => route.query.q, (newQ) => {
if (newQ && !hasSearched.value) {
keyword.value = newQ
doSearch()
}
}, { immediate: true })
</script>
<style scoped>
.search-page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.search-header {
display: flex;
gap: 12px;
margin-bottom: 32px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.search-box {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 16px;
width: 20px;
height: 20px;
color: #999;
pointer-events: none;
}
.search-box input {
width: 100%;
padding: 14px 40px 14px 48px;
border: 2px solid #e0e0e0;
border-radius: 12px;
font-size: 16px;
transition: border-color 0.2s;
outline: none;
}
.search-box input:focus {
border-color: #667eea;
}
.btn-clear-input {
position: absolute;
right: 12px;
width: 24px;
height: 24px;
border: none;
background: #e0e0e0;
color: #666;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-search {
padding: 14px 32px;
background: #667eea;
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.btn-search:hover:not(:disabled) {
background: #5a6fd6;
}
.btn-search:disabled {
background: #ccc;
cursor: not-allowed;
}
.results-header {
margin-bottom: 20px;
color: #666;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 20px;
}
.pagination button {
padding: 10px 20px;
background: #f0f0f0;
border: none;
border-radius: 8px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.pagination button:hover:not(:disabled) {
background: #667eea;
color: white;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 14px;
}
.search-suggestions {
text-align: center;
padding: 60px 20px;
}
.search-suggestions h3 {
color: #666;
margin-bottom: 20px;
}
.hot-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
.tag {
padding: 10px 20px;
background: #f0f0f0;
border-radius: 20px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.tag:hover {
background: #667eea;
color: white;
}
@media (max-width: 768px) {
.search-header {
flex-direction: column;
}
.btn-search {
width: 100%;
}
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
}
</style>
2.6 路由与入口文件
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import VideoDetail from '@/views/VideoDetail.vue'
import History from '@/views/History.vue'
import Search from '@/views/Search.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/video/:id',
name: 'VideoDetail',
component: VideoDetail
},
{
path: '/history',
name: 'History',
component: History
},
{
path: '/search',
name: 'Search',
component: Search
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
<!-- src/App.vue -->
<template>
<div class="app">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-brand">
<router-link to="/">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<span>VideoPlayer</span>
</router-link>
</div>
<div class="nav-links">
<router-link to="/" active-class="active">首页</router-link>
<router-link to="/history" active-class="active">观看历史</router-link>
<router-link to="/search" active-class="active">搜索</router-link>
</div>
<div class="nav-user">
<span class="user-id">用户 {{ userId }}</span>
</div>
</nav>
<!-- 主内容区 -->
<main class="main-content">
<router-view v-slot="{ Component }">
<keep-alive :include="['Home']">
<component :is="Component" />
</keep-alive>
</router-view>
</main>
<!-- 页脚 -->
<footer class="footer">
<p>© 2026 VideoPlayer - Java视频在线播放系统</p>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
const userId = ref(localStorage.getItem('userId') || '1')
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 导航栏 */
.navbar {
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand a {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: #667eea;
font-size: 20px;
font-weight: 700;
}
.nav-brand svg {
width: 32px;
height: 32px;
fill: #667eea;
}
.nav-links {
display: flex;
gap: 32px;
}
.nav-links a {
color: #666;
text-decoration: none;
font-size: 15px;
font-weight: 500;
padding: 8px 0;
position: relative;
transition: color 0.2s;
}
.nav-links a:hover,
.nav-links a.active {
color: #667eea;
}
.nav-links a.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #667eea;
border-radius: 2px;
}
.nav-user {
font-size: 14px;
color: #999;
}
.user-id {
background: #f0f0f0;
padding: 6px 12px;
border-radius: 20px;
}
/* 主内容 */
.main-content {
flex: 1;
min-height: calc(100vh - 64px - 60px);
}
/* 页脚 */
.footer {
background: white;
border-top: 1px solid #eee;
padding: 20px;
text-align: center;
color: #999;
font-size: 14px;
}
@media (max-width: 768px) {
.navbar {
padding: 0 16px;
}
.nav-links {
gap: 16px;
}
.nav-links a {
font-size: 14px;
}
.nav-user {
display: none;
}
}
</style>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
2.7 项目配置文件
package.json
// package.json
{
"name": "video-player-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.2",
"vue": "^3.3.8",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.0"
}
}
vite.config.js
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
index.html
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoPlayer - Java视频在线播放系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
3. 数据库表结构
-- 视频表
CREATE TABLE videos (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL COMMENT '视频标题',
description TEXT COMMENT '视频描述',
file_path VARCHAR(500) NOT NULL COMMENT '文件路径或URL',
thumbnail_url VARCHAR(500) COMMENT '缩略图URL',
duration BIGINT COMMENT '视频时长(秒)',
resolution VARCHAR(20) COMMENT '分辨率',
status ENUM('ACTIVE', 'INACTIVE', 'DELETED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
FULLTEXT INDEX idx_search (title, description)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户视频播放进度表(核心表)
CREATE TABLE video_progress (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
video_id BIGINT NOT NULL COMMENT '视频ID',
current_time BIGINT NOT NULL DEFAULT 0 COMMENT '当前播放位置(秒)',
total_duration BIGINT COMMENT '视频总时长',
completed BOOLEAN DEFAULT FALSE COMMENT '是否已看完',
watch_count INT DEFAULT 1 COMMENT '观看次数',
last_watched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后观看时间',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_video (user_id, video_id),
INDEX idx_user_time (user_id, last_watched_at),
INDEX idx_completed (user_id, completed),
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4. 关键功能说明
4.1 播放进度保存策略
| 场景 | 策略 | 说明 |
|---|---|---|
| 播放中 | 每5秒上报 | 使用定时器,减少请求频率 |
| 暂停/关闭 | 立即上报 | 确保进度不丢失 |
| 视频结束 | 标记完成 | 更新completed字段 |
| 网络异常 | LocalStorage备份 | 失败时本地存储,下次恢复 |
4.2 性能优化点
- Redis缓存:高频读取的播放进度优先从Redis获取
- 延迟写入:播放进度先写Redis,异步批量写入MySQL
- 断点续传:HTTP 206 Partial Content支持拖动跳转
- 防抖处理:前端保存进度使用500ms防抖
4.3 用户体验特性
- 继续观看提示:进入视频页时自动提示上次观看位置
- 多设备同步:通过WebSocket或轮询实现多设备进度同步
- 观看历史管理:支持查看、删除、清空历史记录
- 进度可视化:缩略图上的进度条直观显示观看进度
这个实现提供了完整的视频播放历史记录功能,包括精确到秒级的进度保存、继续观看提示、观看历史管理等核心功能,同时考虑了高并发场景下的性能优化。