20260110:
1、视频流关闭兜底方案,离开页面(分屏/直播)马上关闭视频流
This commit is contained in:
parent
241fcb0333
commit
d8c7c19367
@ -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 # 同上,另外增加编号,(可保持默认)
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "最近三十天",
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user