Bootstrap

【视频流上传播放功能】前后端分离用springboot-vue简单实现视频流上传和播放功能【详细注释版本,包含前后端代码】

前言:

我是前端程序猿一枚,不是后端的,如有写的有不规范的地方别介意哈,自己摸索了两天算是把这个功能做出来了,网上看了很多帖子没注释说实话,我看的基本是懵逼的。毕竟没有系统学过,所以现在做出来了就总结一下,自己多写点注释解释一下逻辑,让前端的小伙伴们如果也想自己做一个这个功能,可以参考参考。

包含功能:

1,前端点击通过流的形式上传视频
2,视频到后端保存到服务器本地的磁盘中
3,视频上传成功后,数据库对应出现一条信息,分别展示视频的原名,视频的唯一识别码,视频的id,视频的磁盘路径地址。
4,前端渲染出表格展示所有视频信息,根据点击播放按钮,打开对应的视频
5,后端接到请求后根据前端返回的id不同,返回不同的视频,通过视频流的形式返回播放

各文件用处解释:

SelectVideoController:后端给前端的接口写在这个文件内,其中包含两个接口。
(1)/SelectVideo/policemen/{videoId}:用来前端请求后返回对应视频流数据给前端展示视频。
(2)/SelectVideo/table:用于前端表格展示所有video数据的

uploadVideoController:后端给前端的接口写在这个文件内,其中包含一个接口
(1)/api/uploadVideo3:用于前端把本地的视频上传给后端保存在服务器磁盘并在数据库内加一条信息。

VideoUpload:数据库视频表的实体类,前端的人理解为对象。这里的变量必须和数据库的字段一样,不然报错

VideoUploadMapper:接口文件,用于后端链接数据库增删改查等操作的接口,和前端没关系,这里包含三个查询sql语句。
(1)save:用于前端上传的视频保存在数据库内增加一条信息。
(2)SelectVideoAll:用于前端表格展示所有视频信息,查询数据库所有视频信息返回
(3)SelectVideoId:用于前端传id过来,根据id查询数据库对应的一条视频信息返回

NonStaticResourceHttpRequestHandler:用于把视频转换为视频流返回给前端

Demo2Application:配置文件,这里包含一个方法
(1)multipartConfigElement:和上传视频的功能文件uploadVideoController结合的,用于限制视频大小的。

效果图

前端上传页面
在这里插入图片描述
前端视频数据展示页面
在这里插入图片描述
前端点击播放后弹框页面,我手动打码了,不用在意
在这里插入图片描述
在这里插入图片描述
mysql数据库字段
在这里插入图片描述
后台传过来的视频上传信息
在这里插入图片描述
后端代码架构,红框标出来了,其他的不用管,不是相关代码。
在这里插入图片描述

说一句,其他的文件有不同的功能,我也写了帖子介绍,可以自行查看,对于创建项目后各个文件作用不清楚的也可以看我其他帖子,有详细解释

后端代码

SelectVideoController

package com.example.demo.controller;


import com.example.demo.entity.VideoUpload;
import com.example.demo.mapper.VideoUploadMapper;
import com.example.demo.utils.NonStaticResourceHttpRequestHandler;

import lombok.AllArgsConstructor;

import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

//前端获取后端视频流
@RestController
@RequestMapping("/SelectVideo")
@AllArgsConstructor
public class SelectVideoController {
    //引入返回视频流的组件
    private final NonStaticResourceHttpRequestHandler nonStaticResourceHttpRequestHandler;
    //把后端链接数据库接口引入进来
    @Resource
    VideoUploadMapper videoUploadMapper;
    //解决跨域的注解
    @CrossOrigin(origins = "*", maxAge = 3600)
    //查询视频流的接口
    @GetMapping("/policemen/{videoId}")
    //前两个参数不管,第三个参数videoId代表前端传过来的视频的id号
    public void videoPreview(HttpServletRequest request, HttpServletResponse response,@PathVariable("videoId") Integer videoId) throws Exception {
        //调用查询方法,把前端传来的id传过去,查询对应的视频信息。
        VideoUpload videoPathList = videoUploadMapper.SelectVideoId(videoId);
        //从视频信息中单独把视频路径信息拿出来保存
        String videoPathUrl=videoPathList.getVideoUrl();
        //保存视频磁盘路径
        Path filePath = Paths.get(videoPathUrl );
        //Files.exists:用来测试路径文件是否存在
        if (Files.exists(filePath)) {
            //获取视频的类型,比如是MP4这样
            String mimeType = Files.probeContentType(filePath);
            if (StringUtils.hasText(mimeType)) {
                //判断类型,根据不同的类型文件来处理对应的数据
                response.setContentType(mimeType);
            }
            //转换视频流部分
            request.setAttribute(NonStaticResourceHttpRequestHandler.ATTR_FILE, filePath);
            nonStaticResourceHttpRequestHandler.handleRequest(request, response);
        } else {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        }
    }

