feat(video): 实现基于视频流播放WebSocket的FLV功能
- 修改了Java后端代码,将视频转码服务从HLS改为FLV格式输出 - 删除了原有的HLS相关控制器和转码逻辑 - 新增了VideoWebSocketServer和VideoServerPool类来管理WebSocket连接和视频流传输- 更新了前端Vue组件,使用flv.js替代hls.js来播放视频流- 增加了WebSocket通信机制,通过二进制数据传输FLV视频流 - 移除了旧的转码状态检查和控制逻辑 - 优化了FFmpeg命令参数以适配FLV流媒体传输 - 添加了WebSocket服务器启动配置,并集成到应用启动流程中dev_flv
parent
54ca5e9e4c
commit
ebba316be7
|
|
@ -1,9 +1,9 @@
|
||||||
VITE_BASE=/
|
VITE_BASE=/
|
||||||
# VITE_BASE_URL=http://192.168.0.25:8095/
|
|
||||||
VITE_BASE_URL=http://192.168.0.37:8095/
|
VITE_BASE_URL=http://192.168.4.40:8095/
|
||||||
|
|
||||||
#websocket t掉线
|
#websocket t掉线
|
||||||
VITE_ON_LINE_WEB_SOCKET_URL=ws://192.168.0.37:8869
|
#VITE_ON_LINE_WEB_SOCKET_URL=ws://192.168.4.40:8869
|
||||||
|
|
||||||
#websocket 在线学习
|
#websocket 在线学习
|
||||||
VITE_LEARNING_WEB_SOCKET_URL=ws://192.168.0.37:8899
|
#VITE_LEARNING_WEB_SOCKET_URL=ws://192.168.4.40:8899
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
VITE_BASE=/dist
|
VITE_BASE=/dist
|
||||||
#VITE_BASE_URL=http://10.199.64.27:8520/integrated_yjb/
|
|
||||||
VITE_BASE_URL=http://172.16.70.226:8081/sx_yjb/
|
VITE_BASE_URL=http://172.16.70.226:8081/sx_yjb/
|
||||||
#VITE_BASE_URL=https://qaaqwh.qhdsafety.com/integrated_whb/
|
|
||||||
|
|
||||||
#websocket t掉线
|
#websocket t掉线
|
||||||
VITE_ON_LINE_WEB_SOCKET_URL=ws://183.251.104.38:10103
|
#VITE_ON_LINE_WEB_SOCKET_URL=ws://172.16.70.226:10103
|
||||||
# VITE_ON_LINE_WEB_SOCKET_URL=wss://qaaqwh.qhdsafety.com/disconnected/
|
|
||||||
|
|
||||||
#websocket 在线学习
|
#websocket 在线学习
|
||||||
VITE_LEARNING_WEB_SOCKET_URL=ws://183.251.104.38:10102
|
#VITE_LEARNING_WEB_SOCKET_URL=ws://172.16.70.226:8899
|
||||||
#VITE_LEARNING_WEB_SOCKET_URL=wss://qaaqwh.qhdsafety.com/onlinelearning/
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"element-plus": "^2.6.1",
|
"element-plus": "^2.6.1",
|
||||||
|
"flv.js": "^1.6.2",
|
||||||
"hls.js": "^1.6.13",
|
"hls.js": "^1.6.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^2.5.1",
|
"jspdf": "^2.5.1",
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,4 @@ export const getvideoInfoView = (params) => post("/videoInfo/goEdit", params); /
|
||||||
export const setvideoInfoDelete = (params) => post("/videoInfo/delete", params); // 删除
|
export const setvideoInfoDelete = (params) => post("/videoInfo/delete", params); // 删除
|
||||||
export const setUpToBi = (params) => post("/videoInfo/editZhiding", params); // 删除
|
export const setUpToBi = (params) => post("/videoInfo/editZhiding", params); // 删除
|
||||||
export const setvideoInfoEdit = (params) => post("/videoInfo/edit", params);
|
export const setvideoInfoEdit = (params) => post("/videoInfo/edit", params);
|
||||||
export const startTransCode = (params) =>
|
|
||||||
post("/playVideo/startTranscode", params);
|
|
||||||
export const stopTransCode = (params) =>
|
|
||||||
post("/playVideo/stopTranscode", params);
|
|
||||||
export const getTranscodeStatus = (params) =>
|
|
||||||
post("/playVideo/getTranscodeStatus", params);
|
|
||||||
export const loginDahua = (params) =>
|
|
||||||
post("/dahuaVideo/login", params);
|
|
||||||
export const getDahuaSDKStatus = (params) =>
|
|
||||||
post("/dahuaVideo/status", params);
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="dialogVisible" title="播放后台转码视频" width="50%">
|
<el-dialog v-model="dialogVisible" title="播放视频监控" width="50%">
|
||||||
<!-- 原生video播放器 - 移除了controls属性以去掉默认控制栏 -->
|
|
||||||
<video ref="videoRef" playsinline class="video-player"></video>
|
<video ref="videoRef" playsinline class="video-player"></video>
|
||||||
|
|
||||||
<!-- 底部操作按钮 -->
|
<!-- 底部操作按钮 -->
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="primary" @click="dialogVisible = false">
|
<el-button type="primary" @click="handleClose"> 关闭播放 </el-button>
|
||||||
关闭播放
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch, onUnmounted, nextTick } from "vue";
|
||||||
import Hls from "hls.js"; // 引入HLS解析库
|
import flvjs from "flv.js";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { getTranscodeStatus, stopTransCode } from "@/request/video_info.js"; // 后端“停止转码”接口
|
|
||||||
|
|
||||||
// 接收父组件传递的属性
|
// 接收父组件传递的属性
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -26,7 +21,7 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
src: {
|
src: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true, // 必须传递HLS流地址
|
required: true, // 必须传递FLV流地址
|
||||||
},
|
},
|
||||||
videoId: {
|
videoId: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
@ -40,46 +35,26 @@ const emit = defineEmits(["update:visible", "onStop"]);
|
||||||
// 控制弹窗显示(内部响应props.visible)
|
// 控制弹窗显示(内部响应props.visible)
|
||||||
const dialogVisible = ref(props.visible);
|
const dialogVisible = ref(props.visible);
|
||||||
const videoRef = ref(null); // video元素引用
|
const videoRef = ref(null); // video元素引用
|
||||||
const isPlaying = ref(false); // 视频播放状态
|
let flvPlayer = null; // FLV播放器实例
|
||||||
let hlsInstance = null; // HLS实例(用于管理流解析)
|
|
||||||
|
|
||||||
// 监听父组件visible变化,同步到内部dialogVisible
|
// 监听父组件visible变化,同步到内部dialogVisible
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
dialogVisible.value = newVal;
|
dialogVisible.value = newVal;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 定义定时器变量
|
|
||||||
let checkInterval = null;
|
|
||||||
|
|
||||||
// 弹窗打开时执行的方法
|
|
||||||
const onDialogOpen = () => {
|
|
||||||
// 每3秒调用一次接口
|
|
||||||
checkInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
// 假设接口名为checkVideoProgress,需自行导入
|
|
||||||
const response = await getTranscodeStatus({ id: props.videoId });
|
|
||||||
if (response.progress > 2) {
|
|
||||||
handlePlay();
|
|
||||||
// 播放后清除定时器
|
|
||||||
if (checkInterval) {
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
checkInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 可根据需要处理接口返回结果
|
|
||||||
} catch (error) {}
|
|
||||||
}, 3000); // 3秒间隔
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听内部dialogVisible变化,通知父组件
|
// 监听内部dialogVisible变化,通知父组件
|
||||||
watch(dialogVisible, (newVal) => {
|
watch(dialogVisible, (newVal) => {
|
||||||
emit("update:visible", newVal);
|
emit("update:visible", newVal);
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
onDialogOpen();
|
// 使用nextTick确保DOM更新完成后再播放
|
||||||
|
nextTick(() => {
|
||||||
|
handlePlay();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// 销毁播放器
|
||||||
destroyPlayer(); // 弹窗关闭时销毁播放器
|
destroyPlayer(); // 弹窗关闭时销毁播放器
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -101,97 +76,135 @@ const handlePlay = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLS.js 支持检测
|
// 使用FLV播放
|
||||||
if (Hls.isSupported()) {
|
playWithFLV();
|
||||||
// 如果已有实例,先销毁
|
};
|
||||||
if (hlsInstance) {
|
|
||||||
hlsInstance.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
hlsInstance = new Hls({
|
// 使用FLV播放
|
||||||
maxBufferLength: 30, // 增加缓冲长度
|
const playWithFLV = () => {
|
||||||
maxMaxBufferLength: 60,
|
const video = videoRef.value;
|
||||||
});
|
if (!video) {
|
||||||
|
ElMessage.error("视频播放器初始化失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有播放器实例,先销毁
|
||||||
|
if (flvPlayer) {
|
||||||
|
flvPlayer.destroy();
|
||||||
|
flvPlayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查浏览器是否支持flv.js
|
||||||
|
if (!flvjs.isSupported()) {
|
||||||
|
ElMessage.error("您的浏览器不支持FLV播放");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建WebSocket URL
|
||||||
|
const wsUrl = "ws://localhost:8888?src=" + encodeURIComponent(props.src);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建flv.js播放器实例
|
||||||
|
flvPlayer = flvjs.createPlayer(
|
||||||
|
{
|
||||||
|
type: "flv",
|
||||||
|
isLive: true,
|
||||||
|
hasAudio: false, // 先设置为false,让播放器不等待音频
|
||||||
|
hasVideo: true, // 明确指定是否有视频
|
||||||
|
url: wsUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableWorker: false,
|
||||||
|
enableStashBuffer: true,
|
||||||
|
stashInitialSize: 128,
|
||||||
|
lazyLoad: true,
|
||||||
|
lazyLoadMaxDuration: 3 * 60,
|
||||||
|
seekType: "range",
|
||||||
|
liveBufferLatencyChasing: true,
|
||||||
|
liveBufferLatencyMaxLatency: 1.5,
|
||||||
|
liveBufferLatencyMinRemain: 0.5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将播放器附加到video元素
|
||||||
|
flvPlayer.attachMediaElement(video);
|
||||||
|
|
||||||
// 监听错误事件
|
// 监听错误事件
|
||||||
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
|
flvPlayer.on(flvjs.Events.ERROR, (type, detail) => {
|
||||||
if (data.fatal) {
|
console.error("FLV播放器错误类型:", type);
|
||||||
switch (data.type) {
|
console.error("FLV播放器错误详情:", detail);
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
||||||
hlsInstance.startLoad();
|
let errorMsg = "FLV播放错误: ";
|
||||||
break;
|
switch (type) {
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case flvjs.ErrorTypes.NETWORK_ERROR:
|
||||||
hlsInstance.recoverMediaError();
|
errorMsg += "网络错误,请检查WebSocket连接";
|
||||||
break;
|
break;
|
||||||
default:
|
case flvjs.ErrorTypes.MEDIA_ERROR:
|
||||||
// 无法恢复的错误,需要重新初始化
|
errorMsg += "媒体格式错误,视频流可能不是有效的FLV格式";
|
||||||
destroyPlayer();
|
break;
|
||||||
handlePlay();
|
case flvjs.ErrorTypes.OTHER_ERROR:
|
||||||
break;
|
errorMsg += "其他错误";
|
||||||
}
|
break;
|
||||||
|
default:
|
||||||
|
errorMsg += "未知错误";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (detail && detail.msg) {
|
||||||
|
errorMsg += " (" + detail.msg + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(errorMsg);
|
||||||
});
|
});
|
||||||
|
|
||||||
hlsInstance.loadSource(props.src);
|
// 监听加载完成事件
|
||||||
hlsInstance.attachMedia(video);
|
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, () => {
|
||||||
|
console.log("FLV加载完成");
|
||||||
|
});
|
||||||
|
|
||||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
// 加载并播放
|
||||||
video.play().then(() => {
|
flvPlayer.load();
|
||||||
isPlaying.value = true;
|
video
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
ElMessage.success("视频开始播放");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
ElMessage.error("播放失败: " + err.message);
|
||||||
|
console.error("播放失败详细信息:", err);
|
||||||
});
|
});
|
||||||
});
|
} catch (error) {
|
||||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
ElMessage.error("播放器创建失败: " + error.message);
|
||||||
// Safari等原生支持HLS的浏览器
|
console.error("播放器创建异常:", error);
|
||||||
video.src = props.src;
|
|
||||||
video.addEventListener("loadedmetadata", () => {
|
|
||||||
video
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
isPlaying.value = true;
|
|
||||||
ElMessage.success("视频开始播放");
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
ElMessage.error(`播放失败:${err.message}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("您的浏览器不支持HLS视频流播放,请更换浏览器");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 销毁播放器时清除定时器
|
// 销毁播放器
|
||||||
const destroyPlayer = () => {
|
const destroyPlayer = () => {
|
||||||
// 清除定时器
|
// 销毁播放器实例
|
||||||
if (checkInterval) {
|
if (flvPlayer) {
|
||||||
clearInterval(checkInterval);
|
flvPlayer.unload();
|
||||||
checkInterval = null;
|
flvPlayer.detachMediaElement();
|
||||||
|
flvPlayer.destroy();
|
||||||
|
flvPlayer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有逻辑保持不变
|
|
||||||
isPlaying.value = false;
|
|
||||||
if (hlsInstance) {
|
|
||||||
hlsInstance.destroy();
|
|
||||||
hlsInstance = null;
|
|
||||||
}
|
|
||||||
if (videoRef.value) {
|
if (videoRef.value) {
|
||||||
const video = videoRef.value;
|
const video = videoRef.value;
|
||||||
video.pause();
|
video.pause();
|
||||||
video.src = "";
|
video.src = "";
|
||||||
}
|
}
|
||||||
handleStopTranscode();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 停止转码(调用后端接口)
|
// 组件卸载时清理资源
|
||||||
const handleStopTranscode = async () => {
|
onUnmounted(() => {
|
||||||
try {
|
destroyPlayer();
|
||||||
await stopTransCode({ id: props.videoId }); // 调用后端“停止转码”接口
|
});
|
||||||
emit("onStop"); // 通知父组件“转码已停止”
|
|
||||||
dialogVisible.value = false; // 关闭弹窗
|
// 手动关闭弹窗
|
||||||
} catch (error) {
|
const handleClose = () => {
|
||||||
if (error !== "cancel") {
|
destroyPlayer();
|
||||||
ElMessage.error(`停止转码失败:${error.message || "未知错误"}`);
|
dialogVisible.value = false;
|
||||||
}
|
emit("onStop", props.videoId);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@
|
||||||
<el-button native-type="reset" @click="fnResetPagination"
|
<el-button native-type="reset" @click="fnResetPagination"
|
||||||
>重置</el-button
|
>重置</el-button
|
||||||
>
|
>
|
||||||
<el-button type="success" @click="handleStartTranscode"
|
|
||||||
>播放后台转码视频</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
@ -111,7 +108,6 @@
|
||||||
v-model:visible="data.transcodeVideoDialog.visible"
|
v-model:visible="data.transcodeVideoDialog.visible"
|
||||||
:src="data.transcodeVideoDialog.src"
|
:src="data.transcodeVideoDialog.src"
|
||||||
:video-id="data.transcodeVideoDialog.id"
|
:video-id="data.transcodeVideoDialog.id"
|
||||||
@on-stop="onTranscodeStopped"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -131,7 +127,6 @@ import SelectingPoints from "./components/selecting_points.vue";
|
||||||
import LayoutVideo from "@/components/video/index.vue";
|
import LayoutVideo from "@/components/video/index.vue";
|
||||||
import {setVideoManagerList} from "@/request/eightwork_videomanager.js";
|
import {setVideoManagerList} from "@/request/eightwork_videomanager.js";
|
||||||
import PlayVideo from "@/views/video_manager/video_manager/components/playVideo.vue"; // 引入新播放器组件
|
import PlayVideo from "@/views/video_manager/video_manager/components/playVideo.vue"; // 引入新播放器组件
|
||||||
import {startTransCode, stopTransCode} from "@/request/video_info.js"; // 转码接口
|
|
||||||
|
|
||||||
const data = reactive({
|
const data = reactive({
|
||||||
addDialog: {
|
addDialog: {
|
||||||
|
|
@ -157,26 +152,11 @@ const data = reactive({
|
||||||
},
|
},
|
||||||
transcodeVideoDialog: {
|
transcodeVideoDialog: {
|
||||||
visible: false,
|
visible: false,
|
||||||
src: "http://localhost:8100/api/hls/stream.m3u8", // 后端转码后HLS流的访问路径
|
src: "", // 默认为空
|
||||||
id: "",
|
id: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动转码并显示播放器
|
|
||||||
const handleStartTranscode = async () => {
|
|
||||||
try {
|
|
||||||
await startTransCode(); // 调用后端“启动转码”接口
|
|
||||||
data.transcodeVideoDialog.visible = true; // 显示播放器弹窗
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error("启动转码失败: " + (error.message || "未知错误"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 转码停止后的回调
|
|
||||||
const onTranscodeStopped = async () => {
|
|
||||||
await stopTransCode(); // 调用后端“停止转码”接口
|
|
||||||
};
|
|
||||||
|
|
||||||
// 列表数据逻辑(保持不变)
|
// 列表数据逻辑(保持不变)
|
||||||
const {list, pagination, searchForm, fnGetData, fnResetPagination} =
|
const {list, pagination, searchForm, fnGetData, fnResetPagination} =
|
||||||
useListData(getHkVideoManagerList);
|
useListData(getHkVideoManagerList);
|
||||||
|
|
@ -201,13 +181,12 @@ const fnUpToBi = async (videomanagerId) => {
|
||||||
|
|
||||||
const fnPreviewVideo = async (row) => {
|
const fnPreviewVideo = async (row) => {
|
||||||
try {
|
try {
|
||||||
const resData = await startTransCode({url: row.url, id: row.PLS_ID}); // 调用后端“启动转码”接口
|
// 直接使用RTSP URL,不需要调用startTransCode接口
|
||||||
data.transcodeVideoDialog.visible = true; // 显示播放器弹窗
|
data.transcodeVideoDialog.visible = true; // 显示播放器弹窗
|
||||||
data.transcodeVideoDialog.id = row.PLS_ID; // 显示播放器弹窗
|
data.transcodeVideoDialog.id = row.PLS_ID; // 设置视频ID
|
||||||
data.transcodeVideoDialog.src =
|
data.transcodeVideoDialog.src = row.url; // 直接使用RTSP URL
|
||||||
"http://172.16.70.226:7811/" + resData.videoUrl + "stream.m3u8";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error("启动转码失败: " + (error.message || "未知错误"));
|
ElMessage.error("播放视频失败: " + (error.message || "未知错误"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -233,4 +212,4 @@ const fnDeleteVideo = async (row) => {
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
Loading…
Reference in New Issue