支持通用通道喊话

This commit is contained in:
lin 2026-06-15 16:24:30 +08:00
parent 58cc8a8baf
commit f6ca930492
14 changed files with 810 additions and 42 deletions

View File

@ -16,6 +16,7 @@ public class ChannelDataType {
public final static String DOWNLOAD_SERVICE = "sourceChannelDownloadService";
public final static String PTZ_SERVICE = "sourceChannelPTZService";
public final static String OTHER_SERVICE = "sourceChannelOtherService";
public final static String BROADCAST_SERVICE = "sourceChannelBroadcastService";
public static String getDateTypeDesc(Integer dataType) {

View File

@ -12,6 +12,7 @@ import com.genersoft.iot.vmp.gb28181.utils.VectorTileCatch;
import com.genersoft.iot.vmp.service.bean.ErrorCallback;
import com.genersoft.iot.vmp.service.bean.InviteErrorCode;
import com.genersoft.iot.vmp.utils.DateUtil;
import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult;
import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
import com.genersoft.iot.vmp.vmanager.bean.StreamContent;
import com.genersoft.iot.vmp.vmanager.bean.WVPResult;
@ -352,6 +353,42 @@ public class ChannelController {
channelPlayService.stopPlay(channel);
}
@Operation(summary = "开始对讲", security = @SecurityRequirement(name = JwtUtils.HEADER))
@GetMapping("/talk/start")
public AudioTalkResult startTalk(Integer channelId){
Assert.notNull(channelId,"参数异常");
CommonGBChannel channel = channelService.getOne(channelId);
Assert.notNull(channel, "通道不存在");
return channelPlayService.startTalk(channel);
}
@Operation(summary = "停止对讲", security = @SecurityRequirement(name = JwtUtils.HEADER))
@GetMapping("/talk/stop")
public void stopTalk(Integer channelId){
Assert.notNull(channelId,"参数异常");
CommonGBChannel channel = channelService.getOne(channelId);
Assert.notNull(channel, "通道不存在");
channelPlayService.stopTalk(channel);
}
@Operation(summary = "开始喊话", security = @SecurityRequirement(name = JwtUtils.HEADER))
@GetMapping("/broadcast/start")
public AudioTalkResult startBroadcast(Integer channelId){
Assert.notNull(channelId,"参数异常");
CommonGBChannel channel = channelService.getOne(channelId);
Assert.notNull(channel, "通道不存在");
return channelPlayService.startBroadcast(channel);
}
@Operation(summary = "停止喊话", security = @SecurityRequirement(name = JwtUtils.HEADER))
@GetMapping("/broadcast/stop")
public void stopBroadcast(Integer channelId){
Assert.notNull(channelId,"参数异常");
CommonGBChannel channel = channelService.getOne(channelId);
Assert.notNull(channel, "通道不存在");
channelPlayService.stopBroadcast(channel);
}
@Operation(summary = "录像查询", security = @SecurityRequirement(name = JwtUtils.HEADER))
@Parameter(name = "channelId", description = "通道ID", required = true)
@Parameter(name = "startTime", description = "开始时间", required = true)

View File

@ -7,6 +7,7 @@ import com.genersoft.iot.vmp.gb28181.bean.CommonRecordInfo;
import com.genersoft.iot.vmp.gb28181.bean.InviteMessageInfo;
import com.genersoft.iot.vmp.gb28181.bean.Platform;
import com.genersoft.iot.vmp.service.bean.ErrorCallback;
import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult;
import java.util.List;
@ -41,4 +42,12 @@ public interface IGbChannelPlayService {
void getSnap(CommonGBChannel channel, ErrorCallback<byte[]> callback);
AudioTalkResult startTalk(CommonGBChannel channel);
void stopTalk(CommonGBChannel channel);
AudioTalkResult startBroadcast(CommonGBChannel channel);
void stopBroadcast(CommonGBChannel channel);
}

View File

@ -0,0 +1,18 @@
package com.genersoft.iot.vmp.gb28181.service;
import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel;
import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult;
/**
* 资源能力接入-语音对讲
*/
public interface ISourceBroadcastService {
AudioTalkResult startTalk(CommonGBChannel channel);
void stopTalk(CommonGBChannel channel);
AudioTalkResult startBroadcast(CommonGBChannel channel);
void stopBroadcast(CommonGBChannel channel);
}

View File

@ -7,9 +7,11 @@ import com.genersoft.iot.vmp.conf.UserSetting;
import com.genersoft.iot.vmp.gb28181.bean.*;
import com.genersoft.iot.vmp.gb28181.dao.CommonGBChannelMapper;
import com.genersoft.iot.vmp.gb28181.service.IGbChannelPlayService;
import com.genersoft.iot.vmp.gb28181.service.ISourceBroadcastService;
import com.genersoft.iot.vmp.gb28181.service.ISourceDownloadService;
import com.genersoft.iot.vmp.gb28181.service.ISourcePlayService;
import com.genersoft.iot.vmp.gb28181.service.ISourcePlaybackService;
import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult;
import com.genersoft.iot.vmp.service.bean.ErrorCallback;
import com.genersoft.iot.vmp.service.bean.InviteErrorCode;
import lombok.extern.slf4j.Slf4j;
@ -39,6 +41,9 @@ public class GbChannelPlayServiceImpl implements IGbChannelPlayService {
@Autowired
private Map<String, ISourceDownloadService> sourceDownloadServiceMap;
@Autowired
private Map<String, ISourceBroadcastService> sourceBroadcastServiceMap;
@Override
public void startInvite(CommonGBChannel channel, InviteMessageInfo inviteInfo, Platform platform, ErrorCallback<StreamInfo> callback) {
@ -247,4 +252,52 @@ public class GbChannelPlayServiceImpl implements IGbChannelPlayService {
}
sourceChannelPlayService.getSnap(channel, callback);
}
@Override
public AudioTalkResult startTalk(CommonGBChannel channel) {
log.info("[通用通道] 开始对讲, 类型: {} 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId());
Integer dataType = channel.getDataType();
ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType);
if (broadcastService == null) {
log.error("[通用通道] 类型编号: {} 不支持对讲", dataType);
throw new PlayException(Response.BUSY_HERE, "channel not support");
}
return broadcastService.startTalk(channel);
}
@Override
public void stopTalk(CommonGBChannel channel) {
log.info("[通用通道] 停止对讲, 类型: {} 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId());
Integer dataType = channel.getDataType();
ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType);
if (broadcastService == null) {
log.error("[通用通道] 类型编号: {} 不支持对讲", dataType);
throw new PlayException(Response.BUSY_HERE, "channel not support");
}
broadcastService.stopTalk(channel);
}
@Override
public AudioTalkResult startBroadcast(CommonGBChannel channel) {
log.info("[通用通道] 开始喊话, 类型: {} 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId());
Integer dataType = channel.getDataType();
ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType);
if (broadcastService == null) {
log.error("[通用通道] 类型编号: {} 不支持喊话", dataType);
throw new PlayException(Response.BUSY_HERE, "channel not support");
}
return broadcastService.startBroadcast(channel);
}
@Override
public void stopBroadcast(CommonGBChannel channel) {
log.info("[通用通道] 停止喊话, 类型: {} 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId());
Integer dataType = channel.getDataType();
ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType);
if (broadcastService == null) {
log.error("[通用通道] 类型编号: {} 不支持喊话", dataType);
throw new PlayException(Response.BUSY_HERE, "channel not support");
}
broadcastService.stopBroadcast(channel);
}
}

View File

@ -0,0 +1,101 @@
package com.genersoft.iot.vmp.gb28181.service.impl;
import com.genersoft.iot.vmp.common.enums.ChannelDataType;
import com.genersoft.iot.vmp.common.enums.MediaStreamUtil;
import com.genersoft.iot.vmp.conf.UserSetting;
import com.genersoft.iot.vmp.conf.exception.ControllerException;
import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel;
import com.genersoft.iot.vmp.gb28181.bean.Device;
import com.genersoft.iot.vmp.gb28181.bean.DeviceChannel;
import com.genersoft.iot.vmp.gb28181.service.IDeviceChannelService;
import com.genersoft.iot.vmp.gb28181.service.IDeviceService;
import com.genersoft.iot.vmp.gb28181.service.IPlayService;
import com.genersoft.iot.vmp.gb28181.service.ISourceBroadcastService;
import com.genersoft.iot.vmp.media.bean.MediaServer;
import com.genersoft.iot.vmp.media.service.IMediaServerService;
import com.genersoft.iot.vmp.vmanager.bean.AudioBroadcastResult;
import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult;
import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
import com.genersoft.iot.vmp.vmanager.bean.StreamContent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Slf4j
@Service(ChannelDataType.BROADCAST_SERVICE + ChannelDataType.GB28181)
public class SourceBroadcastServiceForGbImpl implements ISourceBroadcastService {
@Autowired
private IPlayService playService;
@Autowired
private IDeviceService deviceService;
@Autowired
private IDeviceChannelService deviceChannelService;
@Autowired
private IMediaServerService mediaServerService;
@Autowired
private UserSetting userSetting;
@Override
public AudioTalkResult startBroadcast(CommonGBChannel channel) {
Device device = deviceService.getDevice(channel.getDataDeviceId());
if (device == null) {
throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到设备");
}
DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId());
if (deviceChannel == null) {
throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到通道");
}
AudioBroadcastResult abResult = playService.audioBroadcast(
device.getDeviceId(), deviceChannel.getDeviceId(), true);
AudioTalkResult result = new AudioTalkResult();
result.setPushStream(abResult.getStreamInfo());
result.setPlayStream(null);
return result;
}
@Override
public void stopBroadcast(CommonGBChannel channel) {
Device device = deviceService.getDevice(channel.getDataDeviceId());
if (device == null) return;
DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId());
if (deviceChannel == null) return;
playService.stopAudioBroadcast(device, deviceChannel);
}
@Override
public AudioTalkResult startTalk(CommonGBChannel channel) {
Device device = deviceService.getDevice(channel.getDataDeviceId());
if (device == null) {
throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到设备");
}
DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId());
if (deviceChannel == null) {
throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到通道");
}
AudioBroadcastResult abResult = playService.audioBroadcast(
device.getDeviceId(), deviceChannel.getDeviceId(), false);
MediaServer mediaServer = mediaServerService.getMediaServerForMinimumLoad(null);
StreamContent playStream = new StreamContent(
mediaServerService.getStreamInfoByAppAndStream(mediaServer,
MediaStreamUtil.GB28181_TALK, abResult.getStream() + "_talk",
null, null, null, false));
AudioTalkResult result = new AudioTalkResult();
result.setPushStream(abResult.getStreamInfo());
result.setPlayStream(playStream);
return result;
}
@Override
public void stopTalk(CommonGBChannel channel) {
Device device = deviceService.getDevice(channel.getDataDeviceId());
if (device == null) return;
DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId());
if (deviceChannel == null) return;
playService.stopTalk(device, deviceChannel, null);
}
}