    //查询视频表格列表的接口
    @CrossOrigin(origins = "*", maxAge = 3600)
    @GetMapping("/table")
    public List<VideoUpload> videoTable() {
        //调用搜索方法查询所有视频信息,成表格展示前端
        return videoUploadMapper.SelectVideoAll();
    }

}

uploadVideoController

package com.example.demo.controller;
import java.io.File;
import java.util.*;

import com.example.demo.mapper.VideoUploadMapper;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;

//接口:前端视频上传
@RestController
//一级地址
@RequestMapping("/api")
public class uploadVideoController {
    @Resource
    VideoUploadMapper videoUploadMapper;
    //解决跨域的注解
    @CrossOrigin(origins = "*", maxAge = 3600)
    //二级地址
    @PostMapping(value = "/uploadVideo3")
    @ResponseBody
    //Map<String,String>: map是键值对形式组成的集合,类似前端的数组但是里面是键值对形式的,前后两个string代表键和值都是字符串格式的。
    //post请求传入的参数:MultipartFile file(理解为springmvc框架给我们提供的工具类,代表视频流数据),SavePath(前台传来的地址路径,也是用来后端保存在服务器哪个文件夹的地址)
    public Map<String,String> savaVideoTest(@RequestParam("file") MultipartFile file,@RequestParam String SavePath)
    //throws IllegalStateException写在方法的前面是可以抛出异常状态的,如果有错误会把错误信息发出来对应下面的try和catch
            throws IllegalStateException {
        //new一个map集合出来
        Map<String,String> resultMap = new HashMap<>();
        try{
            //获取文件后缀,因此此后端代码可接收一切文件,上传格式前端限定
            String fileExt = file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf(".") + 1).toLowerCase();
            // 重构文件名称
            System.out.println("前端传递的保存路径:"+SavePath);
            //UUID(全局唯一标识符)randomUUID(随机生成标识符)toString(转成字符串)replaceAll(替换字符方法,因为随机生成的里面包括了 - ,这里意思是把 - 全部换成空)
            String pikId = UUID.randomUUID().toString().replaceAll("-", "");
            //视频名字拼接:唯一标识符加上点,再加上上面的视频后缀也就是MP4之类的。就组成了现在的视频名字,比如这样:c7bbc1f9664947a287d35dd7cdc48a95.mp4
            String newVideoName = pikId + "." + fileExt;
            System.out.println("重构文件名防止上传同名文件:"+newVideoName);
            //保存视频的原始名字
            String videoNameText = file.getOriginalFilename();
            System.out.println("视频原名:"+videoNameText);
            //保存视频url路径地址
            String videoUrl = SavePath + "/" + newVideoName;
            //调用数据库接口插入数据库方法save,把视频原名,视频路径,视频的唯一标识码传进去存到数据库内
            videoUploadMapper.save(videoNameText,videoUrl,newVideoName);
            //判断SavePath这个路径也就是需要保存视频的文件夹是否存在
            File filepath = new File(SavePath, file.getOriginalFilename());
            if (!filepath.getParentFile().exists()) {
                //如果不存在,就创建一个这个路径的文件夹。
                filepath.getParentFile().mkdirs();
            }
            //保存视频:把视频按照前端传来的地址保存进去,还有视频的名字用唯一标识符显示,需要其他的名字可改这
            File fileSave = new File(SavePath, newVideoName);
            //下载视频到文件夹中
            file.transferTo(fileSave);
            //构造Map将视频信息返回给前端
            //视频名称重构后的名称:这里put代表添加进map集合内,和前端的push一样。括号内是前面字符串是键,后面是值
            resultMap.put("newVideoName",newVideoName);
            //正确保存视频成功,则设置返回码为200
            resultMap.put("resCode","200");
            //返回视频保存路径
            resultMap.put("VideoUrl",SavePath + "/" + newVideoName);
            //到这里全部保存好了,把整个map集合返给前端
            return  resultMap;

        }catch (Exception e){
            //在命令行打印异常信息在程序中出错的位置及原因
            e.printStackTrace();
            //返回有关异常的详细描述性消息。
            e.getMessage();
            //保存视频错误则设置返回码为400
            resultMap.put("resCode","400");
            //这时候错误了,map里面就添加了错误的状态码400并返回给前端看
            return  resultMap ;

        }
    }



}

