20260110:

1、视频流关闭兜底方案,离开页面(分屏/直播)马上关闭视频流
This commit is contained in:
George 2026-01-10 23:51:09 +08:00
parent 241fcb0333
commit d8c7c19367
15 changed files with 213 additions and 27 deletions

View File

@ -129,10 +129,10 @@ emqx:
ApiSecret: emqx.api.123
# sip 配置
sip:
enabled: false # 是否启用视频监控SIP,true为启用
enabled: true # 是否启用视频监控SIP,true为启用
## 本地调试时,绑定网卡局域网IP,设备在同一局域网,设备接入IP填写绑定IP
## 部署服务端时,默认绑定容器IP,设备接入IP填写服务器公网IP
ip: 192.168.110.78 # 本服务启动的 IP
ip: 192.168.110.160 # 本服务启动的 IP
port: 5061 # SIP端口(保持默认)
domain: 3402000000 # 由省级、市级、区级、基层编号组成
id: 34020000002000000001 # 同上,另外增加编号,(可保持默认)

View File

@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@ -30,6 +31,12 @@ public class PlayerController extends BaseController {
return AjaxResult.success(MessageUtils.message("success"), playService.play(deviceId, channelId, false));
}
@ApiOperation("获取直播流信息(流就绪后返回)")
@GetMapping("/playstream/{deviceId}/{channelId}")
public AjaxResult playstream(@PathVariable String deviceId, @PathVariable String channelId) {
return AjaxResult.success(MessageUtils.message("success"), playService.getStreamInfo(deviceId, channelId));
}
@ApiOperation("回放播放")
@GetMapping("/playback/{deviceId}/{channelId}")
public AjaxResult playback(@PathVariable String deviceId,
@ -39,7 +46,13 @@ public class PlayerController extends BaseController {
@ApiOperation("停止推流")
@GetMapping("/closeStream/{deviceId}/{channelId}/{streamId}")
public AjaxResult closeStream(@PathVariable String deviceId, @PathVariable String channelId, @PathVariable String streamId) {
public AjaxResult closeStream(@PathVariable String deviceId,
@PathVariable String channelId,
@PathVariable String streamId,
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force) {
if (force) {
return AjaxResult.success("success!", playService.closeStreamForce(deviceId, channelId, streamId));
}
return AjaxResult.success("success!", playService.closeStream(deviceId, channelId, streamId));
}

View File

@ -6,10 +6,21 @@ public interface IPlayService {
Stream play(String deviceId, String channelId, boolean record);
/**
* 获取流信息(仅当流已在ZLM侧就绪/存在时返回)
*/
Stream getStreamInfo(String deviceId, String channelId);
Stream playback(String deviceId, String channelId, String startTime, String endTime);
String closeStream(String deviceId, String channelId, String streamId);
/**
* 强制关闭流(直接发送BYE,不依赖ZLM的readerCount查询)
* 适用于ZLM明确回调“无人观看”的场景。
*/
String closeStreamForce(String deviceId, String channelId, String streamId);
String playbackPause(String deviceId, String channelId, String streamId);
String playbackReplay(String deviceId, String channelId, String streamId);

View File

@ -220,6 +220,11 @@ public class MediaServerServiceImpl implements IMediaServerService {
param.put("hook.enable", "1");
param.put("general.mediaServerId", mediaServer.getServerId());
param.put("general.flowThreshold", "64");
// 无人观看多久后触发 on_stream_none_reader(单位ms)。值越小关流越快,但也更容易受短暂抖动影响。
// 这里给一个偏保守的默认值(5s),可按现场需要调整。
param.put("general.streamNoneReaderDelayMS", "5000");
// 等待流注册的最大时间(单位ms),适当增大可降低“刚开始播放偶发失败需要刷新”的概率
param.put("general.maxStreamWaitMS", "5000");
param.put("hook.on_play", String.format("%s/on_play", hookPrex));
param.put("hook.on_http_access", String.format("%s/on_http_access", hookPrex));
param.put("hook.on_publish", String.format("%s/on_publish", hookPrex));

View File

@ -3,6 +3,7 @@ package com.fastbee.sip.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.sip.domain.MediaServer;
import com.fastbee.sip.domain.SipDevice;
import com.fastbee.sip.enums.SessionType;
import com.fastbee.sip.model.Stream;
import com.fastbee.sip.model.VideoSessionInfo;
import com.fastbee.sip.server.IRtspCmd;
@ -92,6 +93,26 @@ public class PlayServiceImpl implements IPlayService {
}
}
@Override
public Stream getStreamInfo(String deviceId, String channelId) {
String streamId = String.format("%s_%s_%s", SipUtil.PREFIX_PLAY, deviceId, channelId);
VideoSessionInfo sinfo = streamSession.getSessionInfo(deviceId, channelId, streamId, SessionType.play.name());
if (sinfo == null) {
return null;
}
MediaServer mediaInfo = mediaServerService.selectMediaServerBydeviceSipId(deviceId);
if (mediaInfo == null) {
log.error("getStreamInfo mediaInfo is null,deviceId:{},channelId:{}", deviceId, channelId);
return null;
}
JSONObject rtpInfo = zlmApiUtils.getRtpInfo(mediaInfo, streamId);
if (rtpInfo != null && rtpInfo.getInteger("code") == 0 && Boolean.TRUE.equals(rtpInfo.getBoolean("exist"))) {
// 流在ZLM侧已存在:返回播放地址
return zmlHookService.updateStream(sinfo);
}
return null;
}
@Override
public String closeStream(String deviceId, String channelId, String streamId) {
MediaServer mediaInfo = mediaServerService.selectMediaServerBydeviceSipId(deviceId);
@ -110,6 +131,22 @@ public class PlayServiceImpl implements IPlayService {
return "";
}
@Override
public String closeStreamForce(String deviceId, String channelId, String streamId) {
SipDevice dev = sipDeviceService.selectSipDeviceBySipId(deviceId);
try {
if (dev != null) {
sipCmd.streamByeCmd(dev, channelId, streamId, null);
} else {
// 兜底:按deviceId尝试关闭
sipCmd.streamByeCmd(deviceId, channelId, streamId, null);
}
} catch (Exception e) {
log.warn("closeStreamForce error, deviceId:{}, channelId:{}, streamId:{}", deviceId, channelId, streamId, e);
}
return "";
}
@Override
public Stream playback(String deviceId, String channelId, String startTime, String endTime) {
SipDevice dev = sipDeviceService.selectSipDeviceBySipId(deviceId);

View File

@ -106,7 +106,6 @@ public class ZmlHookServiceImpl implements IZmlHookService {
@Override
public JSONObject onPublish(JSONObject json) {
log.info("onPublish:{}", json);
String id = json.getString("id");
String mediaServerId = json.getString("mediaServerId");
String schema = json.getString("schema");
String app = json.getString("app");
@ -188,6 +187,18 @@ public class ZmlHookServiceImpl implements IZmlHookService {
break;
default:
ret.put("close", true);
// 兜底:无人观看时,主动触发关闭(BYE)并清理会话,避免 pushing 状态残留导致设备持续上行耗流量
try {
// 此回调本身就是“无人观看”,无需再依赖readerCount判断,直接BYE更可靠
playService.closeStreamForce(sinfo.getDeviceId(), sinfo.getChannelId(), streamId);
sinfo.setPushing(false);
sinfo.setRecording(false);
sinfo.setPlayer(0);
sinfo.setOnPlaytime(0);
videoSessionManager.put(sinfo, null);
} catch (Exception e) {
log.warn("[ZLM HOOK] 无人观看关闭流失败: {}/{} stream={}", sinfo.getDeviceId(), sinfo.getChannelId(), streamId, e);
}
}
}
return ret;
@ -249,7 +260,6 @@ public class ZmlHookServiceImpl implements IZmlHookService {
@Override
public JSONObject onFlowReport(JSONObject json) {
String id = json.getString("id");
String mediaServerId = json.getString("mediaServerId");
String schema = json.getString("schema");
String app = json.getString("app");

View File

@ -67,10 +67,11 @@ export function playback(deviceId, channelId, query) {
});
}
export function closeStream(deviceId, channelId, streamId) {
export function closeStream(deviceId, channelId, streamId, force = false) {
return request({
url: '/sip/player/closeStream/' + deviceId + '/' + channelId + '/' + streamId,
method: 'get',
params: { force },
});
}

View File

@ -8,6 +8,7 @@
"dataCenter.analysis.349202-6": "Reset",
"dataCenter.analysis.349202-7": "There is currently no data available",
"dataCenter.analysis.349202-8": "Last 2 hours",
"dataCenter.analysis.384934-25": "Last 2 days",
"dataCenter.analysis.349202-9": "Last day",
"dataCenter.analysis.349202-10": "In the past seven days",
"dataCenter.analysis.349202-11": "Last 30 days",

View File

@ -8,6 +8,7 @@
"dataCenter.analysis.349202-6": "重置",
"dataCenter.analysis.349202-7": "暂无数据",
"dataCenter.analysis.349202-8": "最近2小时",
"dataCenter.analysis.384934-25": "最近两天",
"dataCenter.analysis.349202-9": "最近一天",
"dataCenter.analysis.349202-10": "最近七天",
"dataCenter.analysis.349202-11": "最近三十天",

View File

@ -117,10 +117,33 @@ export default {
},
startPlayRecord() {
console.log('录像状态: [' + this.playrecord + ']');
// 开启录像时二次确认:提示会产生大量流量/存储占用
if (this.playrecord) {
this.$confirm(
'开启【直播录像】将持续产生较大上行流量,并占用服务器存储/带宽。\n\n建议:使用完务必关闭,否则可能导致流量快速耗尽。',
'温馨提示',
{
confirmButtonText: '继续开启',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: false,
}
)
.then(() => {
this.closeDestroy(true);
setTimeout(() => {
this.startPlayer();
}, 500);
})
.catch(() => {
// 用户取消:回滚开关状态
this.playrecord = false;
});
return;
}
// 关闭录像:直接停流
this.closeDestroy(true);
},
// 开启直播播放器
startPlayer() {
@ -162,7 +185,7 @@ export default {
if (force) {
if (this.playing && this.streamId) {
console.log('强制关闭推流: [' + this.streamId + ']');
closeStream(this.deviceId, this.channelId, this.streamId).then((res) => {
closeStream(this.deviceId, this.channelId, this.streamId, true).then((res) => {
this.streamId = '';
this.ssrc = '';
this.playurl = '';
@ -177,7 +200,7 @@ export default {
}
if (this.playing && this.streamId) {
console.log('关闭推流: [' + this.streamId + ']');
closeStream(this.deviceId, this.channelId, this.streamId).then((res) => {
closeStream(this.deviceId, this.channelId, this.streamId, false).then((res) => {
this.streamId = '';
this.ssrc = '';
this.playurl = '';

View File

@ -246,7 +246,7 @@ export default {
/**关闭播放流*/
closeStream() {
if (this.playing && this.streamId) {
closeStream(this.deviceId, this.channelId, this.streamId).then((res) => {
closeStream(this.deviceId, this.channelId, this.streamId, true).then((res) => {
this.streamId = '';
this.ssrc = '';
this.playurl = '';

View File

@ -180,6 +180,15 @@ export default {
picker.$emit('pick', [start, end]);
},
},
{
text: this.$t('dataCenter.analysis.384934-25'),
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 2);
picker.$emit('pick', [start, end]);
},
},
{
text: this.$t('dataCenter.history.384934-21'),
onClick(picker) {

View File

@ -115,7 +115,7 @@ export default {
const _this = this;
this.sipList.forEach((item, index) => {
if (_this.tempSipList[index].streamId) {
closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId).then((res) => {});
closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId, true).then((res) => {});
}
_this.$refs[`player_${index}`][0] && _this.$refs[`player_${index}`][0].close();
});

View File

@ -36,7 +36,7 @@
<script>
import player from '@/views/components/player/jessibuca.vue';
import DeviceTree from '@/views/components/player/DeviceTree.vue';
import { startPlay } from '@/api/iot/channel';
import { startPlay, closeStream } from '@/api/iot/channel';
export default {
name: 'SplitView',
@ -44,6 +44,10 @@ export default {
player,
DeviceTree,
},
beforeRouteLeave(to, from, next) {
this.closeAllSessions(true);
next();
},
data() {
return {
videoUrl: [''],
@ -53,6 +57,11 @@ export default {
count: 15,
total: 0,
loading: false,
/**
* 分屏播放信息缓存(按格子索引 0..spilt-1)
* { deviceId, channelId, streamId, playUrl }
*/
playSessions: [],
};
},
mounted() {},
@ -83,6 +92,8 @@ export default {
watch: {
spilt(newValue) {
console.log('切换画幅;' + newValue);
// 缩小分屏时,主动关闭被裁掉的格子,避免流量泄漏
this.closeSessionsFromIndex(newValue);
let that = this;
for (let i = 1; i <= newValue; i++) {
if (!that.$refs['player' + i]) {
@ -102,11 +113,17 @@ export default {
},
destroyed() {
clearTimeout(this.updateLooper);
this.closeAllSessions(true);
},
methods: {
destroy(idx) {
console.log(idx);
this.clear(idx.substring(idx.length - 1));
const last = idx && idx.substring ? idx.substring(idx.length - 1) : '';
const num = parseInt(last, 10);
if (!isNaN(num)) {
this.closeSessionByIndex(num - 1, true);
this.clear(num);
}
},
clickEvent: function (data) {
if (data.channelSipId) {
@ -115,13 +132,17 @@ export default {
},
//通知设备上传媒体流
sendDevicePush: function (itemData) {
this.save(itemData);
let deviceId = itemData.deviceSipId;
let channelId = itemData.channelSipId;
// 注意:DeviceTree 传进来的字段名是 deviceSipId/channelSipId
const deviceId = itemData.deviceSipId || itemData.deviceId;
const channelId = itemData.channelSipId || itemData.channelId;
console.log('通知设备推流1:' + deviceId + ' : ' + channelId);
let idxTmp = this.playerIdx;
let that = this;
this.loading = true;
// 重要:不要在 startPlay 成功之前就关闭旧流,否则“偶发拉流失败”会导致用户需要二次点击
const oldSession = this.playSessions && this.playSessions[idxTmp] ? { ...this.playSessions[idxTmp] } : null;
startPlay(deviceId, channelId)
.then((response) => {
console.log('开始播放:' + this.deviceId + ' : ' + this.channelId);
@ -129,13 +150,24 @@ export default {
let res = response.data;
console.log('playurl:' + res.playurl);
console.log('protocol:' + window.location.protocol);
if (window.location.protocol === 'http:') {
itemData.playUrl = res.ws_flv;
} else {
itemData.playUrl = res.wss_flv;
const streamId = res.streamId;
const playUrl = window.location.protocol === 'http:' ? res.ws_flv : res.wss_flv;
// 记录会话(用于离开页面/销毁/切换格子时 closeStream)
that.saveSession(idxTmp, {
deviceId,
channelId,
streamId,
playUrl,
});
// 直接播放:尽量减少改动与副作用(播放器自身有超时重试)
that.setPlayUrl(playUrl, idxTmp);
// 新流拉起后再强制关闭旧流(force=true),避免误判 readerCount 导致旧流持续耗流量
if (oldSession && oldSession.streamId && oldSession.streamId !== streamId) {
closeStream(oldSession.deviceId, oldSession.channelId, oldSession.streamId, true);
}
itemData.streamId = res.streamId;
that.setPlayUrl(itemData.playUrl, idxTmp);
})
.finally(() => {
that.loading = false;
@ -189,6 +221,49 @@ export default {
console.log(data);
window.localStorage.setItem('playData', JSON.stringify(data));
},
saveSession(idx, session) {
if (!this.playSessions) this.playSessions = [];
this.$set(this.playSessions, idx, session);
window.localStorage.setItem('playSessions', JSON.stringify(this.playSessions));
},
async closeSessionByIndex(idx, force) {
const session = this.playSessions && this.playSessions[idx];
if (!session || !session.streamId) {
// 即使没有会话,也清掉 URL 防止误显示
if (this.videoUrl && this.videoUrl[idx]) {
this.$set(this.videoUrl, idx, '');
}
return;
}
try {
// force=true:减少依赖 readerCount/回调时序导致的“关流失败”
await closeStream(session.deviceId, session.channelId, session.streamId, !!force);
} catch (e) {
// ignore
} finally {
this.$set(this.playSessions, idx, null);
window.localStorage.setItem('playSessions', JSON.stringify(this.playSessions));
if (this.videoUrl && this.videoUrl[idx]) {
this.$set(this.videoUrl, idx, '');
}
}
},
closeSessionsFromIndex(newSpilt) {
if (!this.playSessions || this.playSessions.length === 0) return;
for (let i = newSpilt; i < this.playSessions.length; i++) {
this.closeSessionByIndex(i, true);
}
},
closeAllSessions(force) {
if (!this.playSessions || this.playSessions.length === 0) {
window.localStorage.removeItem('playSessions');
return;
}
for (let i = 0; i < this.playSessions.length; i++) {
this.closeSessionByIndex(i, force);
}
window.localStorage.removeItem('playSessions');
},
},
};
</script>

View File

@ -112,7 +112,7 @@ export default {
const _this = this;
this.sipList.forEach((item, index) => {
if (_this.tempSipList[index].streamId) {
closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId).then((res) => {});
closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId, true).then((res) => {});
}
_this.$refs[`player_${index}`][0] && _this.$refs[`player_${index}`][0].close();
});