View File

@ -0,0 +1,15 @@
package com.genersoft.iot.vmp.vmanager.bean;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "对讲信息")
public class AudioTalkResult {
@Schema(description = "推流地址(浏览器 WebRTC推流到ZLM")
private StreamContent pushStream;
@Schema(description = "播放地址设备音频通过ZLM播放给浏览器喊话时为null")
private StreamContent playStream;
}

View File

@ -0,0 +1,100 @@
package com.genersoft.iot.vmp.gb28181.bean;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DeviceAlarmNotifyTest {
@Test
void fromXml_withoutAlarmType_shouldNotThrowNpe() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Notify>
<DeviceID>55123456781381000010</DeviceID>
<AlarmPriority>1</AlarmPriority>
<AlarmMethod>7</AlarmMethod>
<AlarmTime>2026-06-05T09:46:05</AlarmTime>
<AlarmDescription>1001,1780623964994529058,55123456781381000010,25123456781381000050,55LCPCweb10</AlarmDescription>
<Longitude>0.0</Longitude>
<Latitude>0.0</Latitude>
</Notify>
""";
Element root = DocumentHelper.parseText(xml).getRootElement();
DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root);
assertNotNull(notify);
assertEquals(Integer.valueOf(7), notify.getAlarmMethod());
assertNull(notify.getAlarmType(), "AlarmType should be null when not present in XML");
// Simulate the exact code path from AlarmNotifyMessageHandler.executeTaskQueue lines 131-141
// which was causing the NPE
AlarmChannelMessage alarmChannelMessage = new AlarmChannelMessage();
assertDoesNotThrow(() -> {
alarmChannelMessage.setAlarmType(notify.getAlarmType());
alarmChannelMessage.setAlarmSn(notify.getAlarmMethod());
alarmChannelMessage.setAlarmDescription(notify.getAlarmDescription());
alarmChannelMessage.setGbId(notify.getChannelId());
}, "setAlarmType(null) should not throw NPE when field type is Integer");
assertNull(alarmChannelMessage.getAlarmType());
assertEquals(Integer.valueOf(7), alarmChannelMessage.getAlarmSn());
}
@Test
void fromXml_withAlarmType_shouldParseCorrectly() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Notify>
<DeviceID>34020000001320000001</DeviceID>
<AlarmPriority>1</AlarmPriority>
<AlarmMethod>2</AlarmMethod>
<AlarmTime>2026-06-05T10:30:00</AlarmTime>
<AlarmDescription>Video loss alarm</AlarmDescription>
<Longitude>116.397</Longitude>
<Latitude>39.908</Latitude>
<AlarmType>1</AlarmType>
</Notify>
""";
Element root = DocumentHelper.parseText(xml).getRootElement();
DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root);
assertNotNull(notify);
assertEquals(Integer.valueOf(2), notify.getAlarmMethod());
assertEquals(Integer.valueOf(1), notify.getAlarmType());
AlarmChannelMessage msg = new AlarmChannelMessage();
assertDoesNotThrow(() -> msg.setAlarmType(notify.getAlarmType()));
assertEquals(Integer.valueOf(1), msg.getAlarmType());
}
@Test
void fromXml_withAlarmTypeInInfo_shouldUseInfoValue() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Notify>
<DeviceID>34020000001320000001</DeviceID>
<AlarmPriority>1</AlarmPriority>
<AlarmMethod>5</AlarmMethod>
<AlarmTime>2026-06-05T10:30:00</AlarmTime>
<AlarmDescription>Motion detection</AlarmDescription>
<Longitude>116.397</Longitude>
<Latitude>39.908</Latitude>
<AlarmType>9</AlarmType>
<Info>
<AlarmType>2</AlarmType>
</Info>
</Notify>
""";
Element root = DocumentHelper.parseText(xml).getRootElement();
DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root);
assertNotNull(notify);
assertEquals(Integer.valueOf(2), notify.getAlarmType(),
"AlarmType should use Info/AlarmType value when present");
}
}

View File

@ -0,0 +1,66 @@
package com.genersoft.iot.vmp.jt1078.dao.provider;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class JTChannelProviderTest {
private final JTChannelProvider provider = new JTChannelProvider();
@Test
void selectAll_withQuery_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("terminalDbId", 1);
params.put("query", "test-channel");
String sql = provider.selectAll(params);
assertTrue(sql.contains("#{query}"), "should use #{query} bind variable");
assertFalse(sql.contains("test-channel"), "should not contain raw query value");
assertTrue(sql.contains("concat('%',#{query},'%')"), "should use concat with bind variable");
assertTrue(sql.contains("#{terminalDbId}"), "should use #{terminalDbId} bind variable");
}
@Test
void selectAll_withoutQuery_shouldNotContainLike() {
Map<String, Object> params = new HashMap<>();
params.put("terminalDbId", 1);
String sql = provider.selectAll(params);
assertFalse(sql.contains("LIKE"), "should not contain LIKE clause when no query");
assertTrue(sql.contains("#{terminalDbId}"), "should still have terminalDbId condition");
}
@Test
void selectChannelByChannelId_shouldUseBindVariables() {
Map<String, Object> params = new HashMap<>();
params.put("terminalDbId", 5);
params.put("channelId", 100);
String sql = provider.selectChannelByChannelId(params);
assertTrue(sql.contains("#{terminalDbId}"), "should use #{terminalDbId}");
assertTrue(sql.contains("#{channelId}"), "should use #{channelId}");
}
@Test
void selectChannelById_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("id", 42);
String sql = provider.selectChannelById(params);
assertTrue(sql.contains("#{id}"), "should use #{id} bind variable");
}
@Test
void selectAll_shouldOrderByChannelId() {
Map<String, Object> params = new HashMap<>();
params.put("terminalDbId", 1);
String sql = provider.selectAll(params);
assertTrue(sql.contains("ORDER BY jc.channel_id"), "should order by channel_id");
}
@Test
void baseSql_shouldHaveJoins() {
assertTrue(JTChannelProvider.BASE_SQL.contains("LEFT join wvp_device_channel"), "should have LEFT JOIN");
assertTrue(JTChannelProvider.BASE_SQL.contains("wvp_jt_channel"), "should query from jt_channel");
}
}

View File

@ -0,0 +1,95 @@
package com.genersoft.iot.vmp.streamProxy.dao.provider;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class StreamProxyProviderTest {
private final StreamProxyProvider provider = new StreamProxyProvider();
@Test
void select_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("id", 123);
String sql = provider.select(params);
assertTrue(sql.contains("#{id}"), "should use #{id} bind variable");
assertFalse(sql.contains("123"), "should not contain raw value");
assertTrue(sql.contains("WHERE st.id = #{id}"), "should have proper WHERE clause");
}
@Test
void selectOneByAppAndStream_shouldUseBindVariables() {
Map<String, Object> params = new HashMap<>();
params.put("app", "testApp");
params.put("stream", "testStream");
String sql = provider.selectOneByAppAndStream(params);
assertTrue(sql.contains("#{app}"), "should use #{app} bind variable");
assertTrue(sql.contains("#{stream}"), "should use #{stream} bind variable");
assertFalse(sql.contains("testApp"), "should not contain raw app value");
assertFalse(sql.contains("testStream"), "should not contain raw stream value");
}
@Test
void selectForPushingInMediaServer_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("mediaServerId", "server-001");
String sql = provider.selectForPushingInMediaServer(params);
assertTrue(sql.contains("#{mediaServerId}"), "should use #{mediaServerId} bind variable");
}
@Test
void selectAll_withQuery_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("query", "test-query");
String sql = provider.selectAll(params);
assertTrue(sql.contains("#{query}"), "should use #{query} bind variable");
assertFalse(sql.contains("test-query"), "should not contain raw query value");
assertTrue(sql.contains("LIKE concat('%',#{query},'%')"), "should use concat with bind variable");
}
@Test
void selectAll_withMediaServerId_shouldUseBindVariable() {
Map<String, Object> params = new HashMap<>();
params.put("mediaServerId", "server-001");
String sql = provider.selectAll(params);
assertTrue(sql.contains("#{mediaServerId}"), "should use #{mediaServerId} bind variable");
assertFalse(sql.contains("server-001"), "should not contain raw server id");
}
@Test
void selectAll_withPullingTrue() {
Map<String, Object> params = new HashMap<>();
params.put("pulling", true);
String sql = provider.selectAll(params);
assertTrue(sql.contains("st.pulling=1"), "should filter by pulling=1");
}
@Test
void selectAll_withPullingFalse() {
Map<String, Object> params = new HashMap<>();
params.put("pulling", false);
String sql = provider.selectAll(params);
assertTrue(sql.contains("st.pulling=0"), "should filter by pulling=0");
}
@Test
void selectAll_withoutParams_shouldReturnBaseQuery() {
Map<String, Object> params = new HashMap<>();
String sql = provider.selectAll(params);
assertTrue(sql.contains("FROM wvp_stream_proxy"), "should have FROM clause");
assertTrue(sql.contains("LEFT join wvp_device_channel"), "should have JOIN clause");
assertTrue(sql.contains("order by"), "should have ORDER BY");
}
@Test
void getBaseSelectSql_shouldReturnValidSql() {
String sql = provider.getBaseSelectSql();
assertTrue(sql.contains("SELECT"), "should start with SELECT");
assertTrue(sql.contains("FROM wvp_stream_proxy"), "should have FROM");
assertTrue(sql.contains("LEFT join wvp_device_channel"), "should have LEFT JOIN");
}
}

View File

@ -270,6 +270,38 @@ export function stopPlayChannel(channelId) {
})
}
export function talkStart(channelId) {
return request({
method: 'get',
url: '/api/common/channel/talk/start',
params: { channelId }
})
}
export function talkStop(channelId) {
return request({
method: 'get',
url: '/api/common/channel/talk/stop',
params: { channelId }
})
}
export function broadcastStart(channelId) {
return request({
method: 'get',
url: '/api/common/channel/broadcast/start',
params: { channelId }
})
}
export function broadcastStop(channelId) {
return request({
method: 'get',
url: '/api/common/channel/broadcast/stop',
params: { channelId }
})
}
// 前端控制

View File

@ -49,7 +49,8 @@ import {
pausePlayback,
resumePlayback,
seekPlayback, speedPlayback, getAllForMap, test, saveLevel, resetLevel, clearThin, thinProgress, drawThin, saveThin,
dragZoomIn, dragZoomOut
dragZoomIn, dragZoomOut,
talkStart, talkStop, broadcastStart, broadcastStop
} from '@/api/commonChannel'
const actions = {
@ -283,6 +284,46 @@ const actions = {
})
})
},
talkStart({ commit }, channelId) {
return new Promise((resolve, reject) => {
talkStart(channelId).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
talkStop({ commit }, channelId) {
return new Promise((resolve, reject) => {
talkStop(channelId).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
broadcastStart({ commit }, channelId) {
return new Promise((resolve, reject) => {
broadcastStart(channelId).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
broadcastStop({ commit }, channelId) {
return new Promise((resolve, reject) => {
broadcastStop(channelId).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
getList({ commit }, param) {
return new Promise((resolve, reject) => {
getList(param).then(response => {

View File

@ -3,7 +3,7 @@
<el-dialog
title="语音对讲"
top="10vh"
width="61.5vw"
width="65vw"
:close-on-click-modal="false"
:visible.sync="showDialog"
@close="close()"
@ -33,8 +33,8 @@
Your browser is too old which doesn't support HTML5 video.
</video>
<el-radio-group v-model="talkMode" size="big" @change="onModeChange">
<el-radio-button :label="true">喊话</el-radio-button>
<el-radio-button :label="false">对讲</el-radio-button>
<el-radio-button :label="false">喊话</el-radio-button>
<el-radio-button :label="true">对讲</el-radio-button>
</el-radio-group>
<p style="color: #909399; font-size: 14px; margin-top: 4px;">
{{ talkMode ? '双向语音交互,可听到设备声音' : '单向喊话,仅向设备发送语音' }}
@ -53,7 +53,17 @@
<span v-if="talkStatus === -2">正在释放资源</span>
<span v-if="talkStatus === -1">点击开始{{ talkMode ? '对讲' : '喊话' }}</span>
<span v-if="talkStatus === 0">等待接通中...</span>
<span v-if="talkStatus === 1">请说话</span>
<span v-if="talkStatus === 1 && !talkMode">喊话中</span>
<span v-if="talkStatus === 1 && talkMode && !playConnected">等待接通中...</span>
<span v-if="talkStatus === 1 && talkMode && playConnected">对讲中</span>
</p>
<p v-if="talkStatus === 1 && talkMode && talkAudioFailed" style="margin-top: 8px;">
<el-button
type="warning"
size="mini"
icon="el-icon-refresh"
@click="retryTalkAudio"
>重试音频</el-button>
</p>
</div>
</div>
@ -127,7 +137,10 @@ export default {
if (this.talkStatus === -2) return 'primary'
if (this.talkStatus === -1) return 'primary'
if (this.talkStatus === 0) return 'warning'
if (this.talkStatus === 1) return 'danger'
if (this.talkStatus === 1) {
if (this.talkMode && !this.playConnected) return 'warning'
return 'danger'
}
},
async talkButtonClick() {
if (this.talkStatus === -1) {
@ -137,27 +150,103 @@ export default {
}
},
async startTalk() {
this.talkStatus = 0
try {
const data = await this.$store.dispatch('play/broadcastStart', [this.channelId, this.channelId, this.talkMode])
const si = data.streamInfo
const url = document.location.protocol.includes('https') ? si.rtcs : si.rtc
this.startWebrtcPush(url)
await this.checkMicrophoneAvailability()
} catch (e) {
this.$message({ showClose: true, message: this.getMicrophoneErrorMessage(e), type: 'error' })
return
}
this.talkStatus = 0
try {
const storeName = 'commonChanel'
const actionName = this.talkMode ? 'talkStart' : 'broadcastStart'
const data = await this.$store.dispatch(storeName + '/' + actionName, this.channelId)
const pushStream = data?.pushStream
const playStream = data?.playStream
if (this.talkMode && playStream) {
this.talkAudioPlayStream = playStream
this.startTalkAudioPlay(playStream)
this.muteVideoPlayer()
}
this.startWebrtcPush(pushStream)
} catch (e) {
this.$message({ showClose: true, message: e, type: 'error' })
this.talkStatus = -1
}
},
startWebrtcPush(url) {
this.$store.dispatch('user/getUserInfo')
.then((data) => {
if (data === null) { this.talkStatus = -1; return }
const pushKey = data.pushKey
url += '&sign=' + pushKey
muteVideoPlayer() {
const player = this.$refs.playerTabs
if (!player) return
if (player.mute) {
player.mute()
}
},
unmuteVideoPlayer() {
const player = this.$refs.playerTabs
if (!player) return
if (player.cancelMute) {
player.cancelMute()
}
},
getMicrophoneErrorMessage(error) {
if (!error || !error.name) return '本地麦克风检测失败,请检查浏览器音频采集权限'
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError' || error.name === 'SecurityError') {
return '未授予浏览器麦克风权限,无法发起语音对讲'
}
if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
return '未检测到可用麦克风,无法发起语音对讲'
}
if (error.name === 'NotReadableError' || error.name === 'TrackStartError' || error.name === 'AbortError') {
return '本地麦克风被占用或暂不可用,请检查后重试'
}
if (error.name === 'OverconstrainedError' || error.name === 'ConstraintNotSatisfiedError') {
return '当前麦克风不满足采集条件,无法发起语音对讲'
}
return '本地麦克风检测失败: ' + (error.message || error.name)
},
async checkMicrophoneAvailability() {
if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
throw new Error('当前页面不是安全上下文,浏览器无法采集麦克风音频')
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('当前浏览器不支持麦克风采集')
}
let stream = null
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
const audioTracks = stream.getAudioTracks()
if (!audioTracks.length) throw new Error('未检测到有效的麦克风音轨')
if (audioTracks.every(track => track.readyState === 'ended')) {
throw new Error('麦克风已断开或不可用')
}
} finally {
if (stream) stream.getTracks().forEach(t => t.stop())
}
},
startWebrtcPush(pushStream) {
if (!pushStream) return
let url = pushStream.rtc || pushStream.rtcs
if (!url) {
console.warn('[ChAudioTalk] 未找到RTC推流地址')
return
}
this.$store.dispatch('user/getUserInfo').then(user => {
if (user && user.pushKey) {
url += '&sign=' + user.pushKey
} else {
console.warn('[ChAudioTalk] 未获取到pushKey推流鉴权可能失败')
}
if (this.broadcastRtc) {
this.broadcastRtc.close()
}
this.broadcastRtc = new ZLMRTCClient.Endpoint({
debug: true,
zlmsdpUrl: url,
@ -167,24 +256,124 @@ export default {
videoEnable: false,
recvOnly: false
})
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_NOT_SUPPORT, () => { this.talkStatus = -1 })
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => { this.talkStatus = -1 })
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, () => { this.talkStatus = -1 })
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_NOT_SUPPORT, () => {
this.$message({ showClose: true, message: '不支持WebRTC, 无法进行语音对讲', type: 'error' })
this.talkStatus = -1
})
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => {
this.$message({ showClose: true, message: 'ICE协商出错', type: 'error' })
this.talkStatus = -1
})
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, () => {
this.$message({ showClose: true, message: 'offer/answer交换失败', type: 'error' })
this.talkStatus = -1
})
this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (e) => {
if (e === 'connecting') this.talkStatus = 0
else if (e === 'connected') this.talkStatus = 1
else if (e === 'disconnected') this.talkStatus = -1
if (e === 'connecting') {
this.talkStatus = 0
} else if (e === 'connected') {
this.talkStatus = 1
} else if (e === 'disconnected') {
this.talkStatus = -1
}
})
this.broadcastRtc.on(ZLMRTCClient.Events.CAPTURE_STREAM_FAILED, () => { this.talkStatus = -1 })
}).catch(e => {
console.warn('[ChAudioTalk] 获取用户pushKey失败', e)
this.talkStatus = -1
})
},
startTalkAudioPlay(playStream) {
if (this.talkAudioRtc) {
this.talkAudioRtc.close()
}
if (this.talkAudioRetryTimer) {
clearTimeout(this.talkAudioRetryTimer)
}
const url = location.protocol === 'https:' ? playStream.rtcs : playStream.rtc
if (!url) {
console.warn('[ChAudioTalk] 无可用的设备音频播放地址')
return
}
this.talkAudioRetryTimer = setTimeout(() => {
this.pollMediaInfoAndPlay(playStream)
}, 800)
},
async pollMediaInfoAndPlay(playStream) {
try {
const data = await this.$store.dispatch('server/getMediaInfo', {
app: playStream.app,
stream: playStream.stream,
mediaServerId: playStream.mediaServerId
})
if (data) {
const url = location.protocol === 'https:' ? playStream.rtcs : playStream.rtc
this.startTalkAudioByRtc(url)
} else {
throw new Error('no data')
}
} catch (e) {
if (this.talkStatus === 1 || this.talkStatus === 0) {
this.talkAudioRetryTimer = setTimeout(() => {
this.pollMediaInfoAndPlay(playStream)
}, 800)
}
}
},
startTalkAudioByRtc(url) {
this.talkAudioFailed = false
this.talkAudioRtc = new ZLMRTCClient.Endpoint({
debug: false,
element: document.getElementById('audioTalkVideo'),
zlmsdpUrl: url,
simulecast: false,
useCamera: false,
audioEnable: true,
videoEnable: false,
recvOnly: true,
usedatachannel: false
})
this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, (e) => {
console.warn('[ChAudioTalk] 播放流offer失败:', e?.code, e?.msg)
if (e && e.code == -400 && e.msg == '流不存在') {
this.talkAudioRetryTimer = setTimeout(() => {
this.startTalkAudioByRtc(url)
}, 1000)
}
})
this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ON_REMOTE_STREAMS, () => {
console.warn('[ChAudioTalk] 设备音频流到达')
this.playConnected = true
})
this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => {
console.error('[ChAudioTalk] 音频播放ICE协商失败')
})
this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (s) => {
console.warn('[ChAudioTalk] 音频播放连接状态:', s)
if (s === 'disconnected' || s === 'failed' || s === 'closed') {
this.playConnected = false
this.talkAudioFailed = true
if (this.talkStatus === 1) {
this.talkAudioRetryTimer = setTimeout(() => {
this.startTalkAudioByRtc(url)
}, 2000)
}
}
})
.catch(() => { this.talkStatus = -1 })
},
async stopTalk() {
this.talkStatus = -2
if (this.broadcastRtc) {
this.broadcastRtc.close()
this.broadcastRtc = null
}
if (this.talkAudioRtc) {
this.talkAudioRtc.close()
this.talkAudioRtc = null
@ -193,16 +382,27 @@ export default {
clearTimeout(this.talkAudioRetryTimer)
this.talkAudioRetryTimer = null
}
this.talkAudioFailed = false
this.talkAudioPlayStream = null
this.playConnected = false
this.unmuteVideoPlayer()
const storeName = 'commonChanel'
const actionName = this.talkMode ? 'talkStop' : 'broadcastStop'
try {
await this.$store.dispatch('play/broadcastStop', [this.channelId, this.channelId])
await this.$store.dispatch(storeName + '/' + actionName, this.channelId)
} catch (e) {
console.warn('停止对讲失败', e)
}
this.talkStatus = -1
},
retryTalkAudio() {
if (this.talkAudioPlayStream) {
this.startTalkAudioPlay(this.talkAudioPlayStream)
}
},
close() {
if (this.showPlayer && this.$refs.playerTabs) {
this.$refs.playerTabs.stop()

View File

@ -37,7 +37,7 @@
<el-radio-button :label="false">对讲</el-radio-button>
</el-radio-group>
<p style="color: #909399; font-size: 14px; margin-top: 4px;">
{{ talkMode ? '双向语音交互,可听到设备声音' : '单向喊话,仅向设备发送语音' }}
{{ talkMode ? '单向喊话,仅向设备发送语音' : '双向语音交互,可听到设备声音' }}
</p>
</div>
<div style="text-align: center;">
@ -77,7 +77,7 @@ export default {
channelId: null,
hasAudio: false,
streamInfo: null,
talkMode: false,
talkMode: true,
talkStatus: -1,
broadcastRtc: null,
talkAudioRtc: null,