VideoUpload

package com.example.demo.entity;

//视频数据库实体类
public class VideoUpload {
    private int id;
    private String videoName;
    private String videoUrl;
    private String videoUUID;

    public VideoUpload(int id, String videoName, String videoUrl, String videoUUID) {
        this.id = id;
        this.videoName = videoName;
        this.videoUrl = videoUrl;
        this.videoUUID = videoUUID;
    }

    public VideoUpload() {
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getVideoName() {
        return videoName;
    }

    public void setVideoName(String videoName) {
        this.videoName = videoName;
    }

    public String getVideoUrl() {
        return videoUrl;
    }

    public void setVideoUrl(String videoUrl) {
        this.videoUrl = videoUrl;
    }

    public String getVideoUUID() {
        return videoUUID;
    }

    public void setVideoUUID(String videoUUID) {
        this.videoUUID = videoUUID;
    }
}

NonStaticResourceHttpRequestHandler

package com.example.demo.utils;

import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.resource.ResourceHttpRequestHandler;
import javax.servlet.http.HttpServletRequest;
import java.nio.file.Path;
//返回视频流

@Component
public class NonStaticResourceHttpRequestHandler extends ResourceHttpRequestHandler {
    public final static String ATTR_FILE = "NON-STATIC-FILE";

    @Override
    protected Resource getResource(HttpServletRequest request) {
        final Path filePath = (Path) request.getAttribute(ATTR_FILE);
        return new FileSystemResource(filePath);
    }

}

VideoUploadMapper

package com.example.demo.mapper;


import com.example.demo.entity.VideoUpload;

import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

public interface VideoUploadMapper {
    //插入数据到数据库内,目前需要把id加上,不会自动生成id,不然报错
    @Update("INSERT INTO `video_upload`( `videoName`, `videoUrl`, `videoUUID`) VALUES (#{videoName},#{videoUrl},#{videoUUID});")
    //事务注解:可加可不加
    @Transactional
    //接收传过来的参数数据
    void save(String videoName,String videoUrl,String videoUUID);

    //查询数据库内表名为video_upload的全部数据返回
    @Select("select * from video_upload")
    //方法:以数组的形式返回数据库信息
    List<VideoUpload> SelectVideoAll();


    //查询数据库内表名为video_upload的id=videoId的那一条数据
    @Select("select * from video_upload where id = #{videoId}")
    //方法:以表格格式(就是数据库字段一样的格式直接返回一个对象里面包含各个字段和信息)返回
    VideoUpload SelectVideoId(Integer videoId);
}

Demo2Application

package com.example.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.util.unit.DataSize;
import org.springframework.context.annotation.Bean;
import javax.servlet.MultipartConfigElement;

@SpringBootApplication
//这里写的是告诉spring框架mapper接口在什么位置,然后找到对应的地方扫描。不写框架会找不到接口
@MapperScan("com.example.demo.mapper")
public class Demo2Application {
    public static void main(String[] args) {
        SpringApplication.run(Demo2Application.class, args);
    }


    /**
     * 文件上传配置
     * @return
     */
    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        //单个文件最大
        factory.setMaxFileSize(DataSize.parse("400MB")); //KB,MB
        /// 设置总上传数据总大小
        factory.setMaxRequestSize(DataSize.parse("400MB"));
        return factory.createMultipartConfig();
    }

}

前端代码

视频上传页面

html

        <el-tab-pane label="业务视频" name="second">
          <el-form ref="form" :model="form" label-width="80px">
            <el-form-item label="上传视频">
              <el-upload
                class="avatar-uploader el-upload--text"
                :drag="Plus"
                action="http://localhost:8001/api/uploadVideo3"
                multiple
                :show-file-list="false"
                :data="{ SavePath: this.Path.url }"
                :on-success="handleVideoSuccess"
                :before-upload="beforeUploadVideo"
                :on-progress="uploadVideoProcess"
              >
                <i v-if="Plus" class="el-icon-upload"></i>
                <div v-if="Plus" class="el-upload__text">
                  将文件拖到此处,或<em>点击上传</em>
                </div>
                <el-progress
                  v-if="videoFlag == true"
                  type="circle"
                  :percentage="videoUploadPercent"
                  style="margin-top: 30px"
                ></el-progress>
                <div class="el-upload__tip" slot="tip">
                  只能上传mp4/flv/avi文件,且不超过300M
                </div>
              </el-upload>
            </el-form-item>
            <el-form-item>
              <el-button type="primary" @click="onSubmit">立即创建</el-button>
            </el-form-item>
          </el-form>
        </el-tab-pane>

JS

 data() {
    return {
      //视频部分
      videoForm: {
        videoId: '',
        videoUrl: ''
      },
      videoFlag: false,
      Plus: true,
      //上传视频时带的参数,这个地址就是后端保存磁盘的地址。可以更改。不建议放F盘,有的电脑可能没有F盘,只有C和D
      Path: {
        url: 'D:/video/videoUpload'
      },
      videoUploadPercent: 0
    };
  },
  methods:{
    //视频部分
    // 视频上传前执行
    beforeUploadVideo (file) {
      //文件大小
      const isLt300M = file.size / 1024 / 1024 < 300
      //视频后缀检查
      if (['video/mp4', 'video/ogg', 'video/flv', 'video/avi', 'video/wmv', 'video/rmvb'].indexOf(file.type) === -1) {
        this.$message.error('请上传正确的视频格式')
        return false
      }
      if (!isLt300M) {
        this.$message.error('上传视频大小不能超过300MB哦!')
        return false
      }
    },
    // 视频上传过程中执行
    uploadVideoProcess (event, file, fileList) {
      this.Plus = false
      this.videoFlag = true
      this.videoUploadPercent =+ file.percentage.toFixed(0)
    },
    // 视频上传成功是执行
    handleVideoSuccess (res, file) {
      this.Plus = false
      this.videoUploadPercent = 100
      console.log(res)
      // 如果为200代表视频保存成功
      if (res.resCode === '200') {
        // 接收视频传回来的名称和保存地址
        // 至于怎么使用看你啦~
        this.videoForm.videoId = res.newVidoeName
        this.videoForm.videoUrl = res.VideoUrl
        this.$message.success('视频上传成功!')
      } else {
        this.$message.error('视频上传失败,请重新上传!')
      }
    }
    
  },
};

视频表格展示播放页面

<template>
  <div class="release_wrap">
    <div class="release_title">业 务 视 频</div>
    <el-card class="release_card">
      <el-button
        type="primary"
        round
        icon="el-icon-arrow-left"
        style="margin-bottom: 40px"
        @click="jump_home"
        >返回</el-button
      >

      <el-table stripe :data="tableData" style="width: 100%" height="600px">
        <el-table-column prop="videoName" label="视频名称" min-width="280">
        </el-table-column>
        <el-table-column label="操作">
          <template slot-scope="scope">
            <el-button
              size="mini"
              type="primary"
              @click="playVideo(scope.$index, scope.row)"
              >播放</el-button
            >
          </template>
        </el-table-column>
      </el-table>
    </el-card>
    <el-dialog
      :modal="false"
      title="视频播放"
      :visible.sync="dialogVisible"
      width="40%"
    >
      <p class="video_title">{{videoName}}</p>
      <video
        :src="`http://localhost:8001/SelectVideo/policemen/${videoId}`"
        controls="controls"
        width="100%"
        @canplay="getVidDur()"
        id="myvideo"
      ></video>
    </el-dialog>
  </div>
</template>

<script>
var video = () => {
  var videoTime = document.getElementById("myvideo");
  console.log(videoTime.duration); //获取视频时长
  console.log(videoTime.currentTime); //获取视频当前播放时间
};
import { mapState } from "vuex";
export default {
  data() {
    return {
      title: "",
      videolist: "",
      //表格数据
      tableData: [],
      //弹框组件隐藏
      dialogVisible: false,
      //用于保存视频的id
      videoId: 0,
      //保存视频的名称
      videoName:''
    };
  },
  computed: {
    //引入vuex中state的变量,可以直接this.xxx调用到
    ...mapState(["article"]),
  },
  created() {
    this.getVideoInfo();
  },
  methods: {
    jump_home() {
      this.$router.go(-1);
    },
    getVidDur() {
      video();
    },
    //获取video表格数据
    getVideoInfo() {
      this.$axios.get("/SelectVideo/table").then((res) => {
        this.tableData = res;
        console.log(res);
      });
    },
    //点击播放按钮
    playVideo(i, val) {
      //显示弹框
      this.dialogVisible = true;
      //保存视频名字
      this.videoName=val.videoName;
      //保存视频id
      this.videoId=val.id;
      console.log(i, val);
    },
  },
};
</script>

<style  scoped lang='scss'>
.release_wrap {
  background-image: url("../assets/home1.jpg");
  width: 100%;
  height: 100%;
  position: relative;
}
//卡片效果
.release_card {
  width: 70%;
  position: absolute;
  top: 200px;
  left: 50%;
  transform: translateX(-50%);
  box-shadow: 0 0.3px 0.7px rgba(0, 0, 0, 0.126),
    0 0.9px 1.7px rgba(0, 0, 0, 0.179), 0 1.8px 3.5px rgba(0, 0, 0, 0.224),
    0 3.7px 7.3px rgba(0, 0, 0, 0.277), 0 10px 20px rgba(0, 0, 0, 0.4);
  transition: 0.5s ease; //改变大小
  &:hover {
    box-shadow: 0 0.7px 1px rgba(0, 0, 0, 0.157),
      0 1.7px 2.6px rgba(0, 0, 0, 0.224), 0 3.5px 5.3px rgba(0, 0, 0, 0.28),
      0 7.3px 11px rgba(0, 0, 0, 0.346), 0 20px 30px rgba(0, 0, 0, 0.5);
  }
}
.el-tag + .el-tag {
  margin-left: 10px;
}
.button-new-tag {
  margin-left: 10px;
  height: 32px;
  line-height: 30px;
  padding-top: 0;
  padding-bottom: 0;
}
.input-new-tag {
  width: 90px;
  margin-left: 10px;
  vertical-align: bottom;
}
// title效果
.release_title {
  text-align: center;
  font-size: 38px;
  font-weight: bold;
  position: absolute;
  top: 75px;
  left: 50%;
  transform: translateX(-50%);
  font-family: Lato, sans-serif; //字体
  letter-spacing: 4px; //字符间距空白
  text-transform: uppercase; //转换文本,控制大小写
  background: linear-gradient(
    90deg,
    rgba(0, 0, 0, 1) 0%,
    rgba(255, 255, 255, 1) 50%,
    rgba(0, 0, 0, 1) 100%
  );
  background-size: 80%; //背景大小
  background-repeat: no-repeat; //背景平铺不重复
  // below two lines create text gradient effect
  color: rgba(237, 227, 231, 0.7); //颜色透明
  background-clip: text; //规定背景的绘制区域在文字上
  animation: shining 3s linear infinite;
}
@keyframes shining {
  from {
    background-position: -500%; //背景图像的起始位置
  }
  to {
    background-position: 500%; //背景图像的结束位置
  }
}
.video_title {
  text-align: center;
  font-size: 22px;
  font-weight: bold;
  letter-spacing: 3px;
}
</style>
;