Commit 158ae83b9bd14f53917d9ddc9d667a9e5a06f8a3

Authored by 王鑫
1 parent b52f0961

fix():添加语音对讲功能

Showing 49 changed files with 3697 additions and 614 deletions
@@ -144,6 +144,22 @@ @@ -144,6 +144,22 @@
144 <version>1.0.5</version> 144 <version>1.0.5</version>
145 </dependency> 145 </dependency>
146 146
  147 + <dependency>
  148 + <groupId>org.springframework.boot</groupId>
  149 + <artifactId>spring-boot-starter-websocket</artifactId>
  150 + </dependency>
  151 + <dependency>
  152 + <groupId>org.java-websocket</groupId>
  153 + <artifactId>Java-WebSocket</artifactId>
  154 + <version>1.5.3</version>
  155 + </dependency>
  156 +
  157 + <dependency>
  158 + <groupId>com.alibaba</groupId>
  159 + <artifactId>fastjson</artifactId>
  160 + <version>1.2.83</version>
  161 + </dependency>
  162 +
147 163
148 <dependency> 164 <dependency>
149 <groupId>org.springframework.boot</groupId> 165 <groupId>org.springframework.boot</groupId>
src/main/java/com/genersoft/iot/vmp/IntercomTestClient.java 0 → 100644
  1 +package com.genersoft.iot.vmp;
  2 +
  3 +import com.alibaba.fastjson2.JSONObject;
  4 +import org.java_websocket.client.WebSocketClient;
  5 +import org.java_websocket.handshake.ServerHandshake;
  6 +
  7 +import java.net.URI;
  8 +import java.nio.ByteBuffer;
  9 +import java.nio.ByteOrder;
  10 +import java.util.Base64;
  11 +import java.util.Timer;
  12 +import java.util.TimerTask;
  13 +
  14 +public class IntercomTestClient {
  15 +
  16 + // 【配置】修改为你的测试 SIM 卡号 (注意补全12位)
  17 + private static final String SIM = "040028816490";
  18 + // 【配置】网关地址
  19 + private static final String WS_URL = "ws://118.113.164.50:10202?sim=" + SIM;
  20 +
  21 + public static void main(String[] args) throws Exception {
  22 + System.out.println("正在连接 WebSocket: " + WS_URL);
  23 +
  24 + WebSocketClient client = new WebSocketClient(new URI(WS_URL)) {
  25 + private Timer audioTimer;
  26 +
  27 + @Override
  28 + public void onOpen(ServerHandshake handshakedata) {
  29 + System.out.println("✅ WebSocket 连接成功");
  30 +
  31 + // 1. 发送注册包 (模拟前端逻辑)
  32 + JSONObject registerMsg = new JSONObject();
  33 + registerMsg.put("type", "register");
  34 + registerMsg.put("stream_id", SIM);
  35 + send(registerMsg.toJSONString());
  36 + System.out.println(">> 发送注册包: " + registerMsg.toJSONString());
  37 +
  38 + // 2. 开启定时任务,模拟由麦克风产生的音频流 (每40ms发送一次)
  39 + startSendingAudio();
  40 + }
  41 +
  42 + @Override
  43 + public void onMessage(String message) {
  44 + System.out.println("<< 收到消息: " + message);
  45 + }
  46 +
  47 + @Override
  48 + public void onClose(int code, String reason, boolean remote) {
  49 + System.out.println("❌ 连接关闭: " + reason);
  50 + if (audioTimer != null) audioTimer.cancel();
  51 + }
  52 +
  53 + @Override
  54 + public void onError(Exception ex) {
  55 + ex.printStackTrace();
  56 + }
  57 +
  58 + // 模拟发送音频流
  59 + private void startSendingAudio() {
  60 + audioTimer = new Timer();
  61 + // 生成 8000Hz 的正弦波数据 (模拟 '滴-----' 的声音)
  62 + // 每次发送 320 字节 (160个采样点, 20ms数据,或者根据网关调整)
  63 + // 这里发送 40ms 数据 = 320采样点 * 2字节 = 640字节
  64 + final byte[] pcmData = generateSineWave(8000, 440, 320);
  65 +
  66 + audioTimer.scheduleAtFixedRate(new TimerTask() {
  67 + @Override
  68 + public void run() {
  69 + if (isOpen()) {
  70 + try {
  71 + // 1. Base64 编码
  72 + String base64Audio = Base64.getEncoder().encodeToString(pcmData);
  73 +
  74 + // 2. 封装 JSON (完全模仿 Vue 代码结构)
  75 + JSONObject audioMsg = new JSONObject();
  76 + audioMsg.put("type", "audio_data");
  77 + audioMsg.put("stream_id", SIM);
  78 + audioMsg.put("audio_data", base64Audio);
  79 + audioMsg.put("format", "pcm16");
  80 + audioMsg.put("sample_rate", 8000);
  81 + audioMsg.put("channels", 1);
  82 +
  83 + send(audioMsg.toJSONString());
  84 + // System.out.print("."); // 打印点表示正在发送
  85 + } catch (Exception e) {
  86 + e.printStackTrace();
  87 + }
  88 + }
  89 + }
  90 + }, 0, 40); // 每40ms发送一次
  91 + System.out.println(">> 开始持续发送音频流 (标准正弦波)...");
  92 + }
  93 + };
  94 +
  95 + client.connect();
  96 + }
  97 +
  98 + /**
  99 + * 生成 PCM16 正弦波音频数据 (标准的“滴”声)
  100 + * @param sampleRate 采样率 (8000)
  101 + * @param frequency 频率 (440Hz = 标准音A)
  102 + * @param samples 采样点数量
  103 + */
  104 + private static byte[] generateSineWave(int sampleRate, int frequency, int samples) {
  105 + ByteBuffer buffer = ByteBuffer.allocate(samples * 2);
  106 + buffer.order(ByteOrder.LITTLE_ENDIAN); // 对应前端 view.setInt16(..., true)
  107 +
  108 + double period = (double) sampleRate / frequency;
  109 + for (int i = 0; i < samples; i++) {
  110 + double angle = 2.0 * Math.PI * i / period;
  111 + // 生成正弦波,振幅 0x4000 (稍微大一点声音)
  112 + short sample = (short) (Math.sin(angle) * 0x4000);
  113 + buffer.putShort(sample);
  114 + }
  115 + return buffer.array();
  116 + }
  117 +}
src/main/java/com/genersoft/iot/vmp/common/AjaxResult.java 0 → 100644
  1 +package com.genersoft.iot.vmp.common;
  2 +
  3 +import org.apache.commons.lang3.StringUtils;
  4 +
  5 +import java.util.HashMap;
  6 +import java.util.Objects;
  7 +
  8 +/**
  9 + * 操作消息提醒
  10 + *
  11 + * @author wangXin
  12 + */
  13 +public class AjaxResult extends HashMap<String, Object>
  14 +{
  15 + private static final long serialVersionUID = 1L;
  16 +
  17 + /** 状态码 */
  18 + public static final String CODE_TAG = "code";
  19 +
  20 + /** 返回内容 */
  21 + public static final String MSG_TAG = "msg";
  22 +
  23 + /** 数据对象 */
  24 + public static final String DATA_TAG = "data";
  25 +
  26 + /**
  27 + * 初始化一个新创建的 AjaxResult 对象,使其表示一个空消息。
  28 + */
  29 + public AjaxResult()
  30 + {
  31 + }
  32 +
  33 + /**
  34 + * 初始化一个新创建的 AjaxResult 对象
  35 + *
  36 + * @param code 状态码
  37 + * @param msg 返回内容
  38 + */
  39 + public AjaxResult(int code, String msg)
  40 + {
  41 + super.put(CODE_TAG, code);
  42 + super.put(MSG_TAG, msg);
  43 + }
  44 +
  45 + /**
  46 + * 初始化一个新创建的 AjaxResult 对象
  47 + *
  48 + * @param code 状态码
  49 + * @param msg 返回内容
  50 + * @param data 数据对象
  51 + */
  52 + public AjaxResult(int code, String msg, Object data)
  53 + {
  54 + super.put(CODE_TAG, code);
  55 + super.put(MSG_TAG, msg);
  56 + if (data != null)
  57 + {
  58 + super.put(DATA_TAG, data);
  59 + }
  60 + }
  61 +
  62 + /**
  63 + * 返回成功消息
  64 + *
  65 + * @return 成功消息
  66 + */
  67 + public static AjaxResult success()
  68 + {
  69 + return AjaxResult.success("操作成功");
  70 + }
  71 +
  72 + /**
  73 + * 返回成功数据
  74 + *
  75 + * @return 成功消息
  76 + */
  77 + public static AjaxResult success(Object data)
  78 + {
  79 + return AjaxResult.success("操作成功", data);
  80 + }
  81 +
  82 + /**
  83 + * 返回成功消息
  84 + *
  85 + * @param msg 返回内容
  86 + * @return 成功消息
  87 + */
  88 + public static AjaxResult success(String msg)
  89 + {
  90 + return AjaxResult.success(msg, null);
  91 + }
  92 +
  93 + /**
  94 + * 返回成功消息
  95 + *
  96 + * @param msg 返回内容
  97 + * @param data 数据对象
  98 + * @return 成功消息
  99 + */
  100 + public static AjaxResult success(String msg, Object data)
  101 + {
  102 + return new AjaxResult(HttpStatus.SUCCESS, msg, data);
  103 + }
  104 +
  105 + /**
  106 + * 返回警告消息
  107 + *
  108 + * @param msg 返回内容
  109 + * @return 警告消息
  110 + */
  111 + public static AjaxResult warn(String msg)
  112 + {
  113 + return AjaxResult.warn(msg, null);
  114 + }
  115 +
  116 + /**
  117 + * 返回警告消息
  118 + *
  119 + * @param msg 返回内容
  120 + * @param data 数据对象
  121 + * @return 警告消息
  122 + */
  123 + public static AjaxResult warn(String msg, Object data)
  124 + {
  125 + return new AjaxResult(HttpStatus.WARN, msg, data);
  126 + }
  127 +
  128 + /**
  129 + * 返回错误消息
  130 + *
  131 + * @return 错误消息
  132 + */
  133 + public static AjaxResult error()
  134 + {
  135 + return AjaxResult.error("操作失败");
  136 + }
  137 +
  138 + /**
  139 + * 返回错误消息
  140 + *
  141 + * @param msg 返回内容
  142 + * @return 错误消息
  143 + */
  144 + public static AjaxResult error(String msg)
  145 + {
  146 + return AjaxResult.error(msg, null);
  147 + }
  148 +
  149 + /**
  150 + * 返回错误消息
  151 + *
  152 + * @param msg 返回内容
  153 + * @param data 数据对象
  154 + * @return 错误消息
  155 + */
  156 + public static AjaxResult error(String msg, Object data)
  157 + {
  158 + return new AjaxResult(HttpStatus.ERROR, msg, data);
  159 + }
  160 +
  161 + /**
  162 + * 返回错误消息
  163 + *
  164 + * @param code 状态码
  165 + * @param msg 返回内容
  166 + * @return 错误消息
  167 + */
  168 + public static AjaxResult error(int code, String msg)
  169 + {
  170 + return new AjaxResult(code, msg, null);
  171 + }
  172 +
  173 + /**
  174 + * 是否为成功消息
  175 + *
  176 + * @return 结果
  177 + */
  178 + public boolean isSuccess()
  179 + {
  180 + return Objects.equals(HttpStatus.SUCCESS, this.get(CODE_TAG));
  181 + }
  182 +
  183 + /**
  184 + * 是否为警告消息
  185 + *
  186 + * @return 结果
  187 + */
  188 + public boolean isWarn()
  189 + {
  190 + return Objects.equals(HttpStatus.WARN, this.get(CODE_TAG));
  191 + }
  192 +
  193 + /**
  194 + * 是否为错误消息
  195 + *
  196 + * @return 结果
  197 + */
  198 + public boolean isError()
  199 + {
  200 + return Objects.equals(HttpStatus.ERROR, this.get(CODE_TAG));
  201 + }
  202 +
  203 + /**
  204 + * 方便链式调用
  205 + *
  206 + * @param key 键
  207 + * @param value 值
  208 + * @return 数据对象
  209 + */
  210 + @Override
  211 + public AjaxResult put(String key, Object value)
  212 + {
  213 + super.put(key, value);
  214 + return this;
  215 + }
  216 +}
src/main/java/com/genersoft/iot/vmp/common/HttpStatus.java 0 → 100644
  1 +package com.genersoft.iot.vmp.common;
  2 +
  3 +/**
  4 + * 返回状态码
  5 + *
  6 + * @author wangXin
  7 + */
  8 +public class HttpStatus
  9 +{
  10 + /**
  11 + * 操作成功
  12 + */
  13 + public static final int SUCCESS = 200;
  14 +
  15 + /**
  16 + * 对象创建成功
  17 + */
  18 + public static final int CREATED = 201;
  19 +
  20 + /**
  21 + * 请求已经被接受
  22 + */
  23 + public static final int ACCEPTED = 202;
  24 +
  25 + /**
  26 + * 操作已经执行成功,但是没有返回数据
  27 + */
  28 + public static final int NO_CONTENT = 204;
  29 +
  30 + /**
  31 + * 资源已被移除
  32 + */
  33 + public static final int MOVED_PERM = 301;
  34 +
  35 + /**
  36 + * 重定向
  37 + */
  38 + public static final int SEE_OTHER = 303;
  39 +
  40 + /**
  41 + * 资源没有被修改
  42 + */
  43 + public static final int NOT_MODIFIED = 304;
  44 +
  45 + /**
  46 + * 参数列表错误(缺少,格式不匹配)
  47 + */
  48 + public static final int BAD_REQUEST = 400;
  49 +
  50 + /**
  51 + * 未授权
  52 + */
  53 + public static final int UNAUTHORIZED = 401;
  54 +
  55 + /**
  56 + * 访问受限,授权过期
  57 + */
  58 + public static final int FORBIDDEN = 403;
  59 +
  60 + /**
  61 + * 资源,服务未找到
  62 + */
  63 + public static final int NOT_FOUND = 404;
  64 +
  65 + /**
  66 + * 不允许的http方法
  67 + */
  68 + public static final int BAD_METHOD = 405;
  69 +
  70 + /**
  71 + * 资源冲突,或者资源被锁
  72 + */
  73 + public static final int CONFLICT = 409;
  74 +
  75 + /**
  76 + * 不支持的数据,媒体类型
  77 + */
  78 + public static final int UNSUPPORTED_TYPE = 415;
  79 +
  80 + /**
  81 + * 系统内部错误
  82 + */
  83 + public static final int ERROR = 500;
  84 +
  85 + /**
  86 + * 接口未实现
  87 + */
  88 + public static final int NOT_IMPLEMENTED = 501;
  89 +
  90 + /**
  91 + * 系统警告消息
  92 + */
  93 + public static final int WARN = 601;
  94 +}
src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java
@@ -128,6 +128,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @@ -128,6 +128,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
128 .authorizeRequests() 128 .authorizeRequests()
129 .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() 129 .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
130 .antMatchers(userSetting.getInterfaceAuthenticationExcludes().toArray(new String[0])).permitAll() 130 .antMatchers(userSetting.getInterfaceAuthenticationExcludes().toArray(new String[0])).permitAll()
  131 + .antMatchers("/ws/**").permitAll()
131 .antMatchers("/api/user/login","/api/user/getInfo", "/index/hook/**", "/swagger-ui/**", "/doc.html").permitAll() 132 .antMatchers("/api/user/login","/api/user/getInfo", "/index/hook/**", "/swagger-ui/**", "/doc.html").permitAll()
132 .anyRequest().authenticated() 133 .anyRequest().authenticated()
133 .and() 134 .and()
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/Channel.java
1 package com.genersoft.iot.vmp.jtt1078.publisher; 1 package com.genersoft.iot.vmp.jtt1078.publisher;
2 2
3 -import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp;  
4 import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec; 3 import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec;
5 import com.genersoft.iot.vmp.jtt1078.entity.Media; 4 import com.genersoft.iot.vmp.jtt1078.entity.Media;
6 import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding; 5 import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding;
7 import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder; 6 import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder;
  7 +// [恢复] 引用 FFmpeg 进程管理类
8 import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher; 8 import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher;
9 import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber; 9 import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber;
10 import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber; 10 import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber;
@@ -18,15 +18,15 @@ import org.slf4j.LoggerFactory; @@ -18,15 +18,15 @@ import org.slf4j.LoggerFactory;
18 import java.util.Iterator; 18 import java.util.Iterator;
19 import java.util.concurrent.ConcurrentLinkedQueue; 19 import java.util.concurrent.ConcurrentLinkedQueue;
20 20
21 -/**  
22 - * Created by matrixy on 2020/1/11.  
23 - */  
24 public class Channel 21 public class Channel
25 { 22 {
26 static Logger logger = LoggerFactory.getLogger(Channel.class); 23 static Logger logger = LoggerFactory.getLogger(Channel.class);
27 24
28 ConcurrentLinkedQueue<Subscriber> subscribers; 25 ConcurrentLinkedQueue<Subscriber> subscribers;
  26 +
  27 + // [恢复] FFmpeg 推流进程管理器
29 RTMPPublisher rtmpPublisher; 28 RTMPPublisher rtmpPublisher;
  29 + // [删除] ZlmRtpPublisher rtpPublisher;
30 30
31 String tag; 31 String tag;
32 boolean publishing; 32 boolean publishing;
@@ -38,12 +38,16 @@ public class Channel @@ -38,12 +38,16 @@ public class Channel
38 public Channel(String tag) 38 public Channel(String tag)
39 { 39 {
40 this.tag = tag; 40 this.tag = tag;
41 - this.subscribers = new ConcurrentLinkedQueue<Subscriber>(); 41 + this.subscribers = new ConcurrentLinkedQueue<>();
42 this.flvEncoder = new FlvEncoder(true, true); 42 this.flvEncoder = new FlvEncoder(true, true);
43 this.buffer = new ByteHolder(2048 * 100); 43 this.buffer = new ByteHolder(2048 * 100);
44 44
45 - if (!StringUtils.isEmpty(Configs.get("rtmp.url"))) 45 + // [恢复] 启动 FFmpeg 进程
  46 + // 只要配置了 rtmp.url,就启动 FFmpeg 去拉取当前的 HTTP 流并转码推送
  47 + String rtmpUrl = Configs.get("rtmp.url");
  48 + if (StringUtils.isNotBlank(rtmpUrl))
46 { 49 {
  50 + logger.info("[{}] 启动 FFmpeg 进程推流至: {}", tag, rtmpUrl);
47 rtmpPublisher = new RTMPPublisher(tag); 51 rtmpPublisher = new RTMPPublisher(tag);
48 rtmpPublisher.start(); 52 rtmpPublisher.start();
49 } 53 }
@@ -56,8 +60,6 @@ public class Channel @@ -56,8 +60,6 @@ public class Channel
56 60
57 public Subscriber subscribe(ChannelHandlerContext ctx) 61 public Subscriber subscribe(ChannelHandlerContext ctx)
58 { 62 {
59 - logger.info("channel: {} -> {}, subscriber: {}", Long.toHexString(hashCode() & 0xffffffffL), tag, ctx.channel().remoteAddress().toString());  
60 -  
61 Subscriber subscriber = new VideoSubscriber(this.tag, ctx); 63 Subscriber subscriber = new VideoSubscriber(this.tag, ctx);
62 this.subscribers.add(subscriber); 64 this.subscribers.add(subscriber);
63 return subscriber; 65 return subscriber;
@@ -65,12 +67,16 @@ public class Channel @@ -65,12 +67,16 @@ public class Channel
65 67
66 public void writeAudio(long timestamp, int pt, byte[] data) 68 public void writeAudio(long timestamp, int pt, byte[] data)
67 { 69 {
  70 + // 1. 转码为 PCM (用于 FLV 封装,FFmpeg 会从 FLV 中读取 PCM 并转码为 AAC)
68 if (audioCodec == null) 71 if (audioCodec == null)
69 { 72 {
70 audioCodec = AudioCodec.getCodec(pt); 73 audioCodec = AudioCodec.getCodec(pt);
71 logger.info("audio codec: {}", MediaEncoding.getEncoding(Media.Type.Audio, pt)); 74 logger.info("audio codec: {}", MediaEncoding.getEncoding(Media.Type.Audio, pt));
72 } 75 }
  76 + // 写入到内部广播,FFmpeg 通过 HTTP 拉取这个数据
73 broadcastAudio(timestamp, audioCodec.toPCM(data)); 77 broadcastAudio(timestamp, audioCodec.toPCM(data));
  78 +
  79 + // [删除] rtpPublisher.sendAudio(...)
74 } 80 }
75 81
76 public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264) 82 public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264)
@@ -78,12 +84,15 @@ public class Channel @@ -78,12 +84,15 @@ public class Channel
78 if (firstTimestamp == -1) firstTimestamp = timeoffset; 84 if (firstTimestamp == -1) firstTimestamp = timeoffset;
79 this.publishing = true; 85 this.publishing = true;
80 this.buffer.write(h264); 86 this.buffer.write(h264);
  87 +
81 while (true) 88 while (true)
82 { 89 {
83 byte[] nalu = readNalu(); 90 byte[] nalu = readNalu();
84 if (nalu == null) break; 91 if (nalu == null) break;
85 if (nalu.length < 4) continue; 92 if (nalu.length < 4) continue;
86 93
  94 + // 1. 封装为 FLV Tag (必须)
  95 + // FFmpeg 通过 HTTP 读取这些 FLV Tag
87 byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp)); 96 byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp));
88 97
89 if (flvTag == null) continue; 98 if (flvTag == null) continue;
@@ -131,11 +140,19 @@ public class Channel @@ -131,11 +140,19 @@ public class Channel
131 subscriber.close(); 140 subscriber.close();
132 itr.remove(); 141 itr.remove();
133 } 142 }
134 - if (rtmpPublisher != null) rtmpPublisher.close(); 143 +
  144 + // [恢复] 关闭 FFmpeg 进程
  145 + if (rtmpPublisher != null) {
  146 + rtmpPublisher.close();
  147 + rtmpPublisher = null;
  148 + }
135 } 149 }
136 150
  151 + // [恢复] 原版 readNalu (FFmpeg 偏好带 StartCode 的数据,或者 FlvEncoder 需要)
  152 + // 之前为了 RTP 特意修改了切片逻辑,现在改回原版简单逻辑即可
137 private byte[] readNalu() 153 private byte[] readNalu()
138 { 154 {
  155 + // 寻找 00 00 00 01
139 for (int i = 0; i < buffer.size() - 3; i++) 156 for (int i = 0; i < buffer.size() - 3; i++)
140 { 157 {
141 int a = buffer.get(i + 0) & 0xff; 158 int a = buffer.get(i + 0) & 0xff;
src/main/java/com/genersoft/iot/vmp/jtt1078/server/Jtt1078Decoder.java
1 package com.genersoft.iot.vmp.jtt1078.server; 1 package com.genersoft.iot.vmp.jtt1078.server;
2 2
3 import com.genersoft.iot.vmp.jtt1078.util.ByteHolder; 3 import com.genersoft.iot.vmp.jtt1078.util.ByteHolder;
4 -import com.genersoft.iot.vmp.jtt1078.util.ByteUtils;  
5 import com.genersoft.iot.vmp.jtt1078.util.Packet; 4 import com.genersoft.iot.vmp.jtt1078.util.Packet;
6 5
7 -/**  
8 - * Created by matrixy on 2019/4/9.  
9 - */  
10 -public class Jtt1078Decoder  
11 -{  
12 - ByteHolder buffer = new ByteHolder(4096); 6 +public class Jtt1078Decoder {
13 7
14 - public void write(byte[] block)  
15 - { 8 + // 加大缓冲区,防止高清视频流溢出
  9 + ByteHolder buffer = new ByteHolder(4096 * 20);
  10 + private static final int HEADER_MAGIC = 0x30316364;
  11 +
  12 + public void write(byte[] block) {
16 buffer.write(block); 13 buffer.write(block);
17 } 14 }
18 15
19 - public void write(byte[] block, int startIndex, int length)  
20 - { 16 + public void write(byte[] block, int startIndex, int length) {
21 byte[] buff = new byte[length]; 17 byte[] buff = new byte[length];
22 System.arraycopy(block, startIndex, buff, 0, length); 18 System.arraycopy(block, startIndex, buff, 0, length);
23 write(buff); 19 write(buff);
24 } 20 }
25 21
26 - public Packet decode()  
27 - {  
28 - if (this.buffer.size() < 30) return null;  
29 -  
30 - if ((buffer.getInt(0) & 0x7fffffff) != 0x30316364)  
31 - {  
32 - String header = ByteUtils.toString(buffer.array(30));  
33 - throw new RuntimeException("invalid protocol header: " + header); 22 + public Packet decode() {
  23 + // 1. 搜寻包头 (关键改造)
  24 + while (this.buffer.size() >= 4) {
  25 + if ((buffer.getInt(0) & 0x7fffffff) == HEADER_MAGIC) {
  26 + break;
  27 + }
  28 + // 丢弃无效字节
  29 + byte[] trash = new byte[1];
  30 + buffer.read(trash);
34 } 31 }
35 32
  33 + if (this.buffer.size() < 30) return null;
  34 +
36 int lengthOffset = 28; 35 int lengthOffset = 28;
37 int dataType = (this.buffer.get(15) >> 4) & 0x0f; 36 int dataType = (this.buffer.get(15) >> 4) & 0x0f;
38 - // 透传数据类型:0100,没有后面的时间以及Last I Frame Interval和Last Frame Interval字段 37 +
39 if (dataType == 0x04) lengthOffset = 28 - 8 - 2 - 2; 38 if (dataType == 0x04) lengthOffset = 28 - 8 - 2 - 2;
40 else if (dataType == 0x03) lengthOffset = 28 - 4; 39 else if (dataType == 0x03) lengthOffset = 28 - 4;
41 - int bodyLength = this.buffer.getShort(lengthOffset);  
42 40
  41 + int bodyLength = this.buffer.getShort(lengthOffset);
43 int packetLength = bodyLength + lengthOffset + 2; 42 int packetLength = bodyLength + lengthOffset + 2;
44 43
45 if (this.buffer.size() < packetLength) return null; 44 if (this.buffer.size() < packetLength) return null;
  45 +
46 byte[] block = new byte[packetLength]; 46 byte[] block = new byte[packetLength];
47 this.buffer.sliceInto(block, packetLength); 47 this.buffer.sliceInto(block, packetLength);
48 return Packet.create(block); 48 return Packet.create(block);
src/main/java/com/genersoft/iot/vmp/jtt1078/server/Jtt1078Handler.java
1 package com.genersoft.iot.vmp.jtt1078.server; 1 package com.genersoft.iot.vmp.jtt1078.server;
2 2
  3 +import com.genersoft.iot.vmp.VManageBootstrap;
3 import com.genersoft.iot.vmp.jtt1078.publisher.Channel; 4 import com.genersoft.iot.vmp.jtt1078.publisher.Channel;
4 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager; 5 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager;
5 import com.genersoft.iot.vmp.jtt1078.util.Packet; 6 import com.genersoft.iot.vmp.jtt1078.util.Packet;
  7 +import com.genersoft.iot.vmp.jtt1078.websocket.Jtt1078AudioBroadcastManager;
6 import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController; 8 import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController;
7 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.DataBuffer; 9 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.DataBuffer;
8 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.SimFlow; 10 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.SimFlow;
@@ -10,122 +12,181 @@ import io.netty.channel.ChannelHandlerContext; @@ -10,122 +12,181 @@ import io.netty.channel.ChannelHandlerContext;
10 import io.netty.channel.SimpleChannelInboundHandler; 12 import io.netty.channel.SimpleChannelInboundHandler;
11 import io.netty.handler.timeout.IdleState; 13 import io.netty.handler.timeout.IdleState;
12 import io.netty.handler.timeout.IdleStateEvent; 14 import io.netty.handler.timeout.IdleStateEvent;
13 -import io.netty.util.AttributeKey;  
14 import org.slf4j.Logger; 15 import org.slf4j.Logger;
15 import org.slf4j.LoggerFactory; 16 import org.slf4j.LoggerFactory;
16 -import org.springframework.context.ApplicationContext;  
17 import org.springframework.data.redis.core.StringRedisTemplate; 17 import org.springframework.data.redis.core.StringRedisTemplate;
18 18
19 -import java.math.BigDecimal;  
20 -import java.text.SimpleDateFormat;  
21 -import java.util.Date; 19 +import java.time.LocalDateTime;
  20 +import java.time.format.DateTimeFormatter;
22 import java.util.Set; 21 import java.util.Set;
23 -import java.util.concurrent.ConcurrentHashMap;  
24 import java.util.concurrent.TimeUnit; 22 import java.util.concurrent.TimeUnit;
25 23
26 -import static com.genersoft.iot.vmp.VManageBootstrap.getBean;  
27 -  
28 -/**  
29 - * Created by matrixy on 2019/4/9.  
30 - */  
31 public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> { 24 public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> {
32 - private static ApplicationContext applicationContext;  
33 - static Logger logger = LoggerFactory.getLogger(Jtt1078Handler.class);  
34 - private static final AttributeKey<Session> SESSION_KEY = AttributeKey.valueOf("session-key");  
35 - private Integer port;  
36 25
37 - private String time; 26 + private static final Logger logger = LoggerFactory.getLogger(Jtt1078Handler.class);
  27 + private static DataBuffer simFlowDataBuffer;
  28 + private static StringRedisTemplate redisTemplate;
38 29
39 - private static final DataBuffer simFlowDataBuffer = getBean(DataBuffer.class); 30 + private Integer port;
  31 + private String currentTag; // FFmpeg用 (可能带端口后缀)
  32 + private String currentSim; // 纯SIM (日志用)
  33 + private int currentChannelId; // 当前通道号
40 34
41 - private static final ConcurrentHashMap<String, SimFlow> sizeMap = new ConcurrentHashMap<>(); 35 + private long lastStatTime = 0;
  36 + private long currentSecondFlow = 0;
  37 + private int currentSecondCount = 0;
  38 + private long lastRedisKeepAliveTime = 0;
42 39
43 public Jtt1078Handler(Integer port) { 40 public Jtt1078Handler(Integer port) {
44 this.port = port; 41 this.port = port;
45 } 42 }
  43 + public Jtt1078Handler() {}
46 44
47 - public Jtt1078Handler() { 45 + @Override
  46 + public void channelActive(ChannelHandlerContext ctx) throws Exception {
  47 + super.channelActive(ctx);
  48 + if (redisTemplate == null) redisTemplate = VManageBootstrap.getBean(StringRedisTemplate.class);
  49 + if (simFlowDataBuffer == null) simFlowDataBuffer = VManageBootstrap.getBean(DataBuffer.class);
48 } 50 }
49 51
50 - /**  
51 - * 流来  
52 - */  
53 @Override 52 @Override
54 protected void channelRead0(ChannelHandlerContext ctx, Packet packet) throws Exception { 53 protected void channelRead0(ChannelHandlerContext ctx, Packet packet) throws Exception {
55 io.netty.channel.Channel nettyChannel = ctx.channel(); 54 io.netty.channel.Channel nettyChannel = ctx.channel();
  55 +
  56 + // 1. 协议解析
56 packet.seek(8); 57 packet.seek(8);
57 String sim = packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD(); 58 String sim = packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD() + packet.nextBCD();
58 int channel = packet.nextByte() & 0xff; 59 int channel = packet.nextByte() & 0xff;
59 - String tag = sim + "-" + channel;  
60 - tag = tag.replaceAll("^0+", ""); 60 +
  61 + String rawSim = sim;
  62 + // 生成标准 Key: 138000-1 (去除前导0)
  63 + String standardTag = rawSim.replaceAll("^0+", "") + "-" + channel;
  64 + String tag = standardTag;
  65 +
  66 + this.currentSim = rawSim.replaceAll("^0+", "");
  67 + this.currentChannelId = channel;
  68 +
  69 + // 2. 端口过滤与重命名 (保持原有逻辑,处理多端口映射)
61 if (port != null) { 70 if (port != null) {
62 Set<String> set = Jt1078OfCarController.map.get(port); 71 Set<String> set = Jt1078OfCarController.map.get(port);
63 String findSet = Jt1078OfCarController.getFindSet(set, tag); 72 String findSet = Jt1078OfCarController.getFindSet(set, tag);
64 if (findSet != null) { 73 if (findSet != null) {
65 - tag = findSet + "_" + port; 74 + tag = findSet + "_" + port; // tag 变成了 138000-1_1078
66 } else { 75 } else {
67 return; 76 return;
68 } 77 }
69 } 78 }
70 - StringRedisTemplate redisTemplate = getBean(StringRedisTemplate.class);  
71 - if (redisTemplate.opsForSet().add("tag:" + tag, tag) > 0) {  
72 - redisTemplate.expire("tag:" + tag, 60, TimeUnit.SECONDS);  
73 - logger.info("[ {} ] --> 流接收成功 ", tag);  
74 - } else {  
75 - redisTemplate.expire("tag:" + tag, 60, TimeUnit.SECONDS);  
76 - }  
77 - String format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());  
78 -  
79 - String key = sim + "-" + channel;  
80 - if (sizeMap.containsKey(key)){  
81 - SimFlow simFlow = sizeMap.get(key);  
82 - simFlow.setFlow(simFlow.getFlow() + packet.size());  
83 - simFlow.setCount(simFlow.getCount() + 1);  
84 - }else {  
85 - sizeMap.put(key, SimFlow.builder().sim(sim).count(1).flow((long) packet.size()).channel(channel).time(format).build()); 79 +
  80 + this.currentTag = tag;
  81 +
  82 + // 3. Redis保活
  83 + long now = System.currentTimeMillis();
  84 + if (now - lastRedisKeepAliveTime > 5000) {
  85 + String redisKey = "tag:" + tag;
  86 + if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
  87 + redisTemplate.expire(redisKey, 60, TimeUnit.SECONDS);
  88 + } else {
  89 + redisTemplate.opsForSet().add(redisKey, tag);
  90 + redisTemplate.expire(redisKey, 60, TimeUnit.SECONDS);
  91 + logger.info("[{}] Stream Online", tag);
  92 + }
  93 + lastRedisKeepAliveTime = now;
86 } 94 }
87 - if (time == null || !time.equals(format)) {  
88 - time = format;  
89 - sizeMap.entrySet().forEach(entry -> {  
90 - SimFlow value = entry.getValue();  
91 - logger.debug("{} === {}次 === {} 流来的大小 {} ", value.getTime(), value.getCount(), value.getSim() + "-" + value.getChannel(), value.getFlow());  
92 - if (simFlowDataBuffer != null) {  
93 - simFlowDataBuffer.setValue(value);  
94 - }  
95 - });  
96 - sizeMap.clear(); 95 +
  96 + // 4. 流量统计
  97 + long currentSecond = now / 1000;
  98 + long lastSecond = lastStatTime / 1000;
  99 + currentSecondFlow += packet.size();
  100 + currentSecondCount++;
  101 + if (currentSecond != lastSecond) {
  102 + if (lastStatTime != 0) {
  103 + String timeStr = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now());
  104 + SimFlow simFlow = SimFlow.builder().sim(rawSim).channel(channel).flow(currentSecondFlow).count(currentSecondCount).time(timeStr).build();
  105 + if (simFlowDataBuffer != null) simFlowDataBuffer.setValue(simFlow);
  106 + }
  107 + currentSecondFlow = 0;
  108 + currentSecondCount = 0;
  109 + lastStatTime = now;
97 } 110 }
98 - if (SessionManager.contains(nettyChannel, "tag") == false) { 111 +
  112 + // 5. 初始化组件 (核心修复点)
  113 + if (!SessionManager.contains(nettyChannel, "tag")) {
  114 + // A. 启动 FFmpeg 推流 (使用可能带后缀的 tag,用于视频直播)
99 Channel chl = PublishManager.getInstance().open(tag); 115 Channel chl = PublishManager.getInstance().open(tag);
  116 +
  117 + // B. 双重注册:同时注册 tag 和 intercom_tag
  118 + // 这样视频直播用 tag,语音对讲用 intercom_tag,两者互不干扰
100 SessionManager.set(nettyChannel, "tag", tag); 119 SessionManager.set(nettyChannel, "tag", tag);
101 - logger.info("start publishing: {} -> {}-{}", Long.toHexString(chl.hashCode() & 0xffffffffL), sim, channel); 120 + SessionManager.set(nettyChannel, "intercom_tag", standardTag); // 供 WebSocket 查找
  121 +
  122 + logger.info("Publish Start. VideoTag=[{}], IntercomTag=[{}]", tag, standardTag);
102 } 123 }
103 124
  125 + // 6. 数据处理
  126 + processMediaPayload(nettyChannel, packet, tag, lengthOffset(packet));
  127 + }
  128 +
  129 + /**
  130 + * 【重要】修正协议头长度计算
  131 + * DataType=3(音频) 和 2(对讲) 没有帧间隔字段(4字节),所以偏移量要减4
  132 + */
  133 + private int lengthOffset(Packet packet) {
  134 + packet.seek(15);
  135 + int dataType = (packet.nextByte() >> 4) & 0x0f;
  136 +
  137 + if (dataType == 0x04) {
  138 + return 28 - 8 - 2 - 2; // 透传
  139 + } else if (dataType == 0x03 || dataType == 0x02) {
  140 + return 28 - 4; // 音频 & 对讲
  141 + }
  142 + return 28; // 视频
  143 + }
  144 +
  145 + private void processMediaPayload(io.netty.channel.Channel nettyChannel, Packet packet, String tag, int lengthOffset) {
104 Integer sequence = SessionManager.get(nettyChannel, "video-sequence"); 146 Integer sequence = SessionManager.get(nettyChannel, "video-sequence");
105 if (sequence == null) sequence = 0; 147 if (sequence == null) sequence = 0;
106 - // 1. 做好序号  
107 - // 2. 音频需要转码后提供订阅  
108 - int lengthOffset = 28;  
109 - int dataType = (packet.seek(15).nextByte() >> 4) & 0x0f;  
110 - int pkType = packet.seek(15).nextByte() & 0x0f;  
111 - // 透传数据类型:0100,没有后面的时间以及Last I Frame Interval和Last Frame Interval字段  
112 - if (dataType == 0x04) lengthOffset = 28 - 8 - 2 - 2;  
113 - else if (dataType == 0x03) lengthOffset = 28 - 4;  
114 -  
115 - int pt = packet.seek(5).nextByte() & 0x7f;  
116 -  
117 - if (dataType == 0x00 || dataType == 0x01 || dataType == 0x02) {  
118 - // 碰到结束标记时,序号+1 148 +
  149 + packet.seek(15);
  150 + byte dataTypeByte = packet.nextByte();
  151 + int dataType = (dataTypeByte >> 4) & 0x0f; // 0=AV, 1=V, 2=Intercom, 3=Audio
  152 + int pkType = dataTypeByte & 0x0f;
  153 +
  154 + packet.seek(5);
  155 + int pt = packet.nextByte() & 0x7f;
  156 +
  157 + // --- 分支 A: 视频流 (0, 1) ---
  158 + // 视频流必须发给 PublishManager (FFmpeg)
  159 + if (dataType == 0x00 || dataType == 0x01) {
119 if (pkType == 0 || pkType == 2) { 160 if (pkType == 0 || pkType == 2) {
120 sequence += 1; 161 sequence += 1;
121 SessionManager.set(nettyChannel, "video-sequence", sequence); 162 SessionManager.set(nettyChannel, "video-sequence", sequence);
122 } 163 }
123 long timestamp = packet.seek(16).nextLong(); 164 long timestamp = packet.seek(16).nextLong();
124 - PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, packet.seek(lengthOffset + 2).nextBytes());  
125 - } else if (dataType == 0x03) {  
126 - long timestamp = packet.seek(16).nextLong();  
127 - byte[] data = packet.seek(lengthOffset + 2).nextBytes();  
128 - PublishManager.getInstance().publishAudio(tag, sequence, timestamp, pt, data); 165 + byte[] videoData = packet.seek(lengthOffset + 2).nextBytes();
  166 +
  167 + // 推流到 FFmpeg (直播)
  168 + PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, videoData);
  169 + }
  170 +
  171 + // --- 分支 B: 音频流 (0, 2, 3) ---
  172 + else if (dataType == 0x03 || dataType == 0x02 || dataType == 0x00) {
  173 +
  174 + // 只有明确是音频/对讲包时才处理
  175 + if (dataType == 0x03 || dataType == 0x02) {
  176 + long timestamp = packet.seek(16).nextLong();
  177 + byte[] audioData = packet.seek(lengthOffset + 2).nextBytes();
  178 + // 1. 发送给 WebSocket (前端监听 - 对讲核心)
  179 + // 无论是什么音频,只要上来了,就发给 WebSocket,保证对讲能听到
  180 + Jtt1078AudioBroadcastManager.broadcastAudio(this.currentSim, this.currentChannelId, audioData);
  181 +
  182 + // 2. 发送给 FFmpeg (直播/录像)
  183 + // 【核心分流逻辑】
  184 + // 如果是双向对讲(0x02),严格禁止发给 FFmpeg!
  185 + // 只有 0x03 (环境监听) 才发给 FFmpeg 进行混流录制
  186 + if (dataType != 0x02) {
  187 + PublishManager.getInstance().publishAudio(tag, sequence, timestamp, pt, audioData);
  188 + }
  189 + }
129 } 190 }
130 } 191 }
131 192
@@ -137,31 +198,34 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; { @@ -137,31 +198,34 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
137 198
138 @Override 199 @Override
139 public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { 200 public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
140 - // super.exceptionCaught(ctx, cause);  
141 - cause.printStackTrace(); 201 + String msg = cause.getMessage();
  202 + if (msg != null && (msg.contains("invalid protocol header") || msg.contains("Connection reset"))) {
  203 + // ignore scan errors
  204 + } else {
  205 + logger.error("Jtt1078Handler Exception: {}", cause.getMessage());
  206 + }
142 release(ctx.channel()); 207 release(ctx.channel());
143 ctx.close(); 208 ctx.close();
144 } 209 }
145 210
146 @Override 211 @Override
147 public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { 212 public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
148 - if (IdleStateEvent.class.isAssignableFrom(evt.getClass())) { 213 + if (evt instanceof IdleStateEvent) {
149 IdleStateEvent event = (IdleStateEvent) evt; 214 IdleStateEvent event = (IdleStateEvent) evt;
150 if (event.state() == IdleState.READER_IDLE) { 215 if (event.state() == IdleState.READER_IDLE) {
151 - String tag = SessionManager.get(ctx.channel(), "tag");  
152 - logger.info("read timeout: {}", tag);  
153 - release(ctx.channel()); 216 + logger.warn("Read Timeout: {}", currentTag);
  217 + ctx.close();
154 } 218 }
  219 + } else {
  220 + super.userEventTriggered(ctx, evt);
155 } 221 }
156 } 222 }
157 223
158 private void release(io.netty.channel.Channel channel) { 224 private void release(io.netty.channel.Channel channel) {
159 String tag = SessionManager.get(channel, "tag"); 225 String tag = SessionManager.get(channel, "tag");
160 if (tag != null) { 226 if (tag != null) {
161 - logger.info("close netty channel: {}", tag); 227 + SessionManager.remove(channel);
162 PublishManager.getInstance().close(tag); 228 PublishManager.getInstance().close(tag);
163 } 229 }
164 } 230 }
165 -  
166 -  
167 } 231 }
src/main/java/com/genersoft/iot/vmp/jtt1078/server/SessionManager.java
1 package com.genersoft.iot.vmp.jtt1078.server; 1 package com.genersoft.iot.vmp.jtt1078.server;
2 2
3 import io.netty.channel.Channel; 3 import io.netty.channel.Channel;
4 -import org.apache.commons.collections4.map.HashedMap;  
5 - 4 +import io.netty.util.AttributeKey;
  5 +import org.slf4j.Logger;
  6 +import org.slf4j.LoggerFactory;
6 7
7 import java.util.Map; 8 import java.util.Map;
  9 +import java.util.concurrent.ConcurrentHashMap;
  10 +
  11 +/**
  12 + * 会话管理器
  13 + * 负责维护 Netty Channel 与 设备标识 的映射关系
  14 + */
  15 +public class SessionManager {
  16 +
  17 + private static final Logger logger = LoggerFactory.getLogger(SessionManager.class);
  18 +
  19 + // 维护 Tag 到 Netty Channel 的映射
  20 + private static final Map<String, Channel> tagToChannelMap = new ConcurrentHashMap<>();
  21 +
  22 + public static void init() {
  23 + tagToChannelMap.clear();
  24 + logger.info("SessionManager initialized.");
  25 + }
  26 +
  27 + /**
  28 + * 绑定属性到 Channel,并建立反向索引
  29 + */
  30 + public static void set(Channel channel, String key, Object value) {
  31 + if (channel == null || key == null) return;
8 32
9 -public final class SessionManager  
10 -{  
11 - private static final Map<String, Object> mappings = new HashedMap(); 33 + // 1. 设置 Channel 内部属性
  34 + channel.attr(AttributeKey.valueOf(key)).set(value);
12 35
13 - public static void init()  
14 - {  
15 - // ... 36 + // 2. 【核心修复】建立反向索引
  37 + // 同时支持 "tag" (视频流专用,可能带后缀) 和 "intercom_tag" (对讲专用,纯净SIM-通道)
  38 + // 这样 WebSocket 使用 "sim-channel" 也能查找到连接,互不影响
  39 + if (("tag".equals(key) || "intercom_tag".equals(key)) && value instanceof String) {
  40 + String tagValue = (String) value;
  41 + tagToChannelMap.put(tagValue, channel);
  42 + }
16 } 43 }
17 44
18 - public static <T> T get(Channel channel, String key)  
19 - {  
20 - return (T) mappings.get(channel.id().asLongText() + key); 45 + @SuppressWarnings("unchecked")
  46 + public static <T> T get(Channel channel, String key) {
  47 + if (channel == null) return null;
  48 + return (T) channel.attr(AttributeKey.valueOf(key)).get();
21 } 49 }
22 50
23 - public static void set(Channel channel, String key, Object value)  
24 - {  
25 - mappings.put(channel.id().asLongText() + key, value); 51 + public static boolean contains(Channel channel, String key) {
  52 + if (channel == null) return false;
  53 + return channel.attr(AttributeKey.valueOf(key)).get() != null;
  54 + }
  55 +
  56 + /**
  57 + * WebSocket 调用此方法查找设备连接
  58 + */
  59 + public static Channel getChannelByTag(String tag) {
  60 + if (tag == null) return null;
  61 + return tagToChannelMap.get(tag);
  62 + }
  63 +
  64 + public static void remove(Channel channel) {
  65 + if (channel == null) return;
  66 +
  67 + // 清理关联的 tag
  68 + String tag = get(channel, "tag");
  69 + if (tag != null) tagToChannelMap.remove(tag);
  70 +
  71 + // 清理关联的 intercom_tag
  72 + String intercomTag = get(channel, "intercom_tag");
  73 + if (intercomTag != null) tagToChannelMap.remove(intercomTag);
26 } 74 }
27 75
28 - public static boolean contains(Channel channel, String key)  
29 - {  
30 - return mappings.containsKey(channel.id().asLongText() + key); 76 + public static int getSessionCount() {
  77 + return tagToChannelMap.size();
31 } 78 }
32 } 79 }
src/main/java/com/genersoft/iot/vmp/jtt1078/subscriber/RTMPPublisher.java
@@ -3,27 +3,34 @@ package com.genersoft.iot.vmp.jtt1078.subscriber; @@ -3,27 +3,34 @@ package com.genersoft.iot.vmp.jtt1078.subscriber;
3 import com.genersoft.iot.vmp.jtt1078.util.*; 3 import com.genersoft.iot.vmp.jtt1078.util.*;
4 import org.slf4j.Logger; 4 import org.slf4j.Logger;
5 import org.slf4j.LoggerFactory; 5 import org.slf4j.LoggerFactory;
6 -import org.springframework.data.redis.core.RedisTemplate;  
7 6
8 -import javax.annotation.Resource; 7 +import java.io.Closeable;
9 import java.io.InputStream; 8 import java.io.InputStream;
  9 +import java.util.concurrent.TimeUnit;
10 10
  11 +/**
  12 + * FFmpeg 推流器 (仅处理视频直播流)
  13 + */
11 public class RTMPPublisher extends Thread 14 public class RTMPPublisher extends Thread
12 { 15 {
13 static Logger logger = LoggerFactory.getLogger(RTMPPublisher.class); 16 static Logger logger = LoggerFactory.getLogger(RTMPPublisher.class);
14 17
15 String tag = null; 18 String tag = null;
16 Process process = null; 19 Process process = null;
  20 + private volatile boolean running = true;
17 21
18 public RTMPPublisher(String tag) 22 public RTMPPublisher(String tag)
19 { 23 {
20 this.tag = tag; 24 this.tag = tag;
  25 + this.setName("RTMPPublisher-" + tag);
  26 + this.setDaemon(true);
21 } 27 }
22 28
23 @Override 29 @Override
24 public void run() 30 public void run()
25 { 31 {
26 InputStream stderr = null; 32 InputStream stderr = null;
  33 + InputStream stdout = null;
27 int len = -1; 34 int len = -1;
28 byte[] buff = new byte[512]; 35 byte[] buff = new byte[512];
29 boolean debugMode = "on".equalsIgnoreCase(Configs.get("debug.mode")); 36 boolean debugMode = "on".equalsIgnoreCase(Configs.get("debug.mode"));
@@ -32,29 +39,123 @@ public class RTMPPublisher extends Thread @@ -32,29 +39,123 @@ public class RTMPPublisher extends Thread
32 { 39 {
33 String sign = "41db35390ddad33f83944f44b8b75ded"; 40 String sign = "41db35390ddad33f83944f44b8b75ded";
34 String rtmpUrl = Configs.get("rtmp.url").replaceAll("\\{TAG\\}", tag).replaceAll("\\{sign\\}",sign); 41 String rtmpUrl = Configs.get("rtmp.url").replaceAll("\\{TAG\\}", tag).replaceAll("\\{sign\\}",sign);
35 - String cmd = String.format("%s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac -f rtsp %s",  
36 - Configs.get("ffmpeg.path"),  
37 - Configs.getInt("server.http.port", 3333),  
38 - tag,  
39 - rtmpUrl  
40 - );  
41 - logger.info("推流命令 Execute: {}", cmd); 42 +
  43 + // 【修复】自动判断协议格式,避免硬编码 -f rtsp 导致 RTMP 推流失败
  44 + String formatFlag = "";
  45 + if (rtmpUrl.startsWith("rtsp://")) {
  46 + formatFlag = "-f rtsp";
  47 + } else if (rtmpUrl.startsWith("rtmp://")) {
  48 + formatFlag = "-f flv";
  49 + }
  50 +
  51 + // 构造命令:只处理视频流和非对讲的音频流
  52 + String cmd = String.format("%s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac %s %s",
  53 + Configs.get("ffmpeg.path"),
  54 + Configs.getInt("server.http.port", 3333),
  55 + tag,
  56 + formatFlag,
  57 + rtmpUrl
  58 + );
  59 +
  60 + logger.info("FFmpeg Push Started. Tag: {}, CMD: {}", tag, cmd);
  61 +
42 process = Runtime.getRuntime().exec(cmd); 62 process = Runtime.getRuntime().exec(cmd);
43 stderr = process.getErrorStream(); 63 stderr = process.getErrorStream();
44 - while ((len = stderr.read(buff)) > -1) 64 + stdout = process.getInputStream();
  65 +
  66 + // 启动一个线程消费 stdout,防止缓冲区满导致进程阻塞
  67 + final InputStream finalStdout = stdout;
  68 + Thread stdoutConsumer = new Thread(() -> {
  69 + try {
  70 + byte[] buffer = new byte[512];
  71 + while (running && finalStdout.read(buffer) > -1) {
  72 + // 只消费,不输出
  73 + }
  74 + } catch (Exception e) {
  75 + // 忽略异常
  76 + }
  77 + }, "FFmpeg-stdout-" + tag);
  78 + stdoutConsumer.setDaemon(true);
  79 + stdoutConsumer.start();
  80 +
  81 + // 消费 stderr 日志流,防止阻塞
  82 + while (running && (len = stderr.read(buff)) > -1)
45 { 83 {
46 - if (debugMode) System.out.print(new String(buff, 0, len)); 84 + if (debugMode) {
  85 + System.out.print(new String(buff, 0, len));
  86 + }
47 } 87 }
48 - logger.info("Process FFMPEG exited..."); 88 +
  89 + // 进程退出处理
  90 + int exitCode = process.waitFor();
  91 + logger.warn("FFmpeg process exited. Code: {}, Tag: {}", exitCode, tag);
  92 + }
  93 + catch(InterruptedException ex)
  94 + {
  95 + logger.info("RTMPPublisher interrupted: {}", tag);
  96 + Thread.currentThread().interrupt();
49 } 97 }
50 catch(Exception ex) 98 catch(Exception ex)
51 { 99 {
52 - logger.error("publish failed", ex); 100 + logger.error("RTMPPublisher Error: " + tag, ex);
  101 + }
  102 + finally
  103 + {
  104 + // 确保所有流都被关闭
  105 + closeQuietly(stderr);
  106 + closeQuietly(stdout);
  107 + if (process != null) {
  108 + closeQuietly(process.getInputStream());
  109 + closeQuietly(process.getOutputStream());
  110 + closeQuietly(process.getErrorStream());
  111 + }
53 } 112 }
54 } 113 }
55 114
56 public void close() 115 public void close()
57 { 116 {
58 - try { if (process != null) process.destroyForcibly(); } catch(Exception e) { } 117 + try {
  118 + // 设置停止标志
  119 + running = false;
  120 +
  121 + if (process != null) {
  122 + // 先尝试正常终止
  123 + process.destroy();
  124 +
  125 + // 等待最多 3 秒
  126 + boolean exited = process.waitFor(3, TimeUnit.SECONDS);
  127 +
  128 + if (!exited) {
  129 + // 如果还没退出,强制终止
  130 + logger.warn("FFmpeg process did not exit gracefully, forcing termination: {}", tag);
  131 + process.destroyForcibly();
  132 + process.waitFor(2, TimeUnit.SECONDS);
  133 + }
  134 +
  135 + logger.info("FFmpeg process terminated: {}", tag);
  136 + }
  137 +
  138 + // 中断线程(如果还在阻塞读取)
  139 + this.interrupt();
  140 +
  141 + // 等待线程结束
  142 + this.join(2000);
  143 +
  144 + } catch(Exception e) {
  145 + logger.error("Error closing RTMPPublisher: " + tag, e);
  146 + }
  147 + }
  148 +
  149 + /**
  150 + * 安全关闭流,忽略异常
  151 + */
  152 + private void closeQuietly(Closeable stream) {
  153 + if (stream != null) {
  154 + try {
  155 + stream.close();
  156 + } catch (Exception e) {
  157 + // 忽略
  158 + }
  159 + }
59 } 160 }
60 } 161 }
src/main/java/com/genersoft/iot/vmp/jtt1078/subscriber/ZlmRtpPublisher.java 0 → 100644
  1 +package com.genersoft.iot.vmp.jtt1078.subscriber;
  2 +
  3 +import cn.hutool.http.HttpUtil;
  4 +import cn.hutool.json.JSONObject;
  5 +import cn.hutool.json.JSONUtil;
  6 +import com.genersoft.iot.vmp.jtt1078.util.Configs;
  7 +import io.netty.bootstrap.Bootstrap;
  8 +import io.netty.buffer.ByteBuf;
  9 +import io.netty.buffer.Unpooled;
  10 +import io.netty.channel.Channel;
  11 +import io.netty.channel.ChannelOption;
  12 +import io.netty.channel.EventLoopGroup;
  13 +import io.netty.channel.nio.NioEventLoopGroup;
  14 +import io.netty.channel.socket.DatagramPacket;
  15 +import io.netty.channel.socket.nio.NioDatagramChannel;
  16 +import org.slf4j.Logger;
  17 +import org.slf4j.LoggerFactory;
  18 +
  19 +import java.net.InetSocketAddress;
  20 +
  21 +public class ZlmRtpPublisher {
  22 + private static final Logger logger = LoggerFactory.getLogger(ZlmRtpPublisher.class);
  23 + private static final EventLoopGroup group = new NioEventLoopGroup();
  24 +
  25 + private Channel nettyChannel;
  26 + private InetSocketAddress zlmRtpAddress;
  27 + private String streamId;
  28 +
  29 + // [核心修复1] 音视频分离的状态
  30 + private int audioSsrc;
  31 + private int videoSsrc;
  32 + private int audioSeq = 0;
  33 + private int videoSeq = 0;
  34 +
  35 + public ZlmRtpPublisher(String streamId) {
  36 + this.streamId = streamId;
  37 + }
  38 +
  39 + public void start() {
  40 + try {
  41 + String zlmHost = Configs.get("zlm.host");
  42 + int zlmHttpPort = Configs.getInt("zlm.http.port", 80);
  43 + String zlmSecret = Configs.get("zlm.secret");
  44 +
  45 + // 生成 SSRC: 视频用 Hash,音频用 Hash+1,确保不冲突
  46 + int baseSsrc = (streamId.hashCode() & 0x7FFFFFFF);
  47 + this.videoSsrc = baseSsrc;
  48 + this.audioSsrc = baseSsrc + 1;
  49 +
  50 + // 申请端口
  51 + String url = String.format("http://%s:%d/index/api/openRtpServer?secret=%s&stream_id=%s&ssrc=%s&port=0&enable_tcp=0&app=schedule",
  52 + zlmHost, zlmHttpPort, zlmSecret, streamId, videoSsrc);
  53 +
  54 + logger.info("[{}] 申请 RTP 端口: {}", streamId, url);
  55 + String response = HttpUtil.get(url);
  56 + JSONObject json = JSONUtil.parseObj(response);
  57 +
  58 + if (json.getInt("code") != 0) {
  59 + throw new RuntimeException("ZLM openRtpServer error: " + response);
  60 + }
  61 +
  62 + int rtpPort = json.getInt("port");
  63 + this.zlmRtpAddress = new InetSocketAddress(zlmHost, rtpPort);
  64 +
  65 + Bootstrap b = new Bootstrap();
  66 + b.group(group)
  67 + .channel(NioDatagramChannel.class)
  68 + .option(ChannelOption.SO_SNDBUF, 1024 * 1024)
  69 + .handler(new io.netty.channel.ChannelHandlerAdapter() {});
  70 +
  71 + this.nettyChannel = b.bind(0).sync().channel();
  72 + logger.info("[{}] RTP 推流就绪 (UDP) -> {}:{}", streamId, zlmHost, rtpPort);
  73 +
  74 + } catch (Exception e) {
  75 + logger.error("[{}] 启动 RTP 推流失败", streamId, e);
  76 + }
  77 + }
  78 +
  79 + public void sendVideo(long timestampMs, byte[] nalu) {
  80 + if (nettyChannel == null || zlmRtpAddress == null) return;
  81 +
  82 + // [核心修复2] 智能剔除 H.264 Start Code (00 00 00 01 或 00 00 01)
  83 + int offset = 0;
  84 + if (nalu.length > 4 && nalu[0] == 0 && nalu[1] == 0 && nalu[2] == 0 && nalu[3] == 1) {
  85 + offset = 4;
  86 + } else if (nalu.length > 3 && nalu[0] == 0 && nalu[1] == 0 && nalu[2] == 1) {
  87 + offset = 3;
  88 + }
  89 +
  90 + int length = nalu.length - offset;
  91 + if (length <= 0) return; // 空包不发
  92 +
  93 + long rtpTimestamp = timestampMs * 90; // 90kHz
  94 + int maxPacketSize = 1400;
  95 +
  96 + if (length <= maxPacketSize) {
  97 + // 单包
  98 + ByteBuf packet = createRtpPacket(rtpTimestamp, true, 96, videoSsrc, videoSeq++);
  99 + packet.writeBytes(nalu, offset, length);
  100 + sendRtp(packet);
  101 + } else {
  102 + // FU-A 分片
  103 + byte naluHeader = nalu[offset];
  104 + int nri = naluHeader & 0x60;
  105 + int type = naluHeader & 0x1f;
  106 +
  107 + offset += 1;
  108 + length -= 1;
  109 +
  110 + boolean first = true;
  111 + while (length > 0) {
  112 + int currLen = Math.min(length, maxPacketSize);
  113 + boolean last = (length == currLen);
  114 +
  115 + // 只有最后一包打 Marker
  116 + ByteBuf packet = createRtpPacket(rtpTimestamp, last, 96, videoSsrc, videoSeq++);
  117 +
  118 + packet.writeByte(nri | 28); // FU Indicator
  119 +
  120 + int fuHeader = type;
  121 + if (first) fuHeader |= 0x80;
  122 + if (last) fuHeader |= 0x40;
  123 + packet.writeByte(fuHeader); // FU Header
  124 +
  125 + packet.writeBytes(nalu, offset, currLen);
  126 + sendRtp(packet);
  127 +
  128 + offset += currLen;
  129 + length -= currLen;
  130 + first = false;
  131 + }
  132 + }
  133 + }
  134 +
  135 + public void sendAudio(long timestampMs, byte[] data) {
  136 + if (nettyChannel == null || zlmRtpAddress == null) return;
  137 +
  138 + // 音频必须使用独立的序列号 audioSeq
  139 + // RTP 音频时钟 8kHz
  140 + long rtpTimestamp = timestampMs * 8;
  141 +
  142 + ByteBuf packet = createRtpPacket(rtpTimestamp, true, 8, audioSsrc, audioSeq++);
  143 + packet.writeBytes(data);
  144 + sendRtp(packet);
  145 + }
  146 +
  147 + // 统一封装 RTP Header
  148 + private ByteBuf createRtpPacket(long timestamp, boolean marker, int pt, int ssrc, int seqNum) {
  149 + ByteBuf buf = Unpooled.buffer(1500);
  150 + buf.writeByte(0x80); // V=2
  151 + buf.writeByte((marker ? 0x80 : 0x00) | pt); // M | PT
  152 + buf.writeShort(seqNum); // Sequence
  153 + buf.writeInt((int) timestamp); // Timestamp
  154 + buf.writeInt(ssrc); // SSRC
  155 + return buf;
  156 + }
  157 +
  158 + private void sendRtp(ByteBuf packet) {
  159 + nettyChannel.writeAndFlush(new DatagramPacket(packet, zlmRtpAddress));
  160 + }
  161 +
  162 + public void close() {
  163 + if (nettyChannel != null) nettyChannel.close();
  164 + }
  165 +}
src/main/java/com/genersoft/iot/vmp/jtt1078/util/ALaw.java 0 → 100644
  1 +package com.genersoft.iot.vmp.jtt1078.util;
  2 +
  3 +/**
  4 + * G.711 A-Law 编解码工具类
  5 + */
  6 +public class ALaw {
  7 +
  8 + private static final byte[] ALAW_TABLE = new byte[65536]; // 线性 -> ALaw 查找表
  9 + private static final short[] LINEAR_TABLE = new short[256]; // ALaw -> 线性 查找表
  10 +
  11 + static {
  12 + // 初始化 ALaw -> Linear 表
  13 + for (int i = 0; i < 256; i++) {
  14 + LINEAR_TABLE[i] = alaw2linear((byte) i);
  15 + }
  16 +
  17 + // 初始化 Linear -> ALaw 表
  18 + for (int i = -32768; i <= 32767; i++) {
  19 + ALAW_TABLE[i & 0xFFFF] = linear2alaw((short) i);
  20 + }
  21 + }
  22 +
  23 + /**
  24 + * 将 16bit PCM 压缩为 8bit A-Law (查表法,高性能)
  25 + */
  26 + public static byte encode(short pcm) {
  27 + return ALAW_TABLE[pcm & 0xFFFF];
  28 + }
  29 +
  30 + /**
  31 + * 将 8bit A-Law 解压为 16bit PCM (查表法,高性能)
  32 + */
  33 + public static short decode(byte alaw) {
  34 + return LINEAR_TABLE[alaw & 0xFF];
  35 + }
  36 +
  37 + // --- 以下是基础算法实现,用于静态块初始化 ---
  38 +
  39 + private static final int MAX = 0x7FFF; // 32767
  40 +
  41 + private static byte linear2alaw(short pcm_val) {
  42 + int mask;
  43 + int seg;
  44 + int aval;
  45 +
  46 + if (pcm_val >= 0) {
  47 + mask = 0xD5;
  48 + } else {
  49 + mask = 0x55;
  50 + pcm_val = (short) (-pcm_val - 1); // 负数转正数
  51 + if (pcm_val < 0) {
  52 + pcm_val = MAX;
  53 + }
  54 + }
  55 +
  56 + if (pcm_val < 256) {
  57 + aval = pcm_val >> 4;
  58 + } else {
  59 + seg = 0;
  60 + for (int i = pcm_val; i > 256; i >>= 1) {
  61 + seg++;
  62 + }
  63 + aval = (seg << 4) | ((pcm_val >> (seg + 3)) & 0x0F);
  64 + }
  65 + return (byte) ((aval ^ mask) & 0xFF);
  66 + }
  67 +
  68 + private static short alaw2linear(byte alaw_val) {
  69 + int t;
  70 + int seg;
  71 +
  72 + alaw_val ^= 0x55;
  73 +
  74 + t = (alaw_val & 0x0F) << 4;
  75 + seg = ((int) (alaw_val & 0x70)) >> 4;
  76 +
  77 + switch (seg) {
  78 + case 0:
  79 + t += 8;
  80 + break;
  81 + case 1:
  82 + t += 0x108;
  83 + break;
  84 + default:
  85 + t += 0x108;
  86 + t <<= seg - 1;
  87 + }
  88 +
  89 + return (short) ((alaw_val & 0x80) == 0 ? t : -t);
  90 + }
  91 +}
src/main/java/com/genersoft/iot/vmp/jtt1078/util/AudioCodecUtil.java 0 → 100644
  1 +package com.genersoft.iot.vmp.jtt1078.util;
  2 +
  3 +import io.netty.buffer.ByteBuf;
  4 +import io.netty.buffer.Unpooled;
  5 +import java.util.Arrays;
  6 +
  7 +/**
  8 + * 音频转换与封包工具 (修复版)
  9 + */
  10 +public class AudioCodecUtil {
  11 +
  12 + // G.711A (byte) -> PCM (byte[])
  13 + public static byte[] g711aToPcm(byte[] g711Data) {
  14 + if (g711Data == null) return new byte[0];
  15 + byte[] pcmData = new byte[g711Data.length * 2];
  16 + for (int i = 0; i < g711Data.length; i++) {
  17 + short s = ALaw.decode(g711Data[i]);
  18 + pcmData[2 * i] = (byte) (s & 0xff);
  19 + pcmData[2 * i + 1] = (byte) ((s >> 8) & 0xff);
  20 + }
  21 + return pcmData;
  22 + }
  23 +
  24 + // PCM -> G.711A (byte[])
  25 + public static byte[] pcmToG711a(byte[] pcmData) {
  26 + if (pcmData == null) return new byte[0];
  27 + byte[] g711Data = new byte[pcmData.length / 2];
  28 + for (int i = 0; i < g711Data.length; i++) {
  29 + int low = pcmData[2 * i] & 0xff;
  30 + int high = pcmData[2 * i + 1] & 0xff;
  31 + short s = (short) (low | (high << 8));
  32 + g711Data[i] = ALaw.encode(s);
  33 + }
  34 + return g711Data;
  35 + }
  36 +
  37 + /**
  38 + * 【核心修复】更安全的封包逻辑
  39 + */
  40 + public static ByteBuf encodeJt1078AudioPacket(String sim, int channel, int sequence, byte[] audioBody) {
  41 + // 计算总长度:30字节头 + 音频体长度
  42 + int totalLen = 30 + (audioBody != null ? audioBody.length : 0);
  43 +
  44 + // 创建 HeapBuffer
  45 + ByteBuf buffer = Unpooled.buffer(totalLen);
  46 +
  47 + // --- 30字节头 ---
  48 + buffer.writeInt(0x30316364); // 0-3: Frame Header
  49 + buffer.writeByte(0x80); // 4: RTP V=2, P=0, X=0, CC=0
  50 + buffer.writeByte(0x88); // 5: M=1, PT=8 (PCMA)
  51 + buffer.writeShort(sequence); // 6-7: Sequence
  52 +
  53 + // 8-13: SIM卡号 (BCD)
  54 + byte[] bcdSim = str2Bcd(sim);
  55 + buffer.writeBytes(bcdSim); // 写入6字节
  56 +
  57 + buffer.writeByte(channel); // 14: Channel
  58 + buffer.writeByte(0x30); // 15: DataType (0011 0000 -> 音频I帧)
  59 +
  60 + buffer.writeLong(System.currentTimeMillis()); // 16-23: Timestamp
  61 +
  62 + // 24-27: Intervals
  63 + buffer.writeShort(0);
  64 + buffer.writeShort(0);
  65 +
  66 + // 28-29: Body Length
  67 + int bodyLen = (audioBody != null ? audioBody.length : 0);
  68 + buffer.writeShort(bodyLen);
  69 +
  70 + // --- 写入音频数据 ---
  71 + if (bodyLen > 0) {
  72 + buffer.writeBytes(audioBody);
  73 + }
  74 +
  75 + return buffer;
  76 + }
  77 +
  78 + /**
  79 + * 【修复】更健壮的 BCD 转码
  80 + * 确保必须返回 6 字节,不足补0,超过截断
  81 + */
  82 + public static byte[] str2Bcd(String asc) {
  83 + if (asc == null) return new byte[6];
  84 +
  85 + // 1. 预处理:只保留数字,不足12位补0
  86 + String num = asc.replaceAll("[^0-9]", "");
  87 + if (num.length() > 12) num = num.substring(0, 12);
  88 + else while (num.length() < 12) num = "0" + num; // 左补0
  89 +
  90 + // 2. 转换
  91 + byte[] bbt = new byte[6];
  92 + char[] chars = num.toCharArray();
  93 +
  94 + for (int p = 0; p < 6; p++) {
  95 + int high = chars[2 * p] - '0';
  96 + int low = chars[2 * p + 1] - '0';
  97 + bbt[p] = (byte) ((high << 4) | low);
  98 + }
  99 + return bbt;
  100 + }
  101 +}
src/main/java/com/genersoft/iot/vmp/jtt1078/util/ByteHolder.java
@@ -3,92 +3,108 @@ package com.genersoft.iot.vmp.jtt1078.util; @@ -3,92 +3,108 @@ package com.genersoft.iot.vmp.jtt1078.util;
3 import java.util.Arrays; 3 import java.util.Arrays;
4 4
5 /** 5 /**
  6 + * 字节缓冲区工具类
6 * Created by matrixy on 2018-06-15. 7 * Created by matrixy on 2018-06-15.
7 */ 8 */
8 -public class ByteHolder  
9 -{ 9 +public class ByteHolder {
10 int offset = 0; 10 int offset = 0;
11 int size = 0; 11 int size = 0;
12 byte[] buffer = null; 12 byte[] buffer = null;
13 13
14 - public ByteHolder(int bufferSize)  
15 - { 14 + public ByteHolder(int bufferSize) {
16 this.buffer = new byte[bufferSize]; 15 this.buffer = new byte[bufferSize];
17 } 16 }
18 17
19 - public int size()  
20 - { 18 + public int size() {
21 return this.size; 19 return this.size;
22 } 20 }
23 21
24 - public void write(byte[] data)  
25 - { 22 + public void write(byte[] data) {
26 write(data, 0, data.length); 23 write(data, 0, data.length);
27 } 24 }
28 25
29 - public void write(byte[] data, int offset, int length)  
30 - {  
31 - while (this.offset + length >= buffer.length) 26 + public void write(byte[] data, int offset, int length) {
  27 + // 防止溢出,简单的扩容策略或抛异常
  28 + if (this.offset + length > buffer.length) {
  29 + // 简单扩容策略:如果空间不够,扩大两倍 (可选)
  30 + // byte[] newBuffer = new byte[Math.max(buffer.length * 2, this.offset + length)];
  31 + // System.arraycopy(buffer, 0, newBuffer, 0, this.offset);
  32 + // this.buffer = newBuffer;
  33 +
  34 + // 原有逻辑:直接抛异常
32 throw new RuntimeException(String.format("exceed the max buffer size, max length: %d, data length: %d", buffer.length, length)); 35 throw new RuntimeException(String.format("exceed the max buffer size, max length: %d, data length: %d", buffer.length, length));
  36 + }
33 37
34 - // 复制一下内容  
35 System.arraycopy(data, offset, buffer, this.offset, length); 38 System.arraycopy(data, offset, buffer, this.offset, length);
36 -  
37 this.offset += length; 39 this.offset += length;
38 this.size += length; 40 this.size += length;
39 } 41 }
40 42
41 - public byte[] array()  
42 - { 43 + public byte[] array() {
43 return array(this.size); 44 return array(this.size);
44 } 45 }
45 46
46 - public byte[] array(int length)  
47 - { 47 + public byte[] array(int length) {
48 return Arrays.copyOf(this.buffer, length); 48 return Arrays.copyOf(this.buffer, length);
49 } 49 }
50 50
51 - public void write(byte b)  
52 - { 51 + public void write(byte b) {
  52 + if (this.offset >= buffer.length) {
  53 + throw new RuntimeException("Buffer overflow");
  54 + }
53 this.buffer[offset++] = b; 55 this.buffer[offset++] = b;
54 this.size += 1; 56 this.size += 1;
55 } 57 }
56 58
57 - public void sliceInto(byte[] dest, int length)  
58 - { 59 + /**
  60 + * 将数据读入 dest 数组,并从缓冲区移除这些数据
  61 + */
  62 + public void sliceInto(byte[] dest, int length) {
  63 + if (length > this.size) {
  64 + throw new RuntimeException("Buffer underflow: not enough bytes to read");
  65 + }
59 System.arraycopy(this.buffer, 0, dest, 0, length); 66 System.arraycopy(this.buffer, 0, dest, 0, length);
60 - // 往前挪length个位 67 + // 往前挪 length 个位 (移除已读数据)
61 System.arraycopy(this.buffer, length, this.buffer, 0, this.size - length); 68 System.arraycopy(this.buffer, length, this.buffer, 0, this.size - length);
62 this.offset -= length; 69 this.offset -= length;
63 this.size -= length; 70 this.size -= length;
64 } 71 }
65 72
66 - public void slice(int length)  
67 - {  
68 - // 往前挪length个位 73 + /**
  74 + * 【新增】读取字节到数组中,并从缓冲区移除
  75 + * 配合 Decoder 的搜寻包头逻辑使用
  76 + */
  77 + public void read(byte[] dest) {
  78 + sliceInto(dest, dest.length);
  79 + }
  80 +
  81 + /**
  82 + * 丢弃前 length 个字节
  83 + */
  84 + public void slice(int length) {
  85 + if (length > this.size) {
  86 + length = this.size; // 容错处理,最多丢弃所有
  87 + }
69 System.arraycopy(this.buffer, length, this.buffer, 0, this.size - length); 88 System.arraycopy(this.buffer, length, this.buffer, 0, this.size - length);
70 this.offset -= length; 89 this.offset -= length;
71 this.size -= length; 90 this.size -= length;
72 } 91 }
73 92
74 - public byte get(int position)  
75 - { 93 + public byte get(int position) {
76 return this.buffer[position]; 94 return this.buffer[position];
77 } 95 }
78 96
79 - public void clear()  
80 - { 97 + public void clear() {
81 this.offset = 0; 98 this.offset = 0;
82 this.size = 0; 99 this.size = 0;
83 } 100 }
84 101
85 - public int getInt(int offset)  
86 - { 102 + public int getInt(int offset) {
  103 + // 需保证 ByteUtils 存在且逻辑正确
87 return ByteUtils.getInt(this.buffer, offset, 4); 104 return ByteUtils.getInt(this.buffer, offset, 4);
88 } 105 }
89 106
90 - public int getShort(int position)  
91 - { 107 + public int getShort(int position) {
92 int h = this.buffer[position] & 0xff; 108 int h = this.buffer[position] & 0xff;
93 int l = this.buffer[position + 1] & 0xff; 109 int l = this.buffer[position + 1] & 0xff;
94 return ((h << 8) | l) & 0xffff; 110 return ((h << 8) | l) & 0xffff;
src/main/java/com/genersoft/iot/vmp/jtt1078/websocket/Jtt1078AudioBroadcastManager.java 0 → 100644
  1 +package com.genersoft.iot.vmp.jtt1078.websocket;
  2 +
  3 +import com.genersoft.iot.vmp.jtt1078.util.AudioCodecUtil;
  4 +import org.slf4j.Logger;
  5 +import org.slf4j.LoggerFactory;
  6 +import org.springframework.stereotype.Component;
  7 +
  8 +import javax.websocket.Session;
  9 +import java.io.IOException;
  10 +import java.nio.ByteBuffer;
  11 +import java.util.Set;
  12 +import java.util.concurrent.ConcurrentHashMap;
  13 +import java.util.concurrent.CopyOnWriteArraySet;
  14 +
  15 +@Component
  16 +public class Jtt1078AudioBroadcastManager {
  17 + private static final Logger logger = LoggerFactory.getLogger(Jtt1078AudioBroadcastManager.class);
  18 +
  19 + private static final ConcurrentHashMap<String, Set<Session>> sessionMap = new ConcurrentHashMap<>();
  20 +
  21 + public static void addSession(String sim, int channel, Session session) {
  22 + String key = sim + "_" + channel;
  23 + sessionMap.computeIfAbsent(key, k -> new CopyOnWriteArraySet<>()).add(session);
  24 + logger.info("Frontend joined audio stream: {}", key);
  25 + }
  26 +
  27 + public static void removeSession(String sim, int channel, Session session) {
  28 + String key = sim + "_" + channel;
  29 + Set<Session> sessions = sessionMap.get(key);
  30 + if (sessions != null) {
  31 + sessions.remove(session);
  32 + if (sessions.isEmpty()) {
  33 + sessionMap.remove(key);
  34 + }
  35 + }
  36 + }
  37 +
  38 + public static void broadcastAudio(String sim, int channel, byte[] g711Data) {
  39 + String key = sim + "_" + channel;
  40 + Set<Session> sessions = sessionMap.get(key);
  41 +
  42 + if (sessions != null && !sessions.isEmpty()) {
  43 + // 转码: G.711A -> PCM (浏览器友好)
  44 + byte[] pcmData = AudioCodecUtil.g711aToPcm(g711Data);
  45 + ByteBuffer buffer = ByteBuffer.wrap(pcmData);
  46 +
  47 + for (Session session : sessions) {
  48 + if (session.isOpen()) {
  49 + try {
  50 + synchronized (session) {
  51 + session.getBasicRemote().sendBinary(buffer.duplicate());
  52 + }
  53 + } catch (IOException e) {
  54 + logger.error("WebSocket send error", e);
  55 + }
  56 + }
  57 + }
  58 + }
  59 + }
  60 +}
src/main/java/com/genersoft/iot/vmp/jtt1078/websocket/Jtt1078AudioWebSocketServer.java 0 → 100644
  1 +package com.genersoft.iot.vmp.jtt1078.websocket;
  2 +
  3 +import com.genersoft.iot.vmp.jtt1078.server.SessionManager;
  4 +import com.genersoft.iot.vmp.jtt1078.util.AudioCodecUtil;
  5 +import io.netty.buffer.ByteBuf;
  6 +import io.netty.channel.Channel;
  7 +import lombok.extern.slf4j.Slf4j;
  8 +import org.springframework.stereotype.Component;
  9 +
  10 +import javax.websocket.*;
  11 +import javax.websocket.server.PathParam;
  12 +import javax.websocket.server.ServerEndpoint;
  13 +import java.util.concurrent.atomic.AtomicInteger;
  14 +
  15 +@Slf4j
  16 +@Component
  17 +@ServerEndpoint("/ws/audio/{sim}/{channel}")
  18 +public class Jtt1078AudioWebSocketServer {
  19 +
  20 + private String sim;
  21 + private int channel;
  22 + private final AtomicInteger sequence = new AtomicInteger(0);
  23 +
  24 + @OnOpen
  25 + public void onOpen(Session session, @PathParam("sim") String sim, @PathParam("channel") int channel) {
  26 + this.sim = sim.replaceAll("^0+", "");
  27 + this.channel = channel;
  28 + Jtt1078AudioBroadcastManager.addSession(this.sim, channel, session);
  29 + log.info("Intercom Connect: {}", this.sim);
  30 + }
  31 +
  32 + @OnClose
  33 + public void onClose(Session session) {
  34 + Jtt1078AudioBroadcastManager.removeSession(sim, channel, session);
  35 + log.info("Intercom Disconnect: {}", sim);
  36 + }
  37 +
  38 + @OnError
  39 + public void onError(Session session, Throwable error) {
  40 + Jtt1078AudioBroadcastManager.removeSession(sim, channel, session);
  41 + log.error("Intercom Error: {}", error.getMessage());
  42 + }
  43 +
  44 + @OnMessage
  45 + public void onMessage(byte[] pcmData, Session session) {
  46 + ByteBuf jt1078Packet = null;
  47 + try {
  48 + // 1. 校验数据
  49 + if (pcmData == null || pcmData.length == 0) return;
  50 +
  51 + // 2. 转码
  52 + byte[] g711Data = AudioCodecUtil.pcmToG711a(pcmData);
  53 +
  54 + // 3. 封包 (获取 ByteBuf)
  55 + int seq = sequence.getAndIncrement();
  56 + jt1078Packet = AudioCodecUtil.encodeJt1078AudioPacket(sim, channel, seq, g711Data);
  57 +
  58 + // 4. 发送
  59 + String tag = sim + "-" + channel;
  60 + Channel nettyChannel = SessionManager.getChannelByTag(tag);
  61 +
  62 + if (nettyChannel != null && nettyChannel.isActive()) {
  63 + // writeAndFlush 会自动处理 refCnt 的释放(无论成功失败)
  64 + // 我们不需要手动 release,除非发送操作根本没执行
  65 + nettyChannel.writeAndFlush(jt1078Packet);
  66 + } else {
  67 + // 如果没发送,必须释放,防止内存泄漏
  68 + if (jt1078Packet.refCnt() > 0) {
  69 + jt1078Packet.release();
  70 + }
  71 + if (sequence.get() % 100 == 0) { // 减少日志频次
  72 + log.warn("Device offline or channel missing. Tag: {}", tag);
  73 + }
  74 + }
  75 + } catch (Exception e) {
  76 + log.error("Send audio failed", e);
  77 + // 异常发生时,如果 packet 已经创建但未发送,需要释放
  78 + if (jt1078Packet != null && jt1078Packet.refCnt() > 0) {
  79 + try { jt1078Packet.release(); } catch (Exception ex) {}
  80 + }
  81 + }
  82 + }
  83 +}
src/main/java/com/genersoft/iot/vmp/media/zlm/ZLMHttpHookListener.java
@@ -32,6 +32,7 @@ import com.genersoft.iot.vmp.storager.IVideoManagerStorage; @@ -32,6 +32,7 @@ import com.genersoft.iot.vmp.storager.IVideoManagerStorage;
32 import com.genersoft.iot.vmp.utils.DateUtil; 32 import com.genersoft.iot.vmp.utils.DateUtil;
33 import com.genersoft.iot.vmp.vmanager.bean.*; 33 import com.genersoft.iot.vmp.vmanager.bean.*;
34 import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController; 34 import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController;
  35 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.IntercomService;
35 import org.apache.commons.lang3.StringUtils; 36 import org.apache.commons.lang3.StringUtils;
36 import org.slf4j.Logger; 37 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory; 38 import org.slf4j.LoggerFactory;
@@ -44,6 +45,7 @@ import org.springframework.util.ObjectUtils; @@ -44,6 +45,7 @@ import org.springframework.util.ObjectUtils;
44 import org.springframework.web.bind.annotation.*; 45 import org.springframework.web.bind.annotation.*;
45 import org.springframework.web.context.request.async.DeferredResult; 46 import org.springframework.web.context.request.async.DeferredResult;
46 47
  48 +import javax.annotation.Resource;
47 import javax.servlet.http.HttpServletRequest; 49 import javax.servlet.http.HttpServletRequest;
48 import javax.sip.InvalidArgumentException; 50 import javax.sip.InvalidArgumentException;
49 import javax.sip.SipException; 51 import javax.sip.SipException;
@@ -134,6 +136,8 @@ public class ZLMHttpHookListener { @@ -134,6 +136,8 @@ public class ZLMHttpHookListener {
134 136
135 @Autowired 137 @Autowired
136 private StremProxyService1078 stremProxyService1078; 138 private StremProxyService1078 stremProxyService1078;
  139 + @Resource
  140 + private IntercomService intercomService;
137 141
138 142
139 /** 143 /**
@@ -207,7 +211,7 @@ public class ZLMHttpHookListener { @@ -207,7 +211,7 @@ public class ZLMHttpHookListener {
207 return new HookResultForOnPublish(200, "success"); 211 return new HookResultForOnPublish(200, "success");
208 } 212 }
209 // 推流鉴权的处理 213 // 推流鉴权的处理
210 - if (!"rtp".equals(param.getApp())) { 214 + if (!"rtp".equals(param.getApp()) && !"schedule".equals(param.getApp())) {
211 StreamProxyItem stream = streamProxyService.getStreamProxyByAppAndStream(param.getApp(), param.getStream()); 215 StreamProxyItem stream = streamProxyService.getStreamProxyByAppAndStream(param.getApp(), param.getStream());
212 if (stream != null) { 216 if (stream != null) {
213 HookResultForOnPublish result = HookResultForOnPublish.SUCCESS(); 217 HookResultForOnPublish result = HookResultForOnPublish.SUCCESS();
@@ -685,10 +689,15 @@ public class ZLMHttpHookListener { @@ -685,10 +689,15 @@ public class ZLMHttpHookListener {
685 if (object == null) { 689 if (object == null) {
686 String[] split = param.getStream().split("_"); 690 String[] split = param.getStream().split("_");
687 if (split != null && split.length == 2) { 691 if (split != null && split.length == 2) {
688 - try {  
689 - jt1078OfCarController.clearMap(split[1],split[0]);  
690 - } catch (ServiceException e) {  
691 - throw new RuntimeException(e); 692 + if (split[1].equals("voice")){
  693 + intercomService.stopIntercom(split[0]);
  694 + logger.info("{} 历史语音断流成功", split[0]);
  695 + }else {
  696 + try {
  697 + jt1078OfCarController.clearMap(split[1],split[0]);
  698 + } catch (ServiceException e) {
  699 + throw new RuntimeException(e);
  700 + }
692 } 701 }
693 } 702 }
694 } 703 }
src/main/java/com/genersoft/iot/vmp/service/IStreamPushService.java
@@ -6,6 +6,7 @@ import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem; @@ -6,6 +6,7 @@ import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
6 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem; 6 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem;
7 import com.genersoft.iot.vmp.service.bean.StreamPushItemFromRedis; 7 import com.genersoft.iot.vmp.service.bean.StreamPushItemFromRedis;
8 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo; 8 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo;
  9 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch;
9 import com.github.pagehelper.PageInfo; 10 import com.github.pagehelper.PageInfo;
10 import org.springframework.scheduling.annotation.Scheduled; 11 import org.springframework.scheduling.annotation.Scheduled;
11 12
@@ -116,7 +117,15 @@ public interface IStreamPushService { @@ -116,7 +117,15 @@ public interface IStreamPushService {
116 */ 117 */
117 ResourceBaseInfo getOverview(); 118 ResourceBaseInfo getOverview();
118 119
  120 + /**
  121 + * 获取全部的app+Streanm 用于判断推流列表是新增还是修改
  122 + * @return app+Streanm
  123 + */
119 Map<String, StreamPushItem> getAllAppAndStreamMap(); 124 Map<String, StreamPushItem> getAllAppAndStreamMap();
120 125
121 - 126 + /**
  127 + * 推流切换
  128 + * @param streamSwitch 切换信息
  129 + */
  130 + void switchStream(StreamSwitch streamSwitch);
122 } 131 }
src/main/java/com/genersoft/iot/vmp/service/impl/StreamPushServiceImpl.java
@@ -22,8 +22,15 @@ import com.genersoft.iot.vmp.storager.IRedisCatchStorage; @@ -22,8 +22,15 @@ import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
22 import com.genersoft.iot.vmp.storager.mapper.*; 22 import com.genersoft.iot.vmp.storager.mapper.*;
23 import com.genersoft.iot.vmp.utils.DateUtil; 23 import com.genersoft.iot.vmp.utils.DateUtil;
24 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo; 24 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo;
  25 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean;
  26 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.ThirdPartyHttpService;
  27 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch;
  28 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9102;
  29 +import com.genersoft.iot.vmp.vmanager.util.RedisCache;
25 import com.github.pagehelper.PageHelper; 30 import com.github.pagehelper.PageHelper;
26 import com.github.pagehelper.PageInfo; 31 import com.github.pagehelper.PageInfo;
  32 +import lombok.extern.slf4j.Slf4j;
  33 +import org.apache.commons.lang3.StringUtils;
27 import org.slf4j.Logger; 34 import org.slf4j.Logger;
28 import org.slf4j.LoggerFactory; 35 import org.slf4j.LoggerFactory;
29 import org.springframework.beans.factory.annotation.Autowired; 36 import org.springframework.beans.factory.annotation.Autowired;
@@ -33,11 +40,13 @@ import org.springframework.transaction.TransactionDefinition; @@ -33,11 +40,13 @@ import org.springframework.transaction.TransactionDefinition;
33 import org.springframework.transaction.TransactionStatus; 40 import org.springframework.transaction.TransactionStatus;
34 import org.springframework.util.ObjectUtils; 41 import org.springframework.util.ObjectUtils;
35 42
  43 +import javax.annotation.Resource;
36 import java.util.*; 44 import java.util.*;
37 import java.util.stream.Collectors; 45 import java.util.stream.Collectors;
38 46
39 @Service 47 @Service
40 @DS("master") 48 @DS("master")
  49 +@Slf4j
41 public class StreamPushServiceImpl implements IStreamPushService { 50 public class StreamPushServiceImpl implements IStreamPushService {
42 51
43 private final static Logger logger = LoggerFactory.getLogger(StreamPushServiceImpl.class); 52 private final static Logger logger = LoggerFactory.getLogger(StreamPushServiceImpl.class);
@@ -87,6 +96,13 @@ public class StreamPushServiceImpl implements IStreamPushService { @@ -87,6 +96,13 @@ public class StreamPushServiceImpl implements IStreamPushService {
87 @Autowired 96 @Autowired
88 private MediaConfig mediaConfig; 97 private MediaConfig mediaConfig;
89 98
  99 + @Resource
  100 + private Jt1078ConfigBean jt1078ConfigBean;
  101 + @Resource
  102 + private ThirdPartyHttpService httpService;
  103 + @Resource
  104 + private RedisCache redisCache;
  105 +
90 106
91 @Override 107 @Override
92 public List<StreamPushItem> handleJSON(String jsonData, MediaServerItem mediaServerItem) { 108 public List<StreamPushItem> handleJSON(String jsonData, MediaServerItem mediaServerItem) {
@@ -553,4 +569,40 @@ public class StreamPushServiceImpl implements IStreamPushService { @@ -553,4 +569,40 @@ public class StreamPushServiceImpl implements IStreamPushService {
553 public Map<String, StreamPushItem> getAllAppAndStreamMap() { 569 public Map<String, StreamPushItem> getAllAppAndStreamMap() {
554 return streamPushMapper.getAllAppAndStreamMap(); 570 return streamPushMapper.getAllAppAndStreamMap();
555 } 571 }
  572 +
  573 + /**
  574 + * 终端切换码流
  575 + * @param streamSwitch 终端切换码流参数
  576 + */
  577 + @Override
  578 + public void switchStream(StreamSwitch streamSwitch) {
  579 +
  580 + // 去除SIM号前面的所有0
  581 + String trimmedSim = streamSwitch.getSim().replaceFirst("^0+(?=\\d)", "");
  582 +
  583 + // 1. 组装 9102 请求体
  584 + T9102 reqBody = T9102.builder()
  585 + .clientId(streamSwitch.getSim())
  586 + .messageId(0x9102)
  587 + .channelNo(streamSwitch.getChannel())
  588 + .command(1)
  589 + .streamType(streamSwitch.getType())
  590 + .build();
  591 +
  592 + String jsonBody = JSON.toJSONString(reqBody);
  593 +
  594 + // 2. 替换 URL 为 9102
  595 + String targetUrl = StringUtils.replace(jt1078ConfigBean.getJt1078Url(), "{0}", "9102");
  596 +
  597 + log.info("下发9102切换码流. SIM: {}, URL: {}, stream: {}", trimmedSim, targetUrl, streamSwitch.getType());
  598 +
  599 + // 3. 发送请求
  600 + try {
  601 + String responseStr = httpService.doPost(targetUrl, jsonBody, null);
  602 + log.info("设备[{}] 9102响应: {}", trimmedSim, responseStr);
  603 + } catch (Exception e) {
  604 + log.error("设备[{}] 下发9102失败", trimmedSim, e);
  605 + }
  606 +
  607 + }
556 } 608 }
src/main/java/com/genersoft/iot/vmp/utils/G711Utils.java 0 → 100644
  1 +package com.genersoft.iot.vmp.utils;
  2 +
  3 +public class G711Utils {
  4 + private final static int SIGN_BIT = 0x80;
  5 + private final static int QUANT_MASK = 0xf;
  6 + private final static int NSEGS = 8;
  7 + private final static int SEG_MASK = 0x70;
  8 + private final static int SEG_SHIFT = 4;
  9 + private final static int[] seg_uend = {0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF};
  10 +
  11 + public static byte[] encodeA(byte[] pcm) {
  12 + byte[] table = new byte[pcm.length / 2];
  13 + for (int i = 0, j = 0; i < pcm.length; i += 2, j++) {
  14 + // [核心修复] 尝试交换高低位 (杂音修复)
  15 + // 方案 A: 假设输入是 Little Endian (JTT1078标准) -> 这里的写法是对的
  16 + // 方案 B: 如果还是杂音,很可能你的 PCM 是 Big Endian,需要交换 pcm[i] 和 pcm[i+1]
  17 +
  18 + // 下面这是 JTT1078 常见的 Little Endian 处理方式:
  19 + // pcm[i] 是低位, pcm[i+1] 是高位
  20 + short s = (short) ((pcm[i] & 0xff) | (pcm[i + 1] << 8));
  21 +
  22 + // [调试] 如果依然杂音刺耳,请注释掉上面一行,换成下面这行测试:
  23 + // short s = (short) ((pcm[i] << 8) | (pcm[i + 1] & 0xff));
  24 +
  25 + table[j] = linear2alaw(s);
  26 + }
  27 + return table;
  28 + }
  29 +
  30 + private static byte linear2alaw(short pcm_val) {
  31 + int mask;
  32 + int seg;
  33 + byte aval;
  34 +
  35 + if (pcm_val >= 0) {
  36 + mask = 0xD5;
  37 + } else {
  38 + mask = 0x55;
  39 + pcm_val = (short) (-pcm_val - 8);
  40 + }
  41 +
  42 + if (pcm_val < 0) {
  43 + pcm_val = 32767;
  44 + }
  45 +
  46 + seg = 8;
  47 + for (int i = 0; i < NSEGS; i++) {
  48 + if (pcm_val <= seg_uend[i]) {
  49 + seg = i;
  50 + break;
  51 + }
  52 + }
  53 +
  54 + if (seg >= 8)
  55 + return (byte) (0x7F ^ mask);
  56 + else {
  57 + aval = (byte) (seg << SEG_SHIFT);
  58 + if (seg < 2)
  59 + aval |= (pcm_val >> 4) & QUANT_MASK;
  60 + else
  61 + aval |= (pcm_val >> (seg + 3)) & QUANT_MASK;
  62 + return (byte) (aval ^ mask);
  63 + }
  64 + }
  65 +}
src/main/java/com/genersoft/iot/vmp/vmanager/bean/StreamContent.java
@@ -138,6 +138,12 @@ public class StreamContent { @@ -138,6 +138,12 @@ public class StreamContent {
138 private String wss_ts; 138 private String wss_ts;
139 139
140 /** 140 /**
  141 + * WebRTC流地址
  142 + */
  143 + @Schema(description = "WebRTC流地址")
  144 + private String webRtc;
  145 +
  146 + /**
141 * RTMP流地址 147 * RTMP流地址
142 */ 148 */
143 @Schema(description = "RTMP流地址") 149 @Schema(description = "RTMP流地址")
@@ -530,6 +536,14 @@ public class StreamContent { @@ -530,6 +536,14 @@ public class StreamContent {
530 this.startTime = startTime; 536 this.startTime = startTime;
531 } 537 }
532 538
  539 + public String getWebRtc() {
  540 + return webRtc;
  541 + }
  542 +
  543 + public void setWebRtc(String webRtc) {
  544 + this.webRtc = webRtc;
  545 + }
  546 +
533 public String getEndTime() { 547 public String getEndTime() {
534 return endTime; 548 return endTime;
535 } 549 }
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/Jt1078OfCarController.java
@@ -9,7 +9,6 @@ import com.alibaba.fastjson2.JSON; @@ -9,7 +9,6 @@ import com.alibaba.fastjson2.JSON;
9 import com.alibaba.fastjson2.JSONArray; 9 import com.alibaba.fastjson2.JSONArray;
10 import com.alibaba.fastjson2.JSONException; 10 import com.alibaba.fastjson2.JSONException;
11 import com.alibaba.fastjson2.JSONObject; 11 import com.alibaba.fastjson2.JSONObject;
12 -import com.genersoft.iot.vmp.common.StreamInfo;  
13 import com.genersoft.iot.vmp.conf.MediaConfig; 12 import com.genersoft.iot.vmp.conf.MediaConfig;
14 import com.genersoft.iot.vmp.conf.StreamProxyTask; 13 import com.genersoft.iot.vmp.conf.StreamProxyTask;
15 import com.genersoft.iot.vmp.conf.exception.ControllerException; 14 import com.genersoft.iot.vmp.conf.exception.ControllerException;
@@ -18,9 +17,7 @@ import com.genersoft.iot.vmp.conf.security.JwtUtils; @@ -18,9 +17,7 @@ import com.genersoft.iot.vmp.conf.security.JwtUtils;
18 import com.genersoft.iot.vmp.conf.security.dto.JwtUser; 17 import com.genersoft.iot.vmp.conf.security.dto.JwtUser;
19 import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp; 18 import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp;
20 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager; 19 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager;
21 -import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher;  
22 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem; 20 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem;
23 -import com.genersoft.iot.vmp.service.IMediaService;  
24 import com.genersoft.iot.vmp.service.IStreamPushService; 21 import com.genersoft.iot.vmp.service.IStreamPushService;
25 import com.genersoft.iot.vmp.service.StremProxyService1078; 22 import com.genersoft.iot.vmp.service.StremProxyService1078;
26 import com.genersoft.iot.vmp.vmanager.bean.ErrorCode; 23 import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
@@ -31,13 +28,10 @@ import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.FtpConfigBean; @@ -31,13 +28,10 @@ import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.FtpConfigBean;
31 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean; 28 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean;
32 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.RtspConfigBean; 29 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.RtspConfigBean;
33 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.TuohuaConfigBean; 30 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.TuohuaConfigBean;
34 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.CarData;  
35 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.HistoryEnum;  
36 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.HistoryRecord;  
37 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.PatrolDataReq; 31 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.*;
38 import com.genersoft.iot.vmp.vmanager.jt1078.platform.handler.HttpClientUtil; 32 import com.genersoft.iot.vmp.vmanager.jt1078.platform.handler.HttpClientUtil;
39 import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.HisToryRecordService; 33 import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.HisToryRecordService;
40 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.Jt1078OfService; 34 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.IntercomService;
41 import com.genersoft.iot.vmp.vmanager.streamProxy.StreamProxyController; 35 import com.genersoft.iot.vmp.vmanager.streamProxy.StreamProxyController;
42 import com.genersoft.iot.vmp.vmanager.streamPush.StreamPushController; 36 import com.genersoft.iot.vmp.vmanager.streamPush.StreamPushController;
43 import com.genersoft.iot.vmp.vmanager.util.FtpUtils; 37 import com.genersoft.iot.vmp.vmanager.util.FtpUtils;
@@ -53,14 +47,12 @@ import org.slf4j.Logger; @@ -53,14 +47,12 @@ import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory; 47 import org.slf4j.LoggerFactory;
54 import org.springframework.beans.factory.annotation.Autowired; 48 import org.springframework.beans.factory.annotation.Autowired;
55 import org.springframework.beans.factory.annotation.Value; 49 import org.springframework.beans.factory.annotation.Value;
56 -import org.springframework.context.annotation.Bean;  
57 import org.springframework.core.io.InputStreamResource; 50 import org.springframework.core.io.InputStreamResource;
58 import org.springframework.data.redis.core.RedisTemplate; 51 import org.springframework.data.redis.core.RedisTemplate;
59 import org.springframework.http.HttpHeaders; 52 import org.springframework.http.HttpHeaders;
60 import org.springframework.http.MediaType; 53 import org.springframework.http.MediaType;
61 import org.springframework.http.ResponseEntity; 54 import org.springframework.http.ResponseEntity;
62 import org.springframework.scheduling.annotation.Scheduled; 55 import org.springframework.scheduling.annotation.Scheduled;
63 -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;  
64 import org.springframework.util.Base64Utils; 56 import org.springframework.util.Base64Utils;
65 import org.springframework.web.bind.annotation.*; 57 import org.springframework.web.bind.annotation.*;
66 import sun.misc.Signal; 58 import sun.misc.Signal;
@@ -134,6 +126,8 @@ public class Jt1078OfCarController { @@ -134,6 +126,8 @@ public class Jt1078OfCarController {
134 private String profilesActive; 126 private String profilesActive;
135 @Resource 127 @Resource
136 private FtpUtils ftpUtils; 128 private FtpUtils ftpUtils;
  129 + @Resource
  130 + private IntercomService intercomService;
137 131
138 private static final ConcurrentHashMap<String, String> CHANNEL1_MAP = new ConcurrentHashMap<>(); 132 private static final ConcurrentHashMap<String, String> CHANNEL1_MAP = new ConcurrentHashMap<>();
139 private static final ConcurrentHashMap<String, String> CHANNEL2_MAP = new ConcurrentHashMap<>(); 133 private static final ConcurrentHashMap<String, String> CHANNEL2_MAP = new ConcurrentHashMap<>();
@@ -452,6 +446,16 @@ public class Jt1078OfCarController { @@ -452,6 +446,16 @@ public class Jt1078OfCarController {
452 return streamContent; 446 return streamContent;
453 } 447 }
454 448
  449 +
  450 + @PostMapping("/switch/stream")
  451 + public int switchStream(@RequestBody StreamSwitch streamSwitch){
  452 + if (streamSwitch == null || streamSwitch.getSim() == null || streamSwitch.getChannel() == null || streamSwitch.getType() == null) {
  453 + throw new ControllerException(ErrorCode.ERROR400);
  454 + }
  455 + streamPushService.switchStream(streamSwitch);
  456 + return 0;
  457 + }
  458 +
455 /** 459 /**
456 * 批量请求设备推送流 460 * 批量请求设备推送流
457 * 461 *
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/config/Jt1078ConfigBean.java
1 -package com.genersoft.iot.vmp.vmanager.jt1078.platform.config; import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.util.*; @Data @Component public class Jt1078ConfigBean { @Value("${tuohua.bsth.jt1078.url}") private String jt1078Url; @Value("${tuohua.bsth.jt1078.sendPort}") private String jt1078SendPort; @Value("${tuohua.bsth.jt1078.stopSendPort}") private String stopSendPort; @Value("${tuohua.bsth.jt1078.historyListPort}") private String historyListPort; @Value("${tuohua.bsth.jt1078.history_upload}") private String historyUpload = "9206"; @Value("${tuohua.bsth.jt1078.playHistoryPort}") private String playHistoryPort; @Value("${tuohua.bsth.jt1078.ports}") private String portsOf1078; @Value("${tuohua.bsth.jt1078.pushURL}") private String pushURL; @Value("${tuohua.bsth.jt1078.stopPushURL}") private String stopPUshURL; private Integer start1078Port; private Integer end1078Port; @Value("${tuohua.bsth.jt1078.get.url}") private String getURL; @Value("${tuohua.bsth.jt1078.addPortVal}") private Integer addPort; @Value("${tuohua.bsth.jt1078.ws}") private String ws; @Value("${tuohua.bsth.jt1078.wss}") private String wss; @Value("${tuohua.bsth.jt1078.downloadFLV}") private String downloadFlv; @Value("${tuohua.bsth.jt1078.port}") private Integer port; @Value("${tuohua.bsth.jt1078.httpPort}") private Integer httpPort; @Value("${spring.profiles.active}") private String profilesActive; @Value("${media.pushKey}") private String pushKey; @Resource private RedisTemplate<String, Integer> redisTemplate; @Resource private FtpConfigBean ftpConfigBean; public Integer getPort() { if (port == null) { return 30000; } return port; } public Integer getHttpPort() { if (httpPort == null) { return 30000; } return httpPort; } private Integer getIntPort() { return profilesActive.equals("wx-local") ? 10000 : 0; } @PostConstruct public void initMap() { Set<String> historyPortKeys = redisTemplate.keys("history:port:*"); Set<String> keys = redisTemplate.keys("tag:*"); Set<String> patrolKeys = redisTemplate.keys("patrol:stream:*"); Set<String> historyListKeys = redisTemplate.keys("history-list:*"); if (!historyPortKeys.isEmpty()) { keys.addAll(historyPortKeys); } if (!patrolKeys.isEmpty()) { keys.addAll(patrolKeys); } if (!historyListKeys.isEmpty()) { keys.addAll(historyListKeys); } if (keys != null) { redisTemplate.delete(keys); } Map<Integer, Set<String>> hashMap = new HashMap<>(); for (int number = getStart1078Port(); number <= getEnd1078Port(); number++) { hashMap.put(number, new HashSet<>()); } Jt1078OfCarController.map.putAll(hashMap); } private static final String SEND_IO_MESSAGE_RTSP = "{ \"messageId\": 37121, \"properties\": 0, \"clientId\": \"{clientId}\", \"serialNo\": \"1\", \"ip\": \"{ip}\", \"tcpPort\": \"{tcpPort}\", \"udpPort\": \"{udpPort}\", \"channelNo\": \"{channelNo}\", \"mediaType\": \"1\", \"streamType\": \"1\"}"; private static final String SEND_IO_MESSAGE_RTSP_STOP = "{\"messageId\": 37122,\"properties\": 0,\"clientId\": \"{clientId}\",\"serialNo\": \"1\",\"channelNo\": \"{channelNo}\",\"command\": \"0\",\"closeType\": \"0\",\"streamType\": \"1\"}"; private static final String SEND_IO_HISTORY_RTSP = "{\"msgid\":37381,\"clientId\":\"{clientId}\",\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"channelId\":{channelNo}}"; private static final String SEND_IO_PLAY_RTSP = "{\"ip\":\"{ip}\",\"tcpPort\":{tcpPort},\"udpPort\":{udpPort},\"channelNo\":\"{channelNo}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"playbackType\":0,\"playbackSpeed\":1,\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"clientId\":\"{sim}\",\"messageId\":37377}"; public String formatMessageId(String sim, String channel, RtspConfigBean configBean, Integer port) { String msg = StringUtils.replace("{ \"messageId\": 37121, \"properties\": 0, \"clientId\": \"{clientId}\", \"serialNo\": \"1\", \"ip\": \"{ip}\", \"tcpPort\": \"{tcpPort}\", \"udpPort\": \"{udpPort}\", \"channelNo\": \"{channelNo}\", \"mediaType\": \"0\", \"streamType\": \"1\"}", "{clientId}", sim); msg = StringUtils.replace(msg, "{tcpPort}", (port + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{udpPort}", (port + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{channelNo}", channel); return StringUtils.replace(msg, "{ip}", configBean.getRtspIp()); } public String formatMessageStop(String sim, String channel) { String msg = StringUtils.replace("{\"messageId\": 37122,\"properties\": 0,\"clientId\": \"{clientId}\",\"serialNo\": \"1\",\"channelNo\": \"{channelNo}\",\"command\": \"0\",\"closeType\": \"0\",\"streamType\": \"1\"}", "{clientId}", sim); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatMessageHistoryListRTSP(String sim, String channel, String startTime, String endTime) { String msg = StringUtils.replace("{\"msgid\":37381,\"clientId\":\"{clientId}\",\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"mediaType\":0,\"streamType\":1,\"storageType\":0,\"channelNo\":{channelNo}}", "{clientId}", sim); msg = StringUtils.replace(msg, "{startTime}", startTime); msg = StringUtils.replace(msg, "{endTime}", endTime); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatMessageHistoryPlayRTSP(String sim, String channel, String startTime, String endTime, RtspConfigBean configBean, Integer port) { String msg = StringUtils.replace("{\"ip\":\"{ip}\",\"tcpPort\":{tcpPort},\"udpPort\":{udpPort},\"channelNo\":\"{channelNo}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"playbackType\":0,\"playbackSpeed\":1,\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"clientId\":\"{sim}\",\"messageId\":37377}", "{clientId}", sim); msg = StringUtils.replace(msg, "{startTime}", startTime); msg = StringUtils.replace(msg, "{endTime}", endTime); msg = StringUtils.replace(msg, "{channelNo}", channel); msg = StringUtils.replace(msg, "{tcpPort}", (port.intValue() + getIntPort() +getAddPort()) + ""); msg = StringUtils.replace(msg, "{udpPort}", (port.intValue() + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{sim}", sim); return StringUtils.replace(msg, "{ip}", configBean.getRtspIp()); } public String formatMessageHistoryUpload(String stream) { if (StringUtils.isBlank(stream)) { throw new RuntimeException("上传参数不能为空"); } String[] split = stream.split("_"); if (split.length < 4){ throw new RuntimeException("上传参数异常, 请联系管理员"); } String sim = split[0]; String channel = split[1]; String startTime = split[2]; String endTime = split[3]; String msg = StringUtils.replace("{\n" + " \"clientId\": \"{clientId}\",\n" + " \"ip\": \"{ip}\",\n" + " \"port\": {port},\n" + " \"username\": \"{username}\",\n" + " \"password\": \"{password}\",\n" + " \"path\": \"{path}\",\n" + " \"channelNo\": {channel},\n" + " \"startTime\": \"{startTime}\",\n" + " \"endTime\": \"{endTime}\",\n" + " \"mediaType\": 0,\n" + " \"streamType\": 1,\n" + " \"storageType\": 1,\n" + " \"condition\": 6\n" + "}","{clientId}",sim); msg = StringUtils.replace(msg, "{ip}", ftpConfigBean.getHost()); msg = StringUtils.replace(msg, "{port}", ftpConfigBean.getPort().toString()); msg = StringUtils.replace(msg, "{username}", ftpConfigBean.getUsername()); msg = StringUtils.replace(msg, "{password}", ftpConfigBean.getPassword()); msg = StringUtils.replace(msg, "{path}", StringUtils.join(ftpConfigBean.getBasePath(),"/",sim,"/channel_",channel,"/",stream)); msg = StringUtils.replace(msg, "{channel}", channel); msg = StringUtils.replace(msg, "{startTime}", Jt1078OfCarController.timeCover(startTime)); return StringUtils.replace(msg, "{endTime}", Jt1078OfCarController.timeCover(endTime)); } public String formatMessageHistoryStopRTSP(String sim, String channel, RtspConfigBean configBean) { String msg = StringUtils.replace("{\"playbackMode\":2,\"channelNo\":{channelNo},\"playbackSpeed\":0,\"clientId\":\"{sim}\"}", "{sim}", sim); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatPushURL(String pushKey, int port, int httpPort) { String msg = StringUtils.replace(this.pushURL, "{pushKey}", pushKey); msg = StringUtils.replace(msg, "{port}", String.valueOf(port)); return StringUtils.replace(msg, "{httpPort}", String.valueOf(httpPort)); } public String formatStopPushURL(String pushKey, int port, int httpPort) { String msg = StringUtils.replace(this.stopPUshURL, "{pushKey}", pushKey); msg = StringUtils.replace(msg, "{port}", String.valueOf(port)); return StringUtils.replace(msg, "{httpPort}", String.valueOf(httpPort)); } public String formatVideoURL(String stream) { String url = StringUtils.replace(getGetURL(), "{stream}", stream); if (!StringUtils.endsWith(url, ".flv")) { url = url + ".flv"; } return url; } public String getJt1078Url() { return this.jt1078Url; } public String getJt1078SendPort() { return this.jt1078SendPort; } public String getStopSendPort() { return this.stopSendPort; } public String getHistoryListPort() { return this.historyListPort; } public String getPlayHistoryPort() { return this.playHistoryPort; } public String getPushURL() { return this.pushURL; } public Integer getStart1078Port() { if (Objects.isNull(this.start1078Port)) this.start1078Port = Integer.valueOf(Integer.parseInt(StringUtils.substringBefore(this.portsOf1078, ","))); return this.start1078Port; } public Integer getEnd1078Port() { if (Objects.isNull(this.end1078Port)) this.end1078Port = Integer.valueOf(Integer.parseInt(StringUtils.substringAfter(this.portsOf1078, ","))); return this.end1078Port; } public String getPushKey(){ if (Objects.isNull(this.pushKey)){ this.pushKey = "?callId=41db35390ddad33f83944f44b8b75ded"; } return "?callId="+this.pushKey; } public String getStopPUshURL() { return this.stopPUshURL; } public String getGetURL() { return this.getURL; } public Integer getAddPort() { return this.addPort; } public String getWs() { return this.ws; } public String getWss() { return this.wss; } public String getDownloadFlv() { return downloadFlv; } public String getPortsOf1078() { return portsOf1078; } }  
2 \ No newline at end of file 1 \ No newline at end of file
  2 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.config; import com.genersoft.iot.vmp.vmanager.jt1078.platform.Jt1078OfCarController; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.util.*; @Data @Component public class Jt1078ConfigBean { @Value("${tuohua.bsth.jt1078.url}") private String jt1078Url; @Value("${tuohua.bsth.jt1078.sendPort}") private String jt1078SendPort; @Value("${tuohua.bsth.jt1078.stopSendPort}") private String stopSendPort; @Value("${tuohua.bsth.jt1078.historyListPort}") private String historyListPort; @Value("${tuohua.bsth.jt1078.history_upload}") private String historyUpload = "9206"; @Value("${tuohua.bsth.jt1078.playHistoryPort}") private String playHistoryPort; @Value("${tuohua.bsth.jt1078.ports}") private String portsOf1078; @Value("${tuohua.bsth.jt1078.pushURL}") private String pushURL; @Value("${tuohua.bsth.jt1078.stopPushURL}") private String stopPUshURL; private Integer start1078Port; private Integer end1078Port; @Value("${tuohua.bsth.jt1078.get.url}") private String getURL; @Value("${tuohua.bsth.jt1078.addPortVal}") private Integer addPort; @Value("${tuohua.bsth.jt1078.ws}") private String ws; @Value("${tuohua.bsth.jt1078.ws-prefix}") private String wsPrefix; @Value("${tuohua.bsth.jt1078.wss}") private String wss; @Value("${tuohua.bsth.jt1078.downloadFLV}") private String downloadFlv; @Value("${tuohua.bsth.jt1078.port}") private Integer port; @Value("${tuohua.bsth.jt1078.httpPort}") private Integer httpPort; @Value("${spring.profiles.active}") private String profilesActive; @Value("${media.pushKey}") private String pushKey; @Resource private RedisTemplate<String, Integer> redisTemplate; @Resource private FtpConfigBean ftpConfigBean; public Integer getPort() { if (port == null) { return 40000; } return port; } public Integer getHttpPort() { if (httpPort == null) { return 40000; } return httpPort; } private Integer getIntPort() { //return profilesActive.equals("wx-local") ? 10000 : 0; return 0; } @PostConstruct public void initMap() { Set<String> historyPortKeys = redisTemplate.keys("history:port:*"); Set<String> keys = redisTemplate.keys("tag:*"); Set<String> patrolKeys = redisTemplate.keys("patrol:stream:*"); Set<String> historyListKeys = redisTemplate.keys("history-list:*"); if (!historyPortKeys.isEmpty()) { keys.addAll(historyPortKeys); } if (!patrolKeys.isEmpty()) { keys.addAll(patrolKeys); } if (!historyListKeys.isEmpty()) { keys.addAll(historyListKeys); } if (keys != null) { redisTemplate.delete(keys); } Map<Integer, Set<String>> hashMap = new HashMap<>(); for (int number = getStart1078Port(); number <= getEnd1078Port(); number++) { hashMap.put(number, new HashSet<>()); } Jt1078OfCarController.map.putAll(hashMap); } private static final String SEND_IO_MESSAGE_RTSP = "{ \"messageId\": 37121, \"properties\": 0, \"clientId\": \"{clientId}\", \"serialNo\": \"1\", \"ip\": \"{ip}\", \"tcpPort\": \"{tcpPort}\", \"udpPort\": \"{udpPort}\", \"channelNo\": \"{channelNo}\", \"mediaType\": \"1\", \"streamType\": \"1\"}"; private static final String SEND_IO_MESSAGE_RTSP_STOP = "{\"messageId\": 37122,\"properties\": 0,\"clientId\": \"{clientId}\",\"serialNo\": \"1\",\"channelNo\": \"{channelNo}\",\"command\": \"0\",\"closeType\": \"0\",\"streamType\": \"1\"}"; private static final String SEND_IO_HISTORY_RTSP = "{\"msgid\":37381,\"clientId\":\"{clientId}\",\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"channelId\":{channelNo}}"; private static final String SEND_IO_PLAY_RTSP = "{\"ip\":\"{ip}\",\"tcpPort\":{tcpPort},\"udpPort\":{udpPort},\"channelNo\":\"{channelNo}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"playbackType\":0,\"playbackSpeed\":1,\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"clientId\":\"{sim}\",\"messageId\":37377}"; public String formatMessageId(String sim, String channel, RtspConfigBean configBean, Integer port) { String msg = StringUtils.replace("{ \"messageId\": 37121, \"properties\": 0, \"clientId\": \"{clientId}\", \"serialNo\": \"1\", \"ip\": \"{ip}\", \"tcpPort\": \"{tcpPort}\", \"udpPort\": \"{udpPort}\", \"channelNo\": \"{channelNo}\", \"mediaType\": \"0\", \"streamType\": \"1\"}", "{clientId}", sim); msg = StringUtils.replace(msg, "{tcpPort}", (port + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{udpPort}", (port + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{channelNo}", channel); return StringUtils.replace(msg, "{ip}", configBean.getRtspIp()); } public String formatMessageStop(String sim, String channel) { String msg = StringUtils.replace("{\"messageId\": 37122,\"properties\": 0,\"clientId\": \"{clientId}\",\"serialNo\": \"1\",\"channelNo\": \"{channelNo}\",\"command\": \"0\",\"closeType\": \"0\",\"streamType\": \"1\"}", "{clientId}", sim); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatMessageHistoryListRTSP(String sim, String channel, String startTime, String endTime) { String msg = StringUtils.replace("{\"msgid\":37381,\"clientId\":\"{clientId}\",\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"mediaType\":0,\"streamType\":1,\"storageType\":0,\"channelNo\":{channelNo}}", "{clientId}", sim); msg = StringUtils.replace(msg, "{startTime}", startTime); msg = StringUtils.replace(msg, "{endTime}", endTime); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatMessageHistoryPlayRTSP(String sim, String channel, String startTime, String endTime, RtspConfigBean configBean, Integer port) { String msg = StringUtils.replace("{\"ip\":\"{ip}\",\"tcpPort\":{tcpPort},\"udpPort\":{udpPort},\"channelNo\":\"{channelNo}\",\"mediaType\":0,\"streamType\":0,\"storageType\":0,\"playbackType\":0,\"playbackSpeed\":1,\"startTime\":\"{startTime}\",\"endTime\":\"{endTime}\",\"clientId\":\"{sim}\",\"messageId\":37377}", "{clientId}", sim); msg = StringUtils.replace(msg, "{startTime}", startTime); msg = StringUtils.replace(msg, "{endTime}", endTime); msg = StringUtils.replace(msg, "{channelNo}", channel); msg = StringUtils.replace(msg, "{tcpPort}", (port.intValue() + getIntPort() +getAddPort()) + ""); msg = StringUtils.replace(msg, "{udpPort}", (port.intValue() + getIntPort() + getAddPort()) + ""); msg = StringUtils.replace(msg, "{sim}", sim); return StringUtils.replace(msg, "{ip}", configBean.getRtspIp()); } public String formatMessageHistoryUpload(String stream) { if (StringUtils.isBlank(stream)) { throw new RuntimeException("上传参数不能为空"); } String[] split = stream.split("_"); if (split.length < 4){ throw new RuntimeException("上传参数异常, 请联系管理员"); } String sim = split[0]; String channel = split[1]; String startTime = split[2]; String endTime = split[3]; String msg = StringUtils.replace("{\n" + " \"clientId\": \"{clientId}\",\n" + " \"ip\": \"{ip}\",\n" + " \"port\": {port},\n" + " \"username\": \"{username}\",\n" + " \"password\": \"{password}\",\n" + " \"path\": \"{path}\",\n" + " \"channelNo\": {channel},\n" + " \"startTime\": \"{startTime}\",\n" + " \"endTime\": \"{endTime}\",\n" + " \"mediaType\": 0,\n" + " \"streamType\": 1,\n" + " \"storageType\": 1,\n" + " \"condition\": 6\n" + "}","{clientId}",sim); msg = StringUtils.replace(msg, "{ip}", ftpConfigBean.getHost()); msg = StringUtils.replace(msg, "{port}", ftpConfigBean.getPort().toString()); msg = StringUtils.replace(msg, "{username}", ftpConfigBean.getUsername()); msg = StringUtils.replace(msg, "{password}", ftpConfigBean.getPassword()); msg = StringUtils.replace(msg, "{path}", StringUtils.join(ftpConfigBean.getBasePath(),"/",sim,"/channel_",channel,"/",stream)); msg = StringUtils.replace(msg, "{channel}", channel); msg = StringUtils.replace(msg, "{startTime}", Jt1078OfCarController.timeCover(startTime)); return StringUtils.replace(msg, "{endTime}", Jt1078OfCarController.timeCover(endTime)); } public String formatMessageHistoryStopRTSP(String sim, String channel, RtspConfigBean configBean) { String msg = StringUtils.replace("{\"playbackMode\":2,\"channelNo\":{channelNo},\"playbackSpeed\":0,\"clientId\":\"{sim}\"}", "{sim}", sim); return StringUtils.replace(msg, "{channelNo}", channel); } public String formatPushURL(String pushKey, int port, int httpPort) { String msg = StringUtils.replace(this.pushURL, "{pushKey}", pushKey); msg = StringUtils.replace(msg, "{port}", String.valueOf(port)); return StringUtils.replace(msg, "{httpPort}", String.valueOf(httpPort)); } public String formatStopPushURL(String pushKey, int port, int httpPort) { String msg = StringUtils.replace(this.stopPUshURL, "{pushKey}", pushKey); msg = StringUtils.replace(msg, "{port}", String.valueOf(port)); return StringUtils.replace(msg, "{httpPort}", String.valueOf(httpPort)); } public String formatVideoURL(String stream) { String url = StringUtils.replace(getGetURL(), "{stream}", stream); if (!StringUtils.endsWith(url, ".flv")) { url = url + ".flv"; } return url; } public String getJt1078Url() { return this.jt1078Url; } public String getJt1078SendPort() { return this.jt1078SendPort; } public String getStopSendPort() { return this.stopSendPort; } public String getHistoryListPort() { return this.historyListPort; } public String getPlayHistoryPort() { return this.playHistoryPort; } public String getPushURL() { return this.pushURL; } public Integer getStart1078Port() { if (Objects.isNull(this.start1078Port)) this.start1078Port = Integer.valueOf(Integer.parseInt(StringUtils.substringBefore(this.portsOf1078, ","))); return this.start1078Port; } public Integer getEnd1078Port() { if (Objects.isNull(this.end1078Port)) this.end1078Port = Integer.valueOf(Integer.parseInt(StringUtils.substringAfter(this.portsOf1078, ","))); return this.end1078Port; } public String getPushKey(){ if (Objects.isNull(this.pushKey)){ this.pushKey = "?callId=41db35390ddad33f83944f44b8b75ded"; } return "?callId="+this.pushKey; } public String getStopPUshURL() { return this.stopPUshURL; } public String getGetURL() { return this.getURL; } public Integer getAddPort() { return this.addPort; } public String getWs() { return this.ws; } public String getWss() { return this.wss; } public String getDownloadFlv() { return downloadFlv; } public String getPortsOf1078() { return portsOf1078; } }
3 \ No newline at end of file 3 \ No newline at end of file
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/config/ThirdPartyHttpService.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.config;
  2 +
  3 +import lombok.extern.slf4j.Slf4j;
  4 +import org.apache.http.client.config.RequestConfig;
  5 +import org.apache.http.client.methods.CloseableHttpResponse;
  6 +import org.apache.http.client.methods.HttpPost;
  7 +import org.apache.http.client.protocol.HttpClientContext;
  8 +import org.apache.http.entity.StringEntity;
  9 +import org.apache.http.impl.client.BasicCookieStore;
  10 +import org.apache.http.impl.client.CloseableHttpClient;
  11 +import org.apache.http.impl.client.HttpClients;
  12 +import org.apache.http.client.utils.URIBuilder;
  13 +import org.springframework.stereotype.Component;
  14 +
  15 +import javax.annotation.PostConstruct;
  16 +import java.net.URI;
  17 +
  18 +@Slf4j
  19 +@Component
  20 +public class ThirdPartyHttpService {
  21 +
  22 + private CloseableHttpClient httpClient;
  23 +
  24 + // 初始化 HttpClient
  25 + @PostConstruct
  26 + public void init() {
  27 + RequestConfig requestConfig = RequestConfig.custom()
  28 + .setSocketTimeout(5000)
  29 + .setConnectTimeout(5000)
  30 + .build();
  31 + this.httpClient = HttpClients.custom()
  32 + .setDefaultRequestConfig(requestConfig)
  33 + .build();
  34 + }
  35 +
  36 + /**
  37 + * 通用 POST 请求方法
  38 + */
  39 + public String doPost(String url, String requestBody, String jsessionid) {
  40 + long startTime = System.currentTimeMillis();
  41 + CloseableHttpResponse response = null;
  42 + try {
  43 + URIBuilder uriBuilder = new URIBuilder(url);
  44 + URI uri = uriBuilder.build();
  45 + HttpPost httpPost = new HttpPost(uri);
  46 +
  47 + // 设置 Body
  48 + if (requestBody != null) {
  49 + StringEntity stringEntity = new StringEntity(requestBody, "UTF-8");
  50 + stringEntity.setContentType("application/json");
  51 + httpPost.setEntity(stringEntity);
  52 + }
  53 +
  54 + // 使用 Context 传递 Cookie
  55 + HttpClientContext context = HttpClientContext.create();
  56 + // 如果需要 Cookie,在这里设置
  57 + if (jsessionid != null && !jsessionid.isEmpty()) {
  58 + BasicCookieStore cookieStore = new BasicCookieStore();
  59 + context.setCookieStore(cookieStore);
  60 + }
  61 +
  62 + response = httpClient.execute(httpPost, context);
  63 +
  64 + int statusCode = response.getStatusLine().getStatusCode();
  65 + if (statusCode == 200) {
  66 + // TODO: 这里替换为您原有的 combationReturnObj 逻辑
  67 + // 这里简单演示读取字符串
  68 + return org.apache.http.util.EntityUtils.toString(response.getEntity(), "UTF-8");
  69 + } else {
  70 + log.warn("POST请求非200状态: {}, Code: {}", url, statusCode);
  71 + }
  72 + } catch (Exception e) {
  73 + log.error("POST JSON请求异常, url: {}", url, e);
  74 + } finally {
  75 + try {
  76 + if (response != null) response.close();
  77 + } catch (Exception e) {
  78 + log.error("关闭响应流异常", e);
  79 + }
  80 + }
  81 + return null;
  82 + }
  83 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/config/TuohuaConfigBean.java
@@ -111,7 +111,7 @@ public class TuohuaConfigBean { @@ -111,7 +111,7 @@ public class TuohuaConfigBean {
111 //private final String LINE_URL = "/line/all?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}"; 111 //private final String LINE_URL = "/line/all?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}";
112 private final String CAR_URL = "/car/{companyId}?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}"; 112 private final String CAR_URL = "/car/{companyId}?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}";
113 private final String GPS_URL = "/gps/all?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}"; 113 private final String GPS_URL = "/gps/all?timestamp={timestamp}&nonce={nonce}&password={password}&sign={sign}";
114 - private final String DEVICE_URL = "/all"; 114 + private final String DEVICE_URL = "all";
115 115
116 public String requestLine(HttpClientUtil httpClientUtil, String companyId) throws Exception { 116 public String requestLine(HttpClientUtil httpClientUtil, String companyId) throws Exception {
117 String nonce = random(5); 117 String nonce = random(5);
@@ -364,8 +364,8 @@ public class TuohuaConfigBean { @@ -364,8 +364,8 @@ public class TuohuaConfigBean {
364 364
365 Optional<HashMap> optional = gpsList.stream().filter(g -> Objects.nonNull(g) && Objects.nonNull(g.get("deviceId")) && 365 Optional<HashMap> optional = gpsList.stream().filter(g -> Objects.nonNull(g) && Objects.nonNull(g.get("deviceId")) &&
366 Objects.nonNull(ch.get("equipmentCode")) && Objects.equals(g.get("deviceId").toString(), ch.get("equipmentCode").toString())).findFirst(); 366 Objects.nonNull(ch.get("equipmentCode")) && Objects.equals(g.get("deviceId").toString(), ch.get("equipmentCode").toString())).findFirst();
367 - if (StringUtils.isNotEmpty(sim) && CollectionUtils.isNotEmpty(deviceSet) && deviceSet.contains(sim.replaceAll("^0+(?!$)", ""))  
368 - && optional.isPresent() && Objects.nonNull(optional.get().get("timestamp")) 367 + if (StringUtils.isNotEmpty(sim) && CollectionUtils.isNotEmpty(deviceSet) && deviceSet.contains(sim.replaceAll("^0+(?!$)", ""))
  368 + && optional.isPresent() && Objects.nonNull(optional.get().get("timestamp"))
369 && now - Convert.toLong(optional.get().get("timestamp")) <= 120000){ 369 && now - Convert.toLong(optional.get().get("timestamp")) <= 120000){
370 name = "<view style='color:blue'>" + name + "</view>"; 370 name = "<view style='color:blue'>" + name + "</view>";
371 abnormalStatus = 1; 371 abnormalStatus = 1;
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/config/WebSocketConfig.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.web.socket.server.standard.ServerEndpointExporter;
  6 +
  7 +@Configuration
  8 +public class WebSocketConfig {
  9 +
  10 + /**
  11 + * 必须注册这个 Bean,@ServerEndpoint 注解才会生效
  12 + * 注意:如果使用外置 Tomcat 部署,则不需要这个 Bean
  13 + */
  14 + @Bean
  15 + public ServerEndpointExporter serverEndpointExporter() {
  16 + return new ServerEndpointExporter();
  17 + }
  18 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/controller/CommandController.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.controller;
  2 +
  3 +import com.genersoft.iot.vmp.common.AjaxResult;
  4 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.TextSendReq;
  5 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.DeviceCommandService;
  6 +import org.springframework.web.bind.annotation.PostMapping;
  7 +import org.springframework.web.bind.annotation.RequestBody;
  8 +import org.springframework.web.bind.annotation.RequestMapping;
  9 +import org.springframework.web.bind.annotation.RestController;
  10 +
  11 +import javax.annotation.Resource;
  12 +
  13 +@RestController
  14 +@RequestMapping("/api/external")
  15 +public class CommandController {
  16 +
  17 + @Resource
  18 + private DeviceCommandService commandService;
  19 +
  20 + @PostMapping("/send_text")
  21 + public AjaxResult sendText(@RequestBody TextSendReq req) {
  22 + // 1. 再次校验长度(双重保险)
  23 + if (req.getText() == null || req.getText().length() > 50) {
  24 + return AjaxResult.error("内容长度超过限制");
  25 + }
  26 + // 2. 调用业务层发送
  27 + boolean success = commandService.sendTextCommand(req.getNbbm(), req.getText());
  28 + return success ? AjaxResult.success() : AjaxResult.error("设备离线或发送失败");
  29 + }
  30 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/controller/IntercomController.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.controller;
  2 +
  3 +import com.genersoft.iot.vmp.common.AjaxResult;
  4 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.IntercomService;
  5 +import org.springframework.web.bind.annotation.*;
  6 +
  7 +import javax.annotation.Resource;
  8 +
  9 +
  10 +@RestController
  11 +@RequestMapping("/api/jt1078/intercom")
  12 +public class IntercomController {
  13 +
  14 + @Resource
  15 + private IntercomService intercomService;
  16 +
  17 + @GetMapping("/url/{sim}")
  18 + public AjaxResult getIntercomUrl(@PathVariable String sim) {
  19 + try {
  20 + return AjaxResult.success(intercomService.startIntercom(sim));
  21 + } catch (Exception e) {
  22 + return AjaxResult.error("建立连接失败: " + e.getMessage());
  23 + }
  24 + }
  25 + @PostMapping("/stop/{sim}")
  26 + public AjaxResult stopIntercomUrl(@PathVariable String sim) {
  27 + try {
  28 + intercomService.stopIntercom(sim);
  29 + return AjaxResult.success();
  30 + } catch (Exception e) {
  31 + return AjaxResult.error("建立连接失败: " + e.getMessage());
  32 + }
  33 + }
  34 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/domain/StreamSwitch.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.domain;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +import lombok.experimental.SuperBuilder;
  7 +
  8 +/**
  9 + * 码流切换
  10 + */
  11 +@Data
  12 +@AllArgsConstructor
  13 +@NoArgsConstructor
  14 +@SuperBuilder
  15 +public class StreamSwitch {
  16 + /**
  17 + * 设备号
  18 + */
  19 + private String sim;
  20 + /**
  21 + * 通道号
  22 + */
  23 + private Integer channel;
  24 + /**
  25 + * 切换类型(0:主码流 1:子码流)
  26 + */
  27 + private Integer type;
  28 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/domain/T9101.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.domain;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Builder;
  6 +import lombok.Data;
  7 +import lombok.NoArgsConstructor;
  8 +
  9 +import java.io.Serializable;
  10 +
  11 +@Data
  12 +@Builder
  13 +@NoArgsConstructor
  14 +@AllArgsConstructor
  15 +public class T9101 implements Serializable {
  16 +
  17 + /**
  18 + * 终端手机号 (Required)
  19 + */
  20 + @JsonProperty("clientId")
  21 + private String clientId;
  22 +
  23 + /**
  24 + * 服务器IP地址 (Required)
  25 + * 指挥设备将流推送到哪个媒体服务器IP
  26 + */
  27 + @JsonProperty("ip")
  28 + private String ip;
  29 +
  30 + /**
  31 + * 实时视频服务器TCP端口号 (Required)
  32 + */
  33 + @JsonProperty("tcpPort")
  34 + private Integer tcpPort;
  35 +
  36 + /**
  37 + * 实时视频服务器UDP端口号 (Required)
  38 + */
  39 + @JsonProperty("udpPort")
  40 + private Integer udpPort;
  41 +
  42 + /**
  43 + * 逻辑通道号 (Required)
  44 + * 通常: 1-驾驶员, 2-车辆, etc.
  45 + */
  46 + @JsonProperty("channelNo")
  47 + private Integer channelNo;
  48 +
  49 + /**
  50 + * 数据类型 (Required)
  51 + * 0.音视频 1.视频 2.双向对讲 3.监听 4.中心广播 5.透传
  52 + * 对讲这里固定传 2
  53 + */
  54 + @JsonProperty("mediaType")
  55 + private Integer mediaType;
  56 +
  57 + /**
  58 + * 码流类型 (Required)
  59 + * 0.主码流 1.子码流
  60 + */
  61 + @JsonProperty("streamType")
  62 + private Integer streamType;
  63 +
  64 + // --- 以下为非必填或辅助字段 ---
  65 +
  66 + @JsonProperty("messageId")
  67 + private Integer messageId; // 消息ID (0x9101的十进制)
  68 +
  69 + @JsonProperty("protocolVersion")
  70 + private Integer protocolVersion;
  71 +
  72 + @JsonProperty("serialNo")
  73 + private Integer serialNo;
  74 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/domain/T9102.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.domain;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +@Data
  9 +@Builder
  10 +@NoArgsConstructor
  11 +@AllArgsConstructor
  12 +public class T9102 {
  13 + private String clientId; // 设备SIM卡号
  14 + private Integer messageId; // 消息ID (0x9102)
  15 +
  16 + private Integer channelNo; // 逻辑通道号
  17 + private Integer command; // 控制指令:0-关闭,1-切换
  18 + private Integer closeType; // 关闭类型:0-关闭音视频,1-只关音频,2-只关视频
  19 + private Integer streamType; // 码流类型:0-主码流,1-子码流
  20 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/domain/TextSendReq.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.domain;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +import lombok.experimental.SuperBuilder;
  7 +
  8 +/**
  9 + * @Author WangXin
  10 + * @Data 2026/2/2
  11 + * @Version 1.0.0
  12 + */
  13 +@Data
  14 +@SuperBuilder
  15 +@AllArgsConstructor
  16 +@NoArgsConstructor
  17 +public class TextSendReq {
  18 +
  19 + private String nbbm;
  20 + private String text;
  21 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/domain/ThirdPartyRes.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.domain;
  2 +
  3 +import lombok.Data;
  4 +
  5 +@Data
  6 +public class ThirdPartyRes<T> {
  7 + private Integer code;
  8 + private String msg;
  9 + private String detailMsg;
  10 + private T data;
  11 +
  12 + // 判断是否成功
  13 + public boolean isSuccess() {
  14 + return code != null && code == 200;
  15 + }
  16 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/DeviceCommandService.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.service;
  2 +
  3 +/**
  4 + * 文件下发接口
  5 + *
  6 + * @Author WangXin
  7 + * @Data 2026/2/2
  8 + * @Version 1.0.0
  9 + */
  10 +public interface DeviceCommandService {
  11 +
  12 + boolean sendTextCommand(String nbbm, String content);
  13 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/IntercomService.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.service;
  2 +
  3 +import com.genersoft.iot.vmp.conf.exception.ServiceException;
  4 +
  5 +/**
  6 + * @Author WangXin
  7 + * @Data 2026/2/2
  8 + * @Version 1.0.0
  9 + */
  10 +public interface IntercomService {
  11 +
  12 + String startIntercom(String sim) throws ServiceException;
  13 +
  14 + void stopIntercom(String sim);
  15 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/impl/DeviceCommandServiceImpl.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.service.impl;
  2 +
  3 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.ben.HttpClientPostEntity;
  4 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.TuohuaConfigBean;
  5 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.handler.HttpClientUtil;
  6 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.DeviceCommandService;
  7 +import org.apache.commons.lang3.StringUtils;
  8 +import org.springframework.beans.factory.annotation.Autowired;
  9 +import org.springframework.stereotype.Service;
  10 +
  11 +import java.util.HashMap;
  12 +
  13 +import static com.genersoft.iot.vmp.vmanager.util.SignatureGenerateUtil.createUrl;
  14 +
  15 +
  16 +@Service
  17 +public class DeviceCommandServiceImpl implements DeviceCommandService {
  18 + @Autowired
  19 + private TuohuaConfigBean tuohuaConfigBean;
  20 +
  21 + public boolean sendTextCommand(String nbbm, String content) {
  22 + HttpClientUtil httpClientUtil = new HttpClientUtil();
  23 + HashMap<String, String> map = new HashMap<>();
  24 + map.put("nbbm", nbbm);
  25 + map.put("content", content);
  26 + HttpClientPostEntity httpClientPost = httpClientUtil.doPost(createUrl(StringUtils.join(tuohuaConfigBean.getBaseURL(), "/directive/send"),tuohuaConfigBean.getRestPassword()), map, null);
  27 + if (httpClientPost == null) {
  28 + return false;
  29 + }
  30 + return true;
  31 + }
  32 +}
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/impl/IntercomServiceImpl.java 0 → 100644
  1 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.service.impl;
  2 +
  3 +import com.alibaba.fastjson2.JSON;
  4 +import com.alibaba.fastjson2.TypeReference;
  5 +import com.genersoft.iot.vmp.conf.exception.ServiceException;
  6 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean;
  7 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.RtspConfigBean;
  8 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.ThirdPartyHttpService;
  9 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9101;
  10 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9102;
  11 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.ThirdPartyRes;
  12 +import com.genersoft.iot.vmp.vmanager.jt1078.platform.service.IntercomService;
  13 +import com.genersoft.iot.vmp.vmanager.util.RedisCache;
  14 +import lombok.extern.slf4j.Slf4j;
  15 +import org.apache.commons.lang3.StringUtils;
  16 +import org.springframework.stereotype.Service;
  17 +
  18 +import javax.annotation.Resource;
  19 +import java.util.concurrent.TimeUnit;
  20 +
  21 +@Slf4j
  22 +@Service
  23 +public class IntercomServiceImpl implements IntercomService {
  24 +
  25 + @Resource
  26 + private ThirdPartyHttpService httpService;
  27 + @Resource
  28 + private Jt1078ConfigBean jt1078ConfigBean;
  29 + @Resource
  30 + private RtspConfigBean rtspConfigBean;
  31 + @Resource
  32 + private RedisCache redisCache;
  33 +
  34 + /**
  35 + * 开启语音对讲
  36 + */
  37 + public String startIntercom(String sim) throws ServiceException {
  38 +
  39 + if (redisCache.hasKey("intercom:" + sim)) {
  40 + throw new ServiceException("对讲通道已被其他人占用");
  41 + }
  42 +
  43 + // 去除SIM号前面的所有0
  44 + String trimmedSim = sim.replaceFirst("^0+(?!$)", "");
  45 +
  46 + int wanPort = 40012;
  47 +
  48 + // 3. 组装 9101 请求体
  49 + T9101 reqBody = T9101.builder()
  50 + .clientId(trimmedSim)
  51 + .ip("61.169.120.202") // 媒体服务器公网IP
  52 + .tcpPort(wanPort) // 【关键】告诉设备连 40000
  53 + .channelNo(1)
  54 + .mediaType(2)
  55 + .streamType(1)
  56 + .messageId(0x9101)
  57 + .build();
  58 +
  59 + String jsonBody = JSON.toJSONString(reqBody);
  60 + String targetUrl = StringUtils.replace(jt1078ConfigBean.getJt1078Url(), "{0}", "9101");
  61 +
  62 + log.info("下发9101指令. 目标地址: {}:{} (映射自内部:{}), 原始SIM: {}, 处理后SIM: {}",
  63 + reqBody.getIp(), wanPort, wanPort, sim, trimmedSim);
  64 +
  65 + String responseStr = httpService.doPost(targetUrl, jsonBody, null);
  66 +
  67 + if (responseStr == null) {
  68 + throw new RuntimeException("第三方接口请求无响应");
  69 + }
  70 +
  71 + ThirdPartyRes<Object> result = JSON.parseObject(responseStr, new TypeReference<ThirdPartyRes<Object>>(){});
  72 +
  73 + if (result != null && result.isSuccess()) {
  74 + log.info("设备[{}] 9101指令下发成功", trimmedSim);
  75 + redisCache.setCacheObject("intercom:" + trimmedSim, "1",1, TimeUnit.DAYS);
  76 + // 返回 WebSocket 地址 (给前端用的,走 HTTP 端口)
  77 + return buildWebSocketUrl(sim);
  78 + } else {
  79 + String msg = result != null ? result.getMsg() : "未知错误";
  80 + log.error("设备[{}] 9101指令失败: {}", trimmedSim, msg);
  81 + redisCache.deleteObject("intercom:" + trimmedSim);
  82 + throw new RuntimeException("对讲开启失败: " + msg);
  83 + }
  84 + }
  85 +
  86 + /**
  87 + * 关闭语音对讲 (下发 9102 指令)
  88 + * @param sim 设备SIM卡号
  89 + */
  90 + public void stopIntercom(String sim) {
  91 + // 去除SIM号前面的所有0
  92 + String trimmedSim = sim.replaceFirst("^0+(?=\\d)", "");
  93 +
  94 + // 1. 组装 9102 请求体 (关闭指令)
  95 +
  96 + T9102 reqBody = T9102.builder()
  97 + .clientId(trimmedSim)
  98 + .messageId(0x9102)
  99 + .channelNo(1) // 通道1 (对应开启时的通道)
  100 + .command(4) // 0: 关闭
  101 + .closeType(0) // 0: 关闭语音对讲
  102 + .build();
  103 +
  104 + String jsonBody = JSON.toJSONString(reqBody);
  105 +
  106 + // 2. 替换 URL 为 9102
  107 + String targetUrl = StringUtils.replace(jt1078ConfigBean.getJt1078Url(), "{0}", "9102");
  108 +
  109 + log.info("下发9102关闭指令. SIM: {}, URL: {}", trimmedSim, targetUrl);
  110 +
  111 + // 3. 发送请求
  112 + try {
  113 + String responseStr = httpService.doPost(targetUrl, jsonBody, null);
  114 + redisCache.deleteObject("intercom:" + trimmedSim);
  115 + log.info("设备[{}] 9102响应: {}", trimmedSim, responseStr);
  116 + } catch (Exception e) {
  117 + redisCache.deleteObject("intercom:" + trimmedSim);
  118 + log.error("设备[{}] 下发9102失败", trimmedSim, e);
  119 + }
  120 + }
  121 +
  122 + /**
  123 + * 构造 WebSocket 地址
  124 + * 目标网关: 118.113.164.50:10202
  125 + * 协议格式参考提供的 HTML: ws://IP:PORT?sim={sim}
  126 + */
  127 + private String buildWebSocketUrl(String sim) {
  128 +
  129 + // 先去除SIM号前面的0,然后再补齐到12位
  130 + String trimmedSim = sim.replaceFirst("^0+(?=\\d)", "");
  131 + if (trimmedSim.isEmpty()) trimmedSim = "0";
  132 + String paddedSim = String.format("%012d", Long.parseLong(trimmedSim));
  133 + return String.format("ws://61.169.120.202:40013?sim=%s", paddedSim);
  134 + }
  135 +}
src/main/java/com/genersoft/iot/vmp/vmanager/util/SignatureGenerateUtil.java
@@ -130,5 +130,8 @@ public class SignatureGenerateUtil { @@ -130,5 +130,8 @@ public class SignatureGenerateUtil {
130 System.out.println(StringUtils.join("http://61.169.120.202:40007/getInfo?password=",apiParamReq.getPassword(),"&nonce=",apiParamReq.getNonce(),"&sign=",apiParamReq.getSign(),"&timestamp=",apiParamReq.getTimestamp(),"&username=","wangxin","&deviceId=","00000000")); 130 System.out.println(StringUtils.join("http://61.169.120.202:40007/getInfo?password=",apiParamReq.getPassword(),"&nonce=",apiParamReq.getNonce(),"&sign=",apiParamReq.getSign(),"&timestamp=",apiParamReq.getTimestamp(),"&username=","wangxin","&deviceId=","00000000"));
131 } 131 }
132 132
133 - 133 + public static String createUrl(String url, String password){
  134 + ApiParamReq apiParamReq = getApiParamReq(password);
  135 + return StringUtils.join(url,"?password=",apiParamReq.getPassword(),"&nonce=",apiParamReq.getNonce(),"&sign=",apiParamReq.getSign(),"&timestamp=",apiParamReq.getTimestamp());
  136 + }
134 } 137 }
src/main/resources/app.properties
1 -server.port = 30000 1 +server.port = 40000
2 server.http.port = 3333 2 server.http.port = 3333
3 server.history.port = 30001 3 server.history.port = 30001
4 server.backlog = 1024 4 server.backlog = 1024
@@ -15,3 +15,7 @@ rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign} @@ -15,3 +15,7 @@ rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign}
15 #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} 15 #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign}
16 # 设置为on时,控制台将输出ffmpeg的输出 16 # 设置为on时,控制台将输出ffmpeg的输出
17 debug.mode = off 17 debug.mode = off
  18 +
  19 +zlm.host = 127.0.0.1
  20 +zlm.http.port = 80
  21 +zlm.secret= 8KMYsD5ItKkHN1CIcPI9VeLa6u4S8deU
src/main/resources/application-jt1078-dev100.yml 0 → 100644
  1 +spring:
  2 + # 设置接口超时时间
  3 + mvc:
  4 + async:
  5 + request-timeout: 20000
  6 + thymeleaf:
  7 + cache: false
  8 + # [可选]上传文件大小限制
  9 + servlet:
  10 + multipart:
  11 + max-file-size: 10MB
  12 + max-request-size: 100MB
  13 +
  14 + # REDIS数据库配置
  15 + redis:
  16 + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
  17 + host: 192.168.169.100
  18 + # [必须修改] 端口号
  19 + port: 6879
  20 + # [可选] 数据库 DB
  21 + database: 7
  22 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
  23 + password: wvp4@444
  24 + # [可选] 超时时间
  25 + timeout: 10000
  26 + # mysql数据源
  27 + datasource:
  28 + dynamic:
  29 + primary: master
  30 + datasource:
  31 + master:
  32 + type: com.zaxxer.hikari.HikariDataSource
  33 + driver-class-name: com.mysql.cj.jdbc.Driver
  34 + url: jdbc:mysql://192.168.169.100:3306/wvp4?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=TRUE&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&sessionVariables=sql_mode='NO_ENGINE_SUBSTITUTION'&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull
  35 + username: root
  36 + password: guzijian
  37 + hikari:
  38 + connection-timeout: 20000 # 是客户端等待连接池连接的最大毫秒数
  39 + initialSize: 50 # 连接池初始化连接数
  40 + maximum-pool-size: 200 # 连接池最大连接数
  41 + minimum-idle: 10 # 连接池最小空闲连接数
  42 + idle-timeout: 300000 # 允许连接在连接池中空闲的最长时间(以毫秒为单位)
  43 + max-lifetime: 1200000 # 是池中连接关闭后的最长生命周期(以毫秒为单位)
  44 +#[可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
  45 +server:
  46 + port: 18989
  47 + # [可选] HTTPS配置, 默认不开启
  48 + ssl:
  49 + # [可选] 是否开启HTTPS访问
  50 + enabled: false
  51 + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
  52 + key-store: classpath:Aserver.keystore
  53 + # [可选] 证书密码
  54 + key-store-password: guzijian
  55 + # [可选] 证书类型, 默认为jks,根据实际修改
  56 + key-store-type: JKS
  57 +
  58 +# 作为28181服务器的配置
  59 +sip:
  60 + # [必须修改] 本机的IP,对应你的网卡,监听什么ip就是使用什么网卡,
  61 + # 如果要监听多张网卡,可以使用逗号分隔多个IP, 例如: 192.168.1.4,10.0.0.4
  62 + # 如果不明白,就使用0.0.0.0,大部分情况都是可以的
  63 + # 请不要使用127.0.0.1,任何包括localhost在内的域名都是不可以的。
  64 + ip: 192.168.169.100
  65 + # [可选] 28181服务监听的端口
  66 + port: 28083
  67 + # 根据国标6.1.2中规定,domain宜采用ID统一编码的前十位编码。国标附录D中定义前8位为中心编码(由省级、市级、区级、基层编号组成,参照GB/T 2260-2007)
  68 + # 后两位为行业编码,定义参照附录D.3
  69 + # 3701020049标识山东济南历下区 信息行业接入
  70 + # [可选]
  71 + domain: 4403000000
  72 + # [可选]
  73 + id: 44030000003110008001
  74 + # [可选] 默认设备认证密码,后续扩展使用设备单独密码, 移除密码将不进行校验
  75 + password: 2024bsth@gb18181
  76 + # 是否存储alarm信息
  77 + alarm: true
  78 +
  79 +#zlm 默认服务器配置
  80 +media:
  81 + id: guzijian1
  82 + # [必须修改] zlm服务器的内网IP
  83 + ip: 192.168.169.100
  84 + # [必须修改] zlm服务器的http.port
  85 + http-port: 1909
  86 + # [可选] 返回流地址时的ip,置空使用 media.ip 1
  87 + stream-ip: 61.169.120.202
  88 + # [可选] wvp在国标信令中使用的ip,此ip为摄像机可以访问到的ip, 置空使用 media.ip 1
  89 + sdp-ip: 61.169.120.202
  90 + # [可选] zlm服务器的hook所使用的IP, 默认使用sip.ip
  91 + hook-ip: 192.168.169.100
  92 + # [可选] zlm服务器的http.sslport, 置空使用zlm配置文件配置
  93 + http-ssl-port: 2939
  94 + # [可选] zlm服务器的hook.admin_params=secret
  95 + secret: 8KMYsD5ItKkHN1CIcPI9VeLa6u4S8deU
  96 + pushKey: 41db35390ddad33f83944f44b8b75ded
  97 + # 启用多端口模式, 多端口模式使用端口区分每路流,兼容性更好。 单端口使用流的ssrc区分, 点播超时建议使用多端口测试
  98 + rtp:
  99 + # [可选] 是否启用多端口模式, 开启后会在portRange范围内选择端口用于媒体流传输
  100 + enable: true
  101 + # [可选] 在此范围内选择端口用于媒体流传输, 必须提前在zlm上配置该属性,不然自动配置此属性可能不成功
  102 + port-range: 52000,52500 # 端口范围
  103 + # [可选] 国标级联在此范围内选择端口发送媒体流,
  104 + send-port-range: 52000,52500 # 端口范围
  105 + # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用
  106 + record-assist-port: 18081
  107 +# [根据业务需求配置]
  108 +user-settings:
  109 + # 点播/录像回放 等待超时时间,单位:毫秒
  110 + play-timeout: 180000
  111 + # [可选] 自动点播, 使用固定流地址进行播放时,如果未点播则自动进行点播, 需要rtp.enable=true
  112 + auto-apply-play: true
  113 + # 设备/通道状态变化时发送消息
  114 + device-status-notify: true
  115 + # MyBatis配置
  116 +mybatis-plus:
  117 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  118 + mapperLocations: classpath*:mapper/**/*Mapper.xml
  119 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  120 + #mapperLocations: classpath*:mapper/**/*Mapper.xml
  121 +
  122 +# [可选] 日志配置, 一般不需要改
  123 +jt1078:
  124 + enable: true
  125 + port: 1079
  126 +logging:
  127 + config: classpath:logback-spring.xml
  128 +tuohua:
  129 + bsth:
  130 + login:
  131 + pageURL: http://192.168.168.152:9088/user/login/jCryptionKey
  132 + url: http://192.168.168.152:9088/user/login
  133 + userName: yuanxiaohu
  134 + password: Yxiaohu1.0
  135 + rest:
  136 + baseURL: http://192.168.168.152:9089/webservice/rest
  137 + password: bafb2b44a07a02e5e9912f42cd197423884116a8
  138 + tree:
  139 + url:
  140 + company: http://192.168.168.152:9088/video/tree
  141 + car: http://192.168.168.152:9088/video/tree/carNo/{0}
  142 + sim: http://192.168.168.152:9088/video//tree/caNO/sim/{0}
  143 + wvp28181:
  144 + rtsp:
  145 + tcpPort: 1078
  146 + udpPort: 1078
  147 + historyTcpPort: 9999
  148 + historyUdpPort: 9999
  149 + ip : 61.169.120.202
  150 + jt1078:
  151 + ws-prefix: ws://61.169.120.202:${server.port}
  152 + ports: 49101,49101
  153 + port: 9100
  154 + httpPort: 3333
  155 + addPortVal: 0
  156 + pushURL: http://127.0.0.1:3333/new/server/{pushKey}/{port}/{httpPort}
  157 + stopPushURL: http://127.0.0.1:3333/stop/channel/{pushKey}/{port}/{httpPort}
  158 + url: http://192.168.168.152:8100/device/{0}
  159 + new_url: http://192.168.168.152:8100/device
  160 + historyListPort: 9205
  161 + playHistoryPort: 9201
  162 + history_upload: 9206
  163 + sendPort: 9101
  164 + stopSendPort: 9102
  165 + ws: ws://61.169.120.202:1909/schedule/{stream}.live.flv
  166 + wss: wss://61.169.120.202:2930/schedule/{stream}.live.flv
  167 + downloadFLV: http://61.169.120.202:1909/schedule/{stream}.live.flv
  168 + get:
  169 + url: http://192.168.169.100:3333/video/{stream}.flv
  170 + playURL: /play/wasm/ws%3A%2F%2F{ip}%3A{port}%2Fschedule%2F{sim}-{channel}.live.flv%3FcallId%{publickey}
  171 +
  172 +ftp:
  173 + basePath: /wvp-local
  174 + host: 61.169.120.202
  175 + httpPath: ftp://61.169.120.202
  176 + filePathPrefix: http://61.169.120.202:10021/wvp-local
  177 + password: DaYiFtpAdmin
  178 + port: 10021
  179 + username: DaYi@FtpAdmin@1369.
  180 + retryTimes: 5
  181 + retryWaitTimes: 3000
  182 + # 视频过期时间 单位:天
  183 + expirationTime: 7
  184 + # 未上传成功超时时间 单位:秒
  185 + timeOut: 300
  186 +
  187 +forest:
  188 + backend: okhttp3 # 后端HTTP框架(默认为 okhttp3)
  189 + max-connections: 1000 # 连接池最大连接数(默认为 500)
  190 + max-route-connections: 500 # 每个路由的最大连接数(默认为 500)
  191 + max-request-queue-size: 100 # [自v1.5.22版本起可用] 最大请求等待队列大小
  192 + max-async-thread-size: 300 # [自v1.5.21版本起可用] 最大异步线程数
  193 + max-async-queue-size: 16 # [自v1.5.22版本起可用] 最大异步线程池队列大小
  194 + timeout: 3000 # [已不推荐使用] 请求超时时间,单位为毫秒(默认为 3000)
  195 + connect-timeout: 3000 # 连接超时时间,单位为毫秒(默认为 timeout)
  196 + read-timeout: 3000 # 数据读取超时时间,单位为毫秒(默认为 timeout)
  197 + max-retry-count: 0 # 请求失败后重试次数(默认为 0 次不重试)
  198 + # ssl-protocol: TLS # 单向验证的HTTPS的默认TLS协议(默认为 TLS)
  199 + log-enabled: true # 打开或关闭日志(默认为 true)
  200 + log-request: true # 打开/关闭Forest请求日志(默认为 true)
  201 + log-response-status: true # 打开/关闭Forest响应状态日志(默认为 true)
  202 + log-response-content: true # 打开/关闭Forest响应内容日志(默认为 false)
  203 +# async-mode: platform # [自v1.5.27版本起可用] 异步模式(默认为 platform)
src/main/resources/application-jt1078-em.yml 0 → 100644
  1 +spring:
  2 + rabbitmq:
  3 + host: 10.0.0.6
  4 + port: 5672
  5 + username: bsth
  6 + password: bsth001
  7 + virtual-host: /dsm
  8 + listener:
  9 + simple:
  10 + acknowledge-mode: manual # 设置为手动确认
  11 + # 设置接口超时时间
  12 + mvc:
  13 + async:
  14 + request-timeout: 20000
  15 + thymeleaf:
  16 + cache: false
  17 + # [可选]上传文件大小限制
  18 + servlet:
  19 + multipart:
  20 + max-file-size: 10MB
  21 + max-request-size: 100MB
  22 +
  23 + # REDIS数据库配置
  24 + redis:
  25 + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
  26 + host: 10.0.0.16
  27 + # [必须修改] 端口号
  28 + port: 16380
  29 + # [可选] 数据库 DB
  30 + database: 15
  31 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
  32 + password: DVR@dvr123.
  33 + # [可选] 超时时间
  34 + timeout: 10000
  35 + # mysql数据源
  36 + datasource:
  37 + dynamic:
  38 + primary: master
  39 + datasource:
  40 + master:
  41 + type: com.zaxxer.hikari.HikariDataSource
  42 + driver-class-name: com.mysql.cj.jdbc.Driver
  43 + url: jdbc:mysql://10.0.0.5:3386/latest_wvp2?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=TRUE&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&sessionVariables=sql_mode='NO_ENGINE_SUBSTITUTION'&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull
  44 + username: root
  45 + password: DVR@dvr123.
  46 + hikari:
  47 + connection-timeout: 20000 # 是客户端等待连接池连接的最大毫秒数
  48 + initialSize: 50 # 连接池初始化连接数
  49 + maximum-pool-size: 200 # 连接池最大连接数
  50 + minimum-idle: 10 # 连接池最小空闲连接数
  51 + idle-timeout: 300000 # 允许连接在连接池中空闲的最长时间(以毫秒为单位)
  52 + max-lifetime: 1200000 # 是池中连接关闭后的最长生命周期(以毫秒为单位)
  53 +#[可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
  54 +server:
  55 + port: 18989
  56 + # [可选] HTTPS配置, 默认不开启
  57 + ssl:
  58 + # [可选] 是否开启HTTPS访问
  59 + enabled: false
  60 + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
  61 + key-store: classpath:Aserver.keystore
  62 + # [可选] 证书密码
  63 + key-store-password: guzijian
  64 + # [可选] 证书类型, 默认为jks,根据实际修改
  65 + key-store-type: JKS
  66 +
  67 +# 作为28181服务器的配置
  68 +sip:
  69 + # [必须修改] 本机的IP,对应你的网卡,监听什么ip就是使用什么网卡,
  70 + # 如果要监听多张网卡,可以使用逗号分隔多个IP, 例如: 192.168.1.4,10.0.0.4
  71 + # 如果不明白,就使用0.0.0.0,大部分情况都是可以的
  72 + # 请不要使用127.0.0.1,任何包括localhost在内的域名都是不可以的。
  73 + ip: 0.0.0.0
  74 + # [可选] 28181服务监听的端口
  75 + port: 6060
  76 + # 根据国标6.1.2中规定,domain宜采用ID统一编码的前十位编码。国标附录D中定义前8位为中心编码(由省级、市级、区级、基层编号组成,参照GB/T 2260-2007)
  77 + # 后两位为行业编码,定义参照附录D.3
  78 + # 3701020049标识山东济南历下区 信息行业接入
  79 + # [可选]
  80 + domain: 4403000000
  81 + # [可选]
  82 + id: 44030000003110008001
  83 + # [可选] 默认设备认证密码,后续扩展使用设备单独密码, 移除密码将不进行校验
  84 + password: 2024bsth@gb18181
  85 + # 是否存储alarm信息
  86 + alarm: true
  87 +
  88 +#zlm 默认服务器配置
  89 +media:
  90 + # [必须修改] zlm服务器唯一id,用于触发hook时区别是哪台服务器,general.mediaServerId
  91 + id: new-dvr-test
  92 + # [必须修改] zlm服务器的内网IP
  93 + ip: 10.0.0.16
  94 + # [可选] 返回流地址时的ip,置空使用 media.ip
  95 + stream-ip: 113.249.109.139
  96 + # [可选] wvp在国标信令中使用的ip,此ip为摄像机可以访问到的ip, 置空使用 media.ip
  97 + sdp-ip: 113.249.109.139
  98 + # [可选] zlm服务器的hook所使用的IP, 默认使用sip.ip
  99 + hook-ip: 10.0.0.16
  100 + # [必须修改] zlm服务器的http.port
  101 + http-port: 1090
  102 + # [可选] zlm服务器的http.sslport, 置空使用zlm配置文件配置
  103 + http-ssl-port: 8443
  104 + # [可选] zlm服务器的rtmp.port, 置空使用zlm配置文件配置
  105 + rtmp-port: 2935
  106 + # [可选] zlm服务器的rtmp.sslport, 置空使用zlm配置文件配置
  107 + rtmp-ssl-port: 0
  108 + # [可选] zlm服务器的 rtp_proxy.port, 置空使用zlm配置文件配置
  109 + rtp-proxy-port: 10000
  110 + # [可选] zlm服务器的 rtsp.port, 置空使用zlm配置文件配置
  111 + rtsp-port: 9554
  112 + # [可选] zlm服务器的 rtsp.sslport, 置空使用zlm配置文件配置
  113 + rtsp-ssl-port: 0
  114 + # [可选] 是否自动配置ZLM, 如果希望手动配置ZLM, 可以设为false, 不建议新接触的用户修改
  115 + auto-config: true
  116 + # [可选] zlm服务器的hook.admin_params=secret
  117 + secret: 8KMYsD5ItKkHN1CIcPI9VeLa6u4S8deU
  118 + pushKey: 41db35390ddad33f83944f44b8b75ded
  119 + # 启用多端口模式, 多端口模式使用端口区分每路流,兼容性更好。 单端口使用流的ssrc区分, 点播超时建议使用多端口测试
  120 + rtp:
  121 + # [可选] 是否启用多端口模式, 开启后会在portRange范围内选择端口用于媒体流传输
  122 + enable: true
  123 + # [可选] 在此范围内选择端口用于媒体流传输, 必须提前在zlm上配置该属性,不然自动配置此属性可能不成功
  124 + port-range: 50000,50050 # 端口范围
  125 + # [可选] 国标级联在此范围内选择端口发送媒体流,
  126 + send-port-range: 50000,50050 # 端口范围
  127 + # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用
  128 + record-assist-port: 0
  129 + # [可选] 录像保存天数, 默认为7天, 0表示永久保存
  130 + record-day: 1
  131 +# [根据业务需求配置]
  132 +user-settings:
  133 + # 推流直播是否录制
  134 + record-push-live: true
  135 + # 国标是否录制
  136 + record-sip: false
  137 + # 点播/录像回放 等待超时时间,单位:毫秒
  138 + play-timeout: 180000
  139 + # [可选] 自动点播, 使用固定流地址进行播放时,如果未点播则自动进行点播, 需要rtp.enable=true
  140 + auto-apply-play: true
  141 + # 设备/通道状态变化时发送消息
  142 + device-status-notify: true
  143 + # 是否开启接口鉴权
  144 + # interface-authentication: false
  145 + # 等待音视频编码信息再返回, true: 可以根据编码选择合适的播放器,false: 可以更快点播
  146 +
  147 +# MyBatis配置
  148 +mybatis-plus:
  149 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  150 + mapperLocations: classpath*:mapper/**/*Mapper.xml
  151 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  152 + #mapperLocations: classpath*:mapper/**/*Mapper.xml
  153 +
  154 +jt1078:
  155 + enable: true
  156 + port: 1079
  157 +logging:
  158 + config: classpath:logback-spring.xml
  159 +tuohua:
  160 + bsth:
  161 + login:
  162 + pageURL: http://10.0.0.3:9088/user/login/jCryptionKey
  163 + url: http://10.0.0.3:9088/user/login
  164 + userName: yuanxiaohu
  165 + password: Yxiaohu1.0
  166 + rest:
  167 + baseURL: http://10.0.0.3:9089/webservice/rest
  168 + password: bafb2b44a07a02e5e9912f42cd197423884116a8
  169 + tree:
  170 + url:
  171 + company: http://10.0.0.3:9088/video/tree
  172 + car: http://10.0.0.3:9088/video/tree/carNo/{0}
  173 + sim: http://10.0.0.3:9088/video//tree/caNO/sim/{0}
  174 + wvp28181:
  175 + rtsp:
  176 + tcpPort: 1078
  177 + udpPort: 1078
  178 + historyTcpPort: 9999
  179 + historyUdpPort: 9999
  180 + ip : 113.249.109.139
  181 + jt1078:
  182 + ws-prefix: ws://113.249.109.139:${server.port}
  183 + ports: 9101,9101
  184 + port: 9100
  185 + httpPort: 3333
  186 + addPortVal: 0
  187 + pushURL: http://127.0.0.1:3333/new/server/{pushKey}/{port}/{httpPort}
  188 + stopPushURL: http://127.0.0.1:3333/stop/channel/{pushKey}/{port}/{httpPort}
  189 + url: http://10.0.0.3:8100/device/{0}
  190 + new_url: http://10.0.0.3:8100/device
  191 + historyListPort: 9205
  192 + playHistoryPort: 9201
  193 + history_upload: 9206
  194 + sendPort: 9101
  195 + stopSendPort: 9102
  196 + ws: ws://113.249.109.139:1090/schedule/{stream}.live.flv
  197 + wss: wss://113.249.109.139:8443/schedule/{stream}.live.flv
  198 + downloadFLV: http://113.249.109.139:1090/schedule/{stream}.live.flv
  199 + get:
  200 + url: http://10.0.0.16:3333/video/{stream}.flv
  201 + playURL: /play/wasm/ws%3A%2F%2F{ip}%3A{port}%2Fschedule%2F{sim}-{channel}.live.flv%3FcallId%{publickey}
  202 +
  203 +
  204 +ftp:
  205 + basePath: /wvp-local
  206 + within_host: 10.0.0.3
  207 + host: 113.249.109.139
  208 + httpPath: ftp://113.249.109.139
  209 + filePathPrefix: http://113.249.109.139:50100/wvp-local
  210 + password: Em@FtpAdmin@1369.
  211 + port: 50100
  212 + username: EmFtpAdmin
  213 + retryTimes: 5
  214 + retryWaitTimes: 3000
  215 + # 视频过期时间 单位:天
  216 + expirationTime: 7
  217 + # 未上传成功超时时间 单位:秒
  218 + timeOut: 300
  219 +
  220 +forest:
  221 + backend: okhttp3 # 后端HTTP框架(默认为 okhttp3)
  222 + max-connections: 1000 # 连接池最大连接数(默认为 500)
  223 + max-route-connections: 500 # 每个路由的最大连接数(默认为 500)
  224 + max-request-queue-size: 100 # [自v1.5.22版本起可用] 最大请求等待队列大小
  225 + max-async-thread-size: 300 # [自v1.5.21版本起可用] 最大异步线程数
  226 + max-async-queue-size: 16 # [自v1.5.22版本起可用] 最大异步线程池队列大小
  227 + timeout: 3000 # [已不推荐使用] 请求超时时间,单位为毫秒(默认为 3000)
  228 + connect-timeout: 3000 # 连接超时时间,单位为毫秒(默认为 timeout)
  229 + read-timeout: 3000 # 数据读取超时时间,单位为毫秒(默认为 timeout)
  230 + max-retry-count: 0 # 请求失败后重试次数(默认为 0 次不重试)
  231 + # ssl-protocol: TLS # 单向验证的HTTPS的默认TLS协议(默认为 TLS)
  232 + log-enabled: true # 打开或关闭日志(默认为 true)
  233 + log-request: true # 打开/关闭Forest请求日志(默认为 true)
  234 + log-response-status: true # 打开/关闭Forest响应状态日志(默认为 true)
  235 + log-response-content: true # 打开/关闭Forest响应内容日志(默认为 false)
  236 +# async-mode: platform # [自v1.5.27版本起可用] 异步模式(默认为 platform)
src/main/resources/application-wx-local.yml
@@ -187,7 +187,8 @@ tuohua: @@ -187,7 +187,8 @@ tuohua:
187 rest: 187 rest:
188 # baseURL: http://10.10.2.20:9089/webservice/rest 188 # baseURL: http://10.10.2.20:9089/webservice/rest
189 # password: bafb2b44a07a02e5e9912f42cd197423884116a8 189 # password: bafb2b44a07a02e5e9912f42cd197423884116a8
190 - baseURL: http://113.249.109.139:9089/webservice/rest 190 +# baseURL: http://113.249.109.139:9089/webservice/rest
  191 + baseURL: http://192.168.168.152:9089/webservice/rest
191 password: bafb2b44a07a02e5e9912f42cd197423884116a8 192 password: bafb2b44a07a02e5e9912f42cd197423884116a8
192 tree: 193 tree:
193 url: 194 url:
@@ -202,16 +203,19 @@ tuohua: @@ -202,16 +203,19 @@ tuohua:
202 historyUdpPort: 9999 203 historyUdpPort: 9999
203 ip : 61.169.120.202 204 ip : 61.169.120.202
204 jt1078: 205 jt1078:
205 - ports: 30001,30001  
206 - port: 30000 206 + ws-prefix: ws://192.169.1.87:18090
  207 + ports: 40001,40001
  208 + port: 40000
207 httpPort: 3333 209 httpPort: 3333
208 addPortVal: 0 210 addPortVal: 0
209 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort} 211 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort}
210 stopPushURL: http://${my.ip}:3333/stop/channel/{pushKey}/{port}/{httpPort} 212 stopPushURL: http://${my.ip}:3333/stop/channel/{pushKey}/{port}/{httpPort}
211 # url: http://10.10.2.20:8100/device/{0} 213 # url: http://10.10.2.20:8100/device/{0}
212 # new_url: http://10.10.2.20:8100/device 214 # new_url: http://10.10.2.20:8100/device
213 - url: http://113.249.109.139:8100/device/{0}  
214 - new_url: http://113.249.109.139:8100/device 215 +# url: http://113.249.109.139:8100/device/{0}
  216 +# new_url: http://113.249.109.139:8100/device
  217 + url: http://192.168.168.152:8100/device/{0}
  218 + new_url: http://192.168.168.152:8100/device
215 historyListPort: 9205 219 historyListPort: 9205
216 history_upload: 9206 220 history_upload: 9206
217 playHistoryPort: 9201 221 playHistoryPort: 9201
@@ -225,8 +229,23 @@ tuohua: @@ -225,8 +229,23 @@ tuohua:
225 url: http://${my.ip}:3333/video/{stream} 229 url: http://${my.ip}:3333/video/{stream}
226 playURL: /play/wasm/ws%3A%2F%2F{ip}%3A{port}%2Fschedule%2F{sim}-{channel}.live.flv%3FcallId%{publickey} 230 playURL: /play/wasm/ws%3A%2F%2F{ip}%3A{port}%2Fschedule%2F{sim}-{channel}.live.flv%3FcallId%{publickey}
227 231
  232 +#ftp:
  233 +# basePath: /wvp-local
  234 +# host: 61.169.120.202
  235 +# httpPath: ftp://61.169.120.202
  236 +# filePathPrefix: http://61.169.120.202:10021/wvp-local
  237 +# password: ftp@123
  238 +# port: 10021
  239 +# username: ftpadmin
  240 +# retryTimes: 5
  241 +# retryWaitTimes: 3000
  242 +# # 视频过期时间 单位:天
  243 +# expirationTime: 7
  244 +# # 未上传成功超时时间 单位:秒
  245 +# timeOut: 300
228 ftp: 246 ftp:
229 basePath: /wvp-local 247 basePath: /wvp-local
  248 + within_host: 61.169.120.202
230 host: 61.169.120.202 249 host: 61.169.120.202
231 httpPath: ftp://61.169.120.202 250 httpPath: ftp://61.169.120.202
232 filePathPrefix: http://61.169.120.202:10021/wvp-local 251 filePathPrefix: http://61.169.120.202:10021/wvp-local
web_src/package.json
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 "splitpanes": "^2.4.1", 28 "splitpanes": "^2.4.1",
29 "uuid": "^8.3.2", 29 "uuid": "^8.3.2",
30 "v-charts": "^1.19.0", 30 "v-charts": "^1.19.0",
  31 + "video.js": "^8.23.4",
31 "vue": "^2.6.11", 32 "vue": "^2.6.11",
32 "vue-clipboard2": "^0.3.1", 33 "vue-clipboard2": "^0.3.1",
33 "vue-clipboards": "^1.3.0", 34 "vue-clipboards": "^1.3.0",
web_src/src/components/DeviceList1078.vue
@@ -20,6 +20,13 @@ @@ -20,6 +20,13 @@
20 <div class="menu-item" @click="handleContextCommand('playback')"> 20 <div class="menu-item" @click="handleContextCommand('playback')">
21 <i class="el-icon-video-play"></i> 一键播放该设备 21 <i class="el-icon-video-play"></i> 一键播放该设备
22 </div> 22 </div>
  23 + <div class="menu-item" @click="handleContextCommand('message')">
  24 + <i class="el-icon-chat-dot-round"></i> 文本信息下发
  25 + </div>
  26 + <div class="menu-item" @click="handleContextCommand('intercom')">
  27 + <i :class="(rightClickNode && rightClickNode.data && intercomTarget === rightClickNode.data.id) ? 'el-icon-microphone' : 'el-icon-phone-outline'"></i>
  28 + {{ (rightClickNode && rightClickNode.data && intercomTarget === rightClickNode.data.id) ? '停止语音对讲' : '开启语音对讲' }}
  29 + </div>
23 </div> 30 </div>
24 31
25 <el-container class="right-container"> 32 <el-container class="right-container">
@@ -31,6 +38,7 @@ @@ -31,6 +38,7 @@
31 /> 38 />
32 39
33 <window-num-select v-model="windowNum"></window-num-select> 40 <window-num-select v-model="windowNum"></window-num-select>
  41 +
34 <el-button type="danger" size="mini" @click="closeAllVideo">全部关闭</el-button> 42 <el-button type="danger" size="mini" @click="closeAllVideo">全部关闭</el-button>
35 <el-button type="warning" size="mini" @click="closeVideo">关闭选中</el-button> 43 <el-button type="warning" size="mini" @click="closeVideo">关闭选中</el-button>
36 <el-button type="primary" size="mini" icon="el-icon-full-screen" @click="toggleFullscreen"></el-button> 44 <el-button type="primary" size="mini" icon="el-icon-full-screen" @click="toggleFullscreen"></el-button>
@@ -44,19 +52,25 @@ @@ -44,19 +52,25 @@
44 {{ isCarouselRunning ? '停止轮播' : '轮播设置' }} 52 {{ isCarouselRunning ? '停止轮播' : '轮播设置' }}
45 </el-button> 53 </el-button>
46 54
47 - <div v-if="isCarouselRunning" class="carousel-status"> 55 + <div v-if="isCarouselRunning" class="status-tag carousel-status">
48 <template v-if="isWithinSchedule"> 56 <template v-if="isWithinSchedule">
49 <i class="el-icon-loading" style="margin-right:4px"></i> 57 <i class="el-icon-loading" style="margin-right:4px"></i>
50 - <span style="color: #67C23A; font-weight: bold;">轮播运行中</span>  
51 - <span style="font-size: 10px; color: #909399; margin-left: 5px;">(缓冲: {{channelBuffer.length}}路)</span> 58 + <span style="color: #67C23A; font-weight: bold;">轮播中</span>
  59 + <span style="font-size: 10px; color: #909399;">(缓冲:{{channelBuffer.length}})</span>
52 </template> 60 </template>
53 <template v-else> 61 <template v-else>
54 <i class="el-icon-time" style="margin-right:4px"></i> 62 <i class="el-icon-time" style="margin-right:4px"></i>
55 - <span style="color: #E6A23C;">等待时段生效</span> 63 + <span style="color: #E6A23C;">等待时段</span>
56 </template> 64 </template>
57 </div> 65 </div>
58 66
59 - <span class="header-right-info">选中窗口 : {{ windowClickIndex }}</span> 67 + <div v-if="intercomTarget" class="status-tag intercom-status">
  68 + <div class="recording-dot"></div>
  69 + <span>正在与 [{{ intercomTargetName }}] 对讲</span>
  70 + <el-button type="text" class="stop-btn" size="mini" @click="stopIntercom">挂断</el-button>
  71 + </div>
  72 +
  73 + <span class="header-right-info">窗口: {{ windowClickIndex }}</span>
60 </el-header> 74 </el-header>
61 75
62 <carousel-config 76 <carousel-config
@@ -76,6 +90,32 @@ @@ -76,6 +90,32 @@
76 ></player-list-component> 90 ></player-list-component>
77 </el-main> 91 </el-main>
78 </el-container> 92 </el-container>
  93 + <el-dialog
  94 + title="文本信息下发"
  95 + :visible.sync="msgDialogVisible"
  96 + width="400px"
  97 + append-to-body
  98 + :close-on-click-modal="false"
  99 + >
  100 + <div style="margin-bottom: 15px;">
  101 + <span>下发目标:</span>
  102 + <el-tag size="small" v-if="msgTargetNode">{{ msgTargetNode.name }}</el-tag>
  103 + </div>
  104 +
  105 + <el-input
  106 + type="textarea"
  107 + v-model="msgContent"
  108 + :rows="4"
  109 + placeholder="请输入文本内容..."
  110 + maxlength="50"
  111 + show-word-limit
  112 + ></el-input>
  113 +
  114 + <span slot="footer" class="dialog-footer">
  115 + <el-button @click="msgDialogVisible = false" size="small">取 消</el-button>
  116 + <el-button type="primary" @click="confirmSendMessage" size="small" :disabled="!msgContent">发 送</el-button>
  117 + </span>
  118 + </el-dialog>
79 </el-container> 119 </el-container>
80 </template> 120 </template>
81 121
@@ -84,14 +124,24 @@ import VehicleList from &quot;./JT1078Components/deviceList/VehicleList.vue&quot;; @@ -84,14 +124,24 @@ import VehicleList from &quot;./JT1078Components/deviceList/VehicleList.vue&quot;;
84 import CarouselConfig from "./CarouselConfig.vue"; 124 import CarouselConfig from "./CarouselConfig.vue";
85 import WindowNumSelect from "./WindowNumSelect.vue"; 125 import WindowNumSelect from "./WindowNumSelect.vue";
86 import PlayerListComponent from './common/PlayerListComponent.vue'; 126 import PlayerListComponent from './common/PlayerListComponent.vue';
87 - 127 +import VideoPlayer from './common/EasyPlayer.vue';
  128 +const PCMA_TO_PCM = new Int16Array(256);
  129 +for (let i = 0; i < 256; i++) {
  130 + let s = ~i;
  131 + let t = ((s & 0x0f) << 3) + 8;
  132 + let seg = (s & 0x70) >> 4;
  133 + if (seg > 0) t = (t + 0x100) << (seg - 1);
  134 + PCMA_TO_PCM[i] = (s & 0x80) ? -t : t;
  135 +}
88 export default { 136 export default {
  137 +
89 name: "live", 138 name: "live",
90 components: { 139 components: {
91 VehicleList, 140 VehicleList,
92 CarouselConfig, 141 CarouselConfig,
93 WindowNumSelect, 142 WindowNumSelect,
94 - PlayerListComponent 143 + PlayerListComponent,
  144 + VideoPlayer
95 }, 145 },
96 data() { 146 data() {
97 return { 147 return {
@@ -107,6 +157,7 @@ export default { @@ -107,6 +157,7 @@ export default {
107 contextMenuTop: 0, 157 contextMenuTop: 0,
108 rightClickNode: null, 158 rightClickNode: null,
109 159
  160 + // 播放器与轮播相关
110 videoUrl: [], 161 videoUrl: [],
111 videoDataList: [], 162 videoDataList: [],
112 deviceTreeData: [], 163 deviceTreeData: [],
@@ -117,12 +168,34 @@ export default { @@ -117,12 +168,34 @@ export default {
117 carouselDeviceList: [], 168 carouselDeviceList: [],
118 channelBuffer: [], 169 channelBuffer: [],
119 deviceCursor: 0, 170 deviceCursor: 0,
  171 + //文本下发相关
  172 + msgDialogVisible: false,
  173 + msgContent: '',
  174 + msgTargetNode: null, // 记录当前要发送给哪辆车
  175 + // 对讲相关
  176 + intercomTarget: null,
  177 + intercomTargetName: '',
  178 + intercomSession: null,
  179 +
  180 + // 音频处理对象
  181 + audioContext: null, // 全局音频上下文
  182 + audioStream: null, // 麦克风流
  183 + audioProcessor: null, // 录音处理器
  184 + audioInput: null, // 音频输入源
  185 + nextPlayTime: 0, // 下一次播放的时间戳(用于平滑播放)
  186 +
  187 + flvPlayer: null,
  188 + isTalking: false,
  189 + // 重试定时器
  190 + retryTimer: null,
  191 + // 对讲音频播放器
  192 + talkFlvPlayer: null,
  193 + talkAudioElement: null
120 }; 194 };
121 }, 195 },
122 mounted() { 196 mounted() {
123 document.addEventListener('fullscreenchange', this.handleFullscreenChange); 197 document.addEventListener('fullscreenchange', this.handleFullscreenChange);
124 window.addEventListener('beforeunload', this.handleBeforeUnload); 198 window.addEventListener('beforeunload', this.handleBeforeUnload);
125 - // 全局点击隐藏右键菜单  
126 document.addEventListener('click', this.hideContextMenu); 199 document.addEventListener('click', this.hideContextMenu);
127 }, 200 },
128 beforeDestroy() { 201 beforeDestroy() {
@@ -130,6 +203,7 @@ export default { @@ -130,6 +203,7 @@ export default {
130 window.removeEventListener('beforeunload', this.handleBeforeUnload); 203 window.removeEventListener('beforeunload', this.handleBeforeUnload);
131 document.removeEventListener('click', this.hideContextMenu); 204 document.removeEventListener('click', this.hideContextMenu);
132 this.stopCarousel(); 205 this.stopCarousel();
  206 + this.stopIntercom(); // 确保组件销毁时停止对讲
133 }, 207 },
134 // 路由离开守卫 208 // 路由离开守卫
135 beforeRouteLeave(to, from, next) { 209 beforeRouteLeave(to, from, next) {
@@ -145,7 +219,9 @@ export default { @@ -145,7 +219,9 @@ export default {
145 } 219 }
146 }, 220 },
147 methods: { 221 methods: {
148 - // --- 侧边栏折叠逻辑 --- 222 + // ===========================
  223 + // 1. 界面与基础交互
  224 + // ===========================
149 updateSidebarState() { 225 updateSidebarState() {
150 this.sidebarState = !this.sidebarState; 226 this.sidebarState = !this.sidebarState;
151 setTimeout(() => { 227 setTimeout(() => {
@@ -153,18 +229,13 @@ export default { @@ -153,18 +229,13 @@ export default {
153 window.dispatchEvent(event); 229 window.dispatchEvent(event);
154 }, 310); 230 }, 310);
155 }, 231 },
156 -  
157 - // --- 数据同步 ---  
158 handleTreeLoaded(data) { 232 handleTreeLoaded(data) {
159 this.deviceTreeData = data; 233 this.deviceTreeData = data;
160 }, 234 },
161 -  
162 - // --- 播放器交互 ---  
163 handleClick(data, index) { 235 handleClick(data, index) {
164 this.windowClickIndex = index + 1; 236 this.windowClickIndex = index + 1;
165 this.windowClickData = data; 237 this.windowClickData = data;
166 }, 238 },
167 -  
168 toggleFullscreen() { 239 toggleFullscreen() {
169 const element = this.$refs.videoMain.$el; 240 const element = this.$refs.videoMain.$el;
170 if (!this.isFullscreen) { 241 if (!this.isFullscreen) {
@@ -179,40 +250,564 @@ export default { @@ -179,40 +250,564 @@ export default {
179 this.isFullscreen = !!document.fullscreenElement; 250 this.isFullscreen = !!document.fullscreenElement;
180 }, 251 },
181 252
182 - // ==========================================  
183 - // 【核心修复】1. 左键点击播放逻辑  
184 - // ========================================== 253 + // ===========================
  254 + // 2. 右键菜单与指令下发
  255 + // ===========================
  256 + nodeContextmenu(event, data, node) {
  257 + if (data.children && data.children.length > 0) {
  258 + this.rightClickNode = node;
  259 + this.contextMenuVisible = true;
  260 + this.contextMenuLeft = event.clientX;
  261 + this.contextMenuTop = event.clientY;
  262 + }
  263 + },
  264 + hideContextMenu() {
  265 + this.contextMenuVisible = false;
  266 + },
  267 + handleContextCommand(command) {
  268 + this.hideContextMenu();
  269 + // 安全检查
  270 + if (!this.rightClickNode || !this.rightClickNode.data) return;
  271 + const nodeData = this.rightClickNode.data;
  272 +
  273 + if (command === 'playback') {
  274 + this.batchPlayback(nodeData);
  275 + } else if (command === 'message') {
  276 + this.openMessageDialog(nodeData);
  277 + } else if (command === 'intercom') {
  278 + this.toggleIntercom(nodeData);
  279 + }
  280 + },
  281 +
  282 + // 打开弹窗
  283 + openMessageDialog(data) {
  284 + this.msgTargetNode = data; // 记录目标车辆
  285 + this.msgContent = ''; // 清空输入框
  286 + this.msgDialogVisible = true; // 显示自定义弹窗
  287 + },
  288 +
  289 + // 确认发送
  290 + confirmSendMessage() {
  291 + if (!this.msgContent) {
  292 + return this.$message.warning("内容不能为空");
  293 + }
  294 + // 调用之前的发送逻辑
  295 + this.sendTextToDevice(this.msgTargetNode, this.msgContent);
  296 + // 关闭弹窗
  297 + this.msgDialogVisible = false;
  298 + },
  299 +
  300 + sendTextToDevice(data, content) {
  301 + const params = {
  302 + vehicleNo: data.name,
  303 + sim: data.sim,
  304 + text: content
  305 + };
  306 + this.$axios.post('/api/external/send_text', params).then(res => {
  307 + if (res.data.code === 200) {
  308 + this.$message.success("指令下发成功");
  309 + } else {
  310 + this.$message.error(res.data.msg || "下发失败");
  311 + }
  312 + });
  313 + },
  314 +
  315 + // 1. 点击对讲按钮入口
  316 + async toggleIntercom(data) {
  317 + // 如果点击的是当前正在对讲的车辆 -> 停止
  318 + if (this.intercomTarget === data.id) {
  319 + this.stopIntercom();
  320 + return;
  321 + }
  322 + // 如果正在和其他车对讲 -> 提示互斥
  323 + if (this.intercomTarget) {
  324 + this.$message.warning(`请先挂断当前与 [${this.intercomTargetName}] 的对讲`);
  325 + return;
  326 + }
  327 +
  328 + // 权限预检查
  329 + try {
  330 + await navigator.mediaDevices.getUserMedia({ audio: true });
  331 + } catch (err) {
  332 + console.error("麦克风权限错误:", err);
  333 + this.$message.error("无法获取麦克风权限,请检查浏览器设置或HTTPS环境");
  334 + return;
  335 + }
  336 +
  337 + // 【新增】在用户点击瞬间初始化或恢复音频播放上下文
  338 + if (!this.audioPlayContext) {
  339 + this.audioPlayContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 8000 });
  340 + }
  341 + if (this.audioPlayContext.state === 'suspended') {
  342 + await this.audioPlayContext.resume();
  343 + }
  344 + // 开始流程
  345 + this.startIntercom(data);
  346 + },
  347 +
  348 + // 2. 请求后端获取网关地址
  349 + startIntercom(data) {
  350 + const loading = this.$loading({
  351 + lock: true,
  352 + text: `正在连接 [${data.name}] ...`,
  353 + background: 'rgba(0, 0, 0, 0.7)'
  354 + });
  355 +
  356 + this.$axios.get(`/api/jt1078/intercom/url/${data.sim}`)
  357 + .then(res => {
  358 + if (res.data.code === 0 || res.data.code === 200) {
  359 + // 兼容性处理:后端返回的结构可能不同
  360 + let wssUrl = "";
  361 + const responseData = res.data.data;
  362 +
  363 + if (typeof responseData === 'string') {
  364 + wssUrl = responseData;
  365 + } else if (responseData && responseData.url) {
  366 + wssUrl = responseData.url;
  367 + } else if (responseData && responseData.msg) {
  368 + // 有时候 msg 字段会被误用来放 url
  369 + wssUrl = responseData.msg;
  370 + }
  371 +
  372 + console.log("获取到网关地址:", wssUrl);
  373 +
  374 + if (!wssUrl || !wssUrl.startsWith('ws')) {
  375 + loading.close();
  376 + this.$message.error("后端返回的 WebSocket 地址无效");
  377 + return;
  378 + }
  379 +
  380 + this.initIntercomSession(data, wssUrl, loading);
  381 + } else {
  382 + loading.close();
  383 + this.$message.error(res.data.msg || "获取对讲地址失败");
  384 + }
  385 + })
  386 + .catch(err => {
  387 + loading.close();
  388 + console.error(err);
  389 + this.$message.error("请求超时或网络异常");
  390 + });
  391 + },
  392 +
  393 + // 3. 初始化 WebSocket (信令交互)
  394 + initIntercomSession(data, url, loadingInstance) {
  395 + try {
  396 + const socket = new WebSocket(url);
  397 + // 【关键】使用默认文本模式发送 JSON,不要设置 binaryType
  398 +
  399 + socket.onopen = () => {
  400 + this.intercomTarget = data.id;
  401 + this.intercomTargetName = data.name;
  402 + this.intercomSession = socket;
  403 + this.currentIntercomSim = data.sim.toString().padStart(12, '0');
  404 + this.isTalking = true;
  405 +
  406 + // A. 发送注册包 (必须先发这个,网关才认)
  407 + const registerMsg = { type: 'register', stream_id: this.currentIntercomSim };
  408 + socket.send(JSON.stringify(registerMsg));
  409 +
  410 + // B. 先启动播放器 (下行 - 收听),等待连接成功后再启动麦克风
  411 + // 使用 nextTick 确保 DOM 元素已经渲染
  412 + this.$nextTick(() => {
  413 + this.playTalkAudioStream(this.currentIntercomSim, loadingInstance);
  414 + });
  415 + };
  416 +
  417 + socket.onmessage = (event) => {
  418 + // 该网关下行通常不走 WebSocket,这里只打印信令日志
  419 + try {
  420 + const msg = JSON.parse(event.data);
  421 + if(msg.type === 'registered') console.log("网关注册确认成功");
  422 + } catch(e) {}
  423 + };
  424 +
  425 + socket.onerror = (e) => {
  426 + loadingInstance.close();
  427 + console.error("WS Error", e);
  428 + this.stopIntercom();
  429 + this.$message.error("对讲连接中断");
  430 + };
  431 +
  432 + socket.onclose = () => {
  433 + if (this.isTalking) {
  434 + console.warn("WebSocket 意外断开");
  435 + this.stopIntercom();
  436 + }
  437 + };
  438 +
  439 + } catch (e) {
  440 + loadingInstance.close();
  441 + this.$message.error("初始化失败: " + e.message);
  442 + }
  443 + },
  444 +
  445 + // 4. 采集麦克风 -> 降采样 -> 发送 (上行核心)
  446 + // 启动音频采集 (上行)
  447 + async startAudioCapture(sim) {
  448 + try {
  449 + const stream = await navigator.mediaDevices.getUserMedia({
  450 + audio: {
  451 + channelCount: 1,
  452 + echoCancellation: true,
  453 + noiseSuppression: true,
  454 + autoGainControl: true
  455 + }
  456 + });
  457 + this.audioStream = stream;
  458 +
  459 + const AudioContext = window.AudioContext || window.webkitAudioContext;
  460 + this.audioContext = new AudioContext();
  461 + // 获取浏览器真实的采样率 (例如 48000 或 44100)
  462 + const sourceSampleRate = this.audioContext.sampleRate;
  463 + console.log("麦克风真实采样率:", sourceSampleRate);
  464 +
  465 + const source = this.audioContext.createMediaStreamSource(stream);
  466 + const gainNode = this.audioContext.createGain();
  467 + gainNode.gain.value = 2.0; // 音量适度放大(从5.0降低到2.0)
  468 +
  469 + // 使用 ScriptProcessor(较小的缓冲区以减少延迟)
  470 + this.audioProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
  471 +
  472 + this.audioProcessor.onaudioprocess = (e) => {
  473 + if (!this.intercomSession || this.intercomSession.readyState !== WebSocket.OPEN) return;
  474 +
  475 + // 1. 获取原始数据 (Float32, 48000Hz)
  476 + const inputBuffer = e.inputBuffer.getChannelData(0);
  477 +
  478 + // 2. 【核心修复】强制降采样到 8000Hz + 转 PCM16
  479 + // 这一步解决了 "声音杂乱" 和 "格式不支持" 的问题
  480 + const pcm16Buffer = this.downsampleBuffer(inputBuffer, sourceSampleRate, 8000);
  481 +
  482 + // 3. 【核心修复】防止发送空数据
  483 + if (pcm16Buffer.byteLength === 0) {
  484 + return;
  485 + }
  486 +
  487 + // 4. 转 Base64
  488 + const base64Str = this.arrayBufferToBase64(pcm16Buffer);
  489 +
  490 + // 5. 【核心修复】防止 Base64 为空
  491 + if (!base64Str) {
  492 + return;
  493 + }
  494 +
  495 + // 6. 发送数据 (注意 sample_rate 必须是 8000)
  496 + const msg = {
  497 + type: 'audio_data',
  498 + stream_id: sim,
  499 + audio_data: base64Str,
  500 + format: 'pcm16',
  501 + sample_rate: 8000, // 必须告诉网关这是 8k 数据
  502 + channels: 1
  503 + };
  504 +
  505 + this.intercomSession.send(JSON.stringify(msg));
  506 + };
  507 +
  508 + source.connect(gainNode);
  509 + gainNode.connect(this.audioProcessor);
  510 + this.audioProcessor.connect(this.audioContext.destination);
  511 +
  512 + } catch (err) {
  513 + console.error("麦克风启动错误", err);
  514 + this.$message.error("麦克风启动失败");
  515 + this.stopIntercom();
  516 + }
  517 + },
  518 +
  519 + playTalkAudioStream(sim, loadingInstance) {
  520 + const paddedSim = sim.toString().padStart(12, '0');
  521 + const flvUrl = `ws://127.0.0.1:80/schedule/${paddedSim}_voice.live.flv?callId=41db35390ddad33f83944f44b8b75ded`;
  522 +
  523 + // 创建隐藏的audio元素用于播放
  524 + if (!this.talkAudioElement) {
  525 + this.talkAudioElement = document.createElement('audio');
  526 + this.talkAudioElement.autoplay = true;
  527 + this.talkAudioElement.controls = false;
  528 + this.talkAudioElement.volume = 1.0;
  529 + }
  530 +
  531 + // 使用flv.js播放音频流
  532 + if (window.flvjs && window.flvjs.isSupported()) {
  533 + try {
  534 + // 销毁旧的播放器
  535 + if (this.talkFlvPlayer) {
  536 + this.talkFlvPlayer.pause();
  537 + this.talkFlvPlayer.unload();
  538 + this.talkFlvPlayer.detachMediaElement();
  539 + this.talkFlvPlayer.destroy();
  540 + this.talkFlvPlayer = null;
  541 + }
  542 +
  543 + this.talkFlvPlayer = window.flvjs.createPlayer({
  544 + type: 'flv',
  545 + url: flvUrl,
  546 + isLive: true,
  547 + hasAudio: true,
  548 + hasVideo: false
  549 + }, {
  550 + enableWorker: true,
  551 + enableStashBuffer: true,
  552 + stashInitialSize: 384,
  553 + autoCleanupSourceBuffer: true,
  554 + autoCleanupMaxBackwardDuration: 3,
  555 + autoCleanupMinBackwardDuration: 2,
  556 + fixAudioTimestampGap: true,
  557 + liveBufferLatencyChasing: true,
  558 + liveBufferLatencyChasingOnPaused: false,
  559 + liveBufferLatencyMaxLatency: 3,
  560 + liveBufferLatencyMinRemain: 0.8
  561 + });
  562 +
  563 + // 监听播放器错误事件
  564 + this.talkFlvPlayer.on(window.flvjs.Events.ERROR, (errorType, errorDetail, errorInfo) => {
  565 + console.error('FLV播放器错误:', errorType, errorDetail, errorInfo);
  566 + if (loadingInstance) {
  567 + loadingInstance.close();
  568 + }
  569 + this.$message.error('连接ZLM音频流失败,对讲已终止');
  570 + // 连接失败,停止对讲
  571 + this.stopIntercom();
  572 + });
  573 +
  574 + // 监听加载成功事件 - 只有在这里才启动麦克风
  575 + this.talkFlvPlayer.on(window.flvjs.Events.MEDIA_INFO, () => {
  576 + console.log('ZLM音频流连接成功,开始启动麦克风');
  577 + if (loadingInstance) {
  578 + loadingInstance.close();
  579 + }
  580 + // 连接成功后才启动麦克风采集
  581 + this.startAudioCapture(this.currentIntercomSim);
  582 + this.$message.success("对讲通道已建立,可以开始说话");
  583 + });
  584 +
  585 + this.talkFlvPlayer.attachMediaElement(this.talkAudioElement);
  586 + this.talkFlvPlayer.load();
  587 + this.talkFlvPlayer.play().catch(err => {
  588 + console.error('播放音频失败:', err);
  589 + if (loadingInstance) {
  590 + loadingInstance.close();
  591 + }
  592 + this.$message.error('播放音频失败,对讲已终止');
  593 + // 播放失败,停止对讲
  594 + this.stopIntercom();
  595 + });
  596 +
  597 + console.log('对讲音频播放器已启动,等待连接ZLM...');
  598 + } catch (e) {
  599 + console.error('创建FLV播放器失败:', e);
  600 + if (loadingInstance) {
  601 + loadingInstance.close();
  602 + }
  603 + this.$message.error('创建音频播放器失败,对讲已终止');
  604 + // 创建失败,停止对讲
  605 + this.stopIntercom();
  606 + }
  607 + } else {
  608 + console.error('浏览器不支持flv.js');
  609 + if (loadingInstance) {
  610 + loadingInstance.close();
  611 + }
  612 + this.$message.error('浏览器不支持FLV播放,对讲已终止');
  613 + // 不支持,停止对讲
  614 + this.stopIntercom();
  615 + }
  616 + },
  617 +
  618 + // 辅助:彻底销毁播放器
  619 + destroyFlvPlayer() {
  620 + if (this.retryTimer) {
  621 + clearInterval(this.retryTimer);
  622 + clearTimeout(this.retryTimer); // 兼容 setTimeout
  623 + this.retryTimer = null;
  624 + }
  625 +
  626 + if (this.flvPlayer) {
  627 + try {
  628 + this.flvPlayer.pause();
  629 + this.flvPlayer.unload();
  630 + this.flvPlayer.detachMediaElement();
  631 + this.flvPlayer.destroy();
  632 + } catch (e) {}
  633 + this.flvPlayer = null;
  634 + }
  635 + },
  636 +
  637 + // 6. 停止对讲
  638 + stopIntercom() {
  639 + console.log("正在停止对讲流程...");
  640 +
  641 + // A. 通知后端停止
  642 + if (this.currentIntercomSim) {
  643 + this.$axios.post(`/api/jt1078/intercom/stop/${this.currentIntercomSim}`).catch(()=>{});
  644 + }
  645 +
  646 + this.isTalking = false;
  647 +
  648 + // B. 停止对讲音频播放器
  649 + if (this.talkFlvPlayer) {
  650 + try {
  651 + this.talkFlvPlayer.pause();
  652 + this.talkFlvPlayer.unload();
  653 + this.talkFlvPlayer.detachMediaElement();
  654 + this.talkFlvPlayer.destroy();
  655 + } catch (e) {
  656 + console.error('销毁对讲播放器失败:', e);
  657 + }
  658 + this.talkFlvPlayer = null;
  659 + }
  660 +
  661 + if (this.talkAudioElement) {
  662 + try {
  663 + this.talkAudioElement.pause();
  664 + this.talkAudioElement.src = '';
  665 + } catch (e) {}
  666 + }
  667 +
  668 + // C. 关闭WebSocket连接
  669 + if (this.audioSocket) {
  670 + this.audioSocket.close();
  671 + this.audioSocket = null;
  672 + }
  673 + if (this.audioPlayContext) {
  674 + this.audioPlayContext.close().then(() => {
  675 + console.log("播放器上下文已释放");
  676 + });
  677 + this.audioPlayContext = null;
  678 + }
  679 +
  680 + // D. 停止上行采集 (喊话)
  681 + if (this.audioStream) {
  682 + this.audioStream.getTracks().forEach(track => track.stop());
  683 + this.audioStream = null;
  684 + }
  685 + if (this.audioProcessor) {
  686 + this.audioProcessor.disconnect();
  687 + this.audioProcessor = null;
  688 + }
  689 + if (this.audioContext) {
  690 + this.audioContext.close();
  691 + this.audioContext = null;
  692 + }
  693 +
  694 + // E. 关闭与Python的WebSocket信令连接(intercomSession)
  695 + if (this.intercomSession) {
  696 + try {
  697 + this.intercomSession.send(JSON.stringify({ type: 'close_talk' }));
  698 + console.log("已发送关闭信令到Python WebSocket");
  699 + } catch(e){
  700 + console.error("发送关闭信令失败:", e);
  701 + }
  702 + try {
  703 + this.intercomSession.close();
  704 + console.log("Python WebSocket连接已关闭");
  705 + } catch(e) {
  706 + console.error("关闭Python WebSocket失败:", e);
  707 + }
  708 + this.intercomSession = null;
  709 + }
  710 +
  711 + // F. 重置状态
  712 + this.intercomTarget = null;
  713 + this.intercomTargetName = '';
  714 + this.currentIntercomSim = null;
  715 + this.$message.info("对讲已挂断");
  716 + },
  717 +
  718 + // 工具:降采样 (Float32 -> Int16)
  719 + downsampleBuffer(buffer, sampleRate, outSampleRate) {
  720 + if (outSampleRate === sampleRate) {
  721 + return this.floatTo16BitPCM(buffer);
  722 + }
  723 +
  724 + const sampleRateRatio = sampleRate / outSampleRate;
  725 + const newLength = Math.round(buffer.length / sampleRateRatio);
  726 +
  727 + // 如果计算出的长度无效,返回空 buffer
  728 + if (newLength <= 0) return new ArrayBuffer(0);
  729 +
  730 + const result = new Int16Array(newLength);
  731 + let offsetResult = 0;
  732 + let offsetBuffer = 0;
  733 +
  734 + while (offsetResult < newLength) {
  735 + const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
  736 +
  737 + let accum = 0, count = 0;
  738 + // 简单的均值算法,防止声音有毛刺
  739 + for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
  740 + accum += buffer[i];
  741 + count++;
  742 + }
  743 +
  744 + if (count > 0) {
  745 + let s = accum / count;
  746 + // 可以在这里再次放大音量
  747 + // s = s * 2.0;
  748 + s = Math.max(-1, Math.min(1, s));
  749 + result[offsetResult] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  750 + } else {
  751 + result[offsetResult] = 0;
  752 + }
  753 +
  754 + offsetResult++;
  755 + offsetBuffer = nextOffsetBuffer;
  756 + }
  757 + return result.buffer;
  758 + },
  759 +
  760 + // 工具: Float32 -> Int16 (保留备用)
  761 + floatTo16BitPCM(input) {
  762 + let output = new Int16Array(input.length);
  763 + for (let i = 0; i < input.length; i++) {
  764 + let s = Math.max(-1, Math.min(1, input[i]));
  765 + output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  766 + }
  767 + return output;
  768 + },
  769 +
  770 + // 工具:Base64 转换 (必须处理 .buffer)
  771 + arrayBufferToBase64(buffer) {
  772 + let binary = '';
  773 + // 注意:buffer 可能是 ArrayBuffer,需要转 Uint8Array 才能读取
  774 + const bytes = new Uint8Array(buffer);
  775 + const len = bytes.byteLength;
  776 +
  777 + if (len === 0) return ""; // 此时返回空字符串
  778 +
  779 + for (let i = 0; i < len; i++) {
  780 + binary += String.fromCharCode(bytes[i]);
  781 + }
  782 + return window.btoa(binary);
  783 + },
  784 +
  785 + // ===========================
  786 + // 3. 播放逻辑 (单路/批量)
  787 + // ===========================
185 nodeClick(data, node) { 788 nodeClick(data, node) {
186 if (this.isCarouselRunning) { 789 if (this.isCarouselRunning) {
187 this.$message.warning("请先停止轮播再手动播放"); 790 this.$message.warning("请先停止轮播再手动播放");
188 return; 791 return;
189 } 792 }
190 - // 判断是否为叶子节点(通道),没有子节点视为通道  
191 if (!data.children || data.children.length === 0) { 793 if (!data.children || data.children.length === 0) {
192 this.playSingleChannel(data); 794 this.playSingleChannel(data);
193 } 795 }
194 }, 796 },
195 797
196 - // 单路播放 API 请求  
197 playSingleChannel(data) { 798 playSingleChannel(data) {
198 - // 假设 data.code 格式为 "deviceId_sim_channel"  
199 - // 根据您的接口调整这里的解析逻辑  
200 - let stream = data.code.replace('-', '_'); // 容错处理 799 + let stream = data.code.replace('-', '_');
201 let arr = stream.split("_"); 800 let arr = stream.split("_");
202 -  
203 - // 假设 ID 结构是: id_sim_channel,取后两段  
204 - if(arr.length < 3) return;  
205 - 801 + if (arr.length < 3) {
  802 + console.warn("Invalid channel code:", data.code);
  803 + return;
  804 + }
206 this.$axios.get(`/api/jt1078/query/send/request/io/${arr[1]}/${arr[2]}`).then(res => { 805 this.$axios.get(`/api/jt1078/query/send/request/io/${arr[1]}/${arr[2]}`).then(res => {
207 - if(res.data.code === 0 || res.data.code === 200) {  
208 - const url = res.data.data.ws_flv; // 或者 wss_flv 806 + if (res.data.code === 0 || res.data.code === 200) {
  807 + const url = (location.protocol === "https:") ? res.data.data.wss_flv : res.data.data.ws_flv;
209 const idx = this.windowClickIndex - 1; 808 const idx = this.windowClickIndex - 1;
210 -  
211 - // 更新播放地址和信息  
212 this.$set(this.videoUrl, idx, url); 809 this.$set(this.videoUrl, idx, url);
213 - this.$set(this.videoDataList, idx, { ...data, videoUrl: url });  
214 -  
215 - // 自动跳到下一个窗口 810 + this.$set(this.videoDataList, idx, {...data, videoUrl: url});
216 const maxWindow = parseInt(this.windowNum) || 4; 811 const maxWindow = parseInt(this.windowNum) || 4;
217 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1; 812 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1;
218 } else { 813 } else {
@@ -223,81 +818,49 @@ export default { @@ -223,81 +818,49 @@ export default {
223 }); 818 });
224 }, 819 },
225 820
226 - // ==========================================  
227 - // 【核心修复】2. 右键菜单逻辑  
228 - // ==========================================  
229 - nodeContextmenu(event, data, node) {  
230 - // 只有设备节点(有子级)才显示菜单,通道节点不显示  
231 - if (data.children && data.children.length > 0) {  
232 - this.rightClickNode = node;  
233 - this.contextMenuVisible = true;  
234 - this.contextMenuLeft = event.clientX;  
235 - this.contextMenuTop = event.clientY;  
236 -  
237 - // 阻止默认浏览器右键菜单  
238 - // 注意:VehicleList 组件内部也需要处理 @contextmenu.prevent  
239 - }  
240 - },  
241 -  
242 - hideContextMenu() {  
243 - this.contextMenuVisible = false;  
244 - }, 821 + batchPlayback(nodeData) {
  822 + if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作");
  823 + const channels = nodeData.children;
  824 + if (channels && channels.length > 0) {
  825 + this.videoUrl = [];
  826 + this.videoDataList = [];
  827 + if (channels.length > 16) this.windowNum = '25';
  828 + else if (channels.length > 9) this.windowNum = '16';
  829 + else if (channels.length > 4) this.windowNum = '9';
  830 + else this.windowNum = '4';
245 831
246 - // 处理右键菜单命令(一键播放该设备)  
247 - handleContextCommand(command) {  
248 - this.hideContextMenu();  
249 - if (command === 'playback') {  
250 - if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作");  
251 -  
252 - const channels = this.rightClickNode.data.children;  
253 - if (channels && channels.length > 0) {  
254 - // 1. 清屏  
255 - this.videoUrl = [];  
256 - this.videoDataList = [];  
257 -  
258 - // 2. 自动切换分屏布局  
259 - if (channels.length > 16) this.windowNum = '25';  
260 - else if (channels.length > 9) this.windowNum = '16';  
261 - else if (channels.length > 4) this.windowNum = '9';  
262 - else this.windowNum = '4';  
263 -  
264 - // 3. 构造批量请求参数  
265 - // 假设后端接受的格式处理  
266 - const ids = channels.map(c => {  
267 - // 假设 code 是 id_sim_channel  
268 - const parts = c.code.replaceAll('_', '-').split('-');  
269 - // 取 sim-channel 部分  
270 - return parts.slice(1).join('-');  
271 - }); 832 + const ids = channels.map(c => {
  833 + const parts = c.code.replaceAll('_', '-').split('-');
  834 + return parts.slice(1).join('-');
  835 + });
272 836
273 - this.$axios.post('/api/jt1078/query/beachSend/request/io', ids).then(res => {  
274 - if(res.data && res.data.data) {  
275 - const list = res.data.data || [];  
276 - list.forEach((item, i) => {  
277 - if (channels[i]) {  
278 - this.$set(this.videoUrl, i, item.ws_flv);  
279 - this.$set(this.videoDataList, i, { ...channels[i], videoUrl: item.ws_flv });  
280 - }  
281 - });  
282 - } else {  
283 - this.$message.warning("批量获取流地址为空");  
284 - }  
285 - }).catch(e => {  
286 - this.$message.error("批量播放失败");  
287 - });  
288 - } else {  
289 - this.$message.warning("该设备下没有通道");  
290 - } 837 + this.$axios.post('/api/jt1078/query/beachSend/request/io', ids).then(res => {
  838 + if (res.data && res.data.data) {
  839 + const list = res.data.data || [];
  840 + list.forEach((item, i) => {
  841 + if (channels[i]) {
  842 + this.$set(this.videoUrl, i, item.ws_flv);
  843 + this.$set(this.videoDataList, i, { ...channels[i], videoUrl: item.ws_flv });
  844 + }
  845 + });
  846 + } else {
  847 + this.$message.warning("批量获取流地址为空");
  848 + }
  849 + }).catch(e => {
  850 + this.$message.error("批量播放失败");
  851 + });
  852 + } else {
  853 + this.$message.warning("该设备下没有通道");
291 } 854 }
292 }, 855 },
293 856
294 - // ==========================================  
295 - // 3. 视频关闭逻辑  
296 - // ========================================== 857 + // ===========================
  858 + // 4. 关闭视频逻辑
  859 + // ===========================
297 async checkCarouselPermission(actionName) { 860 async checkCarouselPermission(actionName) {
298 if (!this.isCarouselRunning) return true; 861 if (!this.isCarouselRunning) return true;
299 try { 862 try {
300 - await this.$confirm(`正在轮播,"${actionName}"将停止轮播,是否继续?`,'提示',{ type: 'warning' }); 863 + await this.$confirm(`正在轮播,"${actionName}"将停止轮播,是否继续?`, '提示', {type: 'warning'});
301 this.stopCarousel(); 864 this.stopCarousel();
302 return true; 865 return true;
303 } catch (e) { return false; } 866 } catch (e) { return false; }
@@ -305,15 +868,14 @@ export default { @@ -305,15 +868,14 @@ export default {
305 868
306 async closeAllVideo() { 869 async closeAllVideo() {
307 if (!(await this.checkCarouselPermission('关闭所有视频'))) return; 870 if (!(await this.checkCarouselPermission('关闭所有视频'))) return;
308 - this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);  
309 - this.videoDataList = new Array(parseInt(this.windowNum)).fill(null); 871 + this.closeAllVideoNoConfirm();
310 }, 872 },
311 873
312 async closeVideo() { 874 async closeVideo() {
313 if (!(await this.checkCarouselPermission('关闭当前窗口'))) return; 875 if (!(await this.checkCarouselPermission('关闭当前窗口'))) return;
314 const idx = Number(this.windowClickIndex) - 1; 876 const idx = Number(this.windowClickIndex) - 1;
315 if (this.videoUrl && this.videoUrl[idx]) { 877 if (this.videoUrl && this.videoUrl[idx]) {
316 - this.$confirm(`确认关闭窗口 [${this.windowClickIndex}] ?`, '提示', { type: 'warning' }) 878 + this.$confirm(`确认关闭窗口 [${this.windowClickIndex}] ?`, '提示', {type: 'warning'})
317 .then(() => { 879 .then(() => {
318 this.$set(this.videoUrl, idx, null); 880 this.$set(this.videoUrl, idx, null);
319 this.$set(this.videoDataList, idx, null); 881 this.$set(this.videoDataList, idx, null);
@@ -321,9 +883,14 @@ export default { @@ -321,9 +883,14 @@ export default {
321 } 883 }
322 }, 884 },
323 885
324 - // ==========================================  
325 - // 4. 轮播逻辑 (保持之前定义的逻辑)  
326 - // ========================================== 886 + closeAllVideoNoConfirm() {
  887 + this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
  888 + this.videoDataList = new Array(parseInt(this.windowNum)).fill(null);
  889 + },
  890 +
  891 + // ===========================
  892 + // 5. 轮播逻辑
  893 + // ===========================
327 openCarouselConfig() { 894 openCarouselConfig() {
328 if (this.isCarouselRunning) { 895 if (this.isCarouselRunning) {
329 this.$confirm('确定要停止轮播吗?', '提示').then(() => this.stopCarousel()); 896 this.$confirm('确定要停止轮播吗?', '提示').then(() => this.stopCarousel());
@@ -334,58 +901,196 @@ export default { @@ -334,58 +901,196 @@ export default {
334 901
335 stopCarousel() { 902 stopCarousel() {
336 this.isCarouselRunning = false; 903 this.isCarouselRunning = false;
337 - if (this.carouselTimer) clearTimeout(this.carouselTimer); 904 + if (this.carouselTimer) {
  905 + clearTimeout(this.carouselTimer);
  906 + this.carouselTimer = null;
  907 + }
  908 + this.$message.info("轮播已停止");
338 }, 909 },
339 910
340 async startCarousel(config) { 911 async startCarousel(config) {
341 this.carouselConfig = config; 912 this.carouselConfig = config;
  913 + // 1. 筛选目标设备
342 let targetNodes = []; 914 let targetNodes = [];
  915 + if (config.sourceType === 'all_online') {
  916 + const collectOnline = (nodes) => {
  917 + nodes.forEach(node => {
  918 + if (node.abnormalStatus === 1) targetNodes.push(node);
  919 + if (node.children && node.children.length > 0) collectOnline(node.children);
  920 + });
  921 + };
  922 + collectOnline(this.deviceTreeData);
  923 + } else {
  924 + targetNodes = config.selectedNodes.filter(n => n.abnormalStatus === 1);
  925 + }
  926 +
  927 + if (targetNodes.length === 0) {
  928 + this.$message.warning("当前范围内没有在线设备可供轮播");
  929 + return;
  930 + }
343 931
344 - // ... (保留您之前的 startCarousel 完整逻辑,这里简略以节省篇幅,请确保您之前的代码已粘贴进来) ...  
345 - // 如果没有之前的代码,请告诉我,我再发一遍完整的 startCarousel  
346 - // 这里简单模拟一个启动 932 + // 2. 初始化状态
  933 + this.carouselDeviceList = targetNodes;
  934 + this.channelBuffer = [];
  935 + this.deviceCursor = 0;
347 this.isCarouselRunning = true; 936 this.isCarouselRunning = true;
348 - this.$message.success("轮播已启动 (需要补全 startCarousel 完整逻辑)"); 937 + this.isWithinSchedule = true;
  938 +
  939 + this.$message.success(`轮播已启动,共 ${targetNodes.length} 台在线设备`);
  940 + await this.executeFirstRound();
  941 + },
  942 +
  943 + async executeFirstRound() {
  944 + if (this.windowNum !== this.carouselConfig.layout) {
  945 + this.windowNum = this.carouselConfig.layout;
  946 + }
  947 + const batch = await this.fetchNextBatchData();
  948 + if (batch) {
  949 + this.applyVideoBatch(batch);
  950 + this.runCarouselLoop();
  951 + } else {
  952 + this.$message.error("首轮加载失败,尝试重试...");
  953 + this.runCarouselLoop();
  954 + }
  955 + },
  956 +
  957 + runCarouselLoop() {
  958 + if (!this.isCarouselRunning) return;
  959 + const {runMode, timeRange, interval} = this.carouselConfig;
  960 +
  961 + // 1. 检查定时
  962 + if (runMode === 'schedule') {
  963 + if (!this.checkTimeRange(timeRange[0], timeRange[1])) {
  964 + this.isWithinSchedule = false;
  965 + if (this.videoUrl.some(v => v)) this.closeAllVideoNoConfirm();
  966 + this.carouselTimer = setTimeout(() => this.runCarouselLoop(), 10000);
  967 + return;
  968 + }
  969 + this.isWithinSchedule = true;
  970 + }
  971 +
  972 + // 2. 计算时间轴
  973 + const PRELOAD_TIME = 15;
  974 + const intervalSec = Math.max(interval, 30);
  975 + const waitTime = (intervalSec - PRELOAD_TIME) * 1000;
  976 +
  977 + // 3. 计时循环
  978 + this.carouselTimer = setTimeout(async () => {
  979 + if (!this.isCarouselRunning) return;
  980 + const nextBatch = await this.fetchNextBatchData();
  981 +
  982 + this.carouselTimer = setTimeout(() => {
  983 + if (!this.isCarouselRunning) return;
  984 + if (nextBatch) this.applyVideoBatch(nextBatch);
  985 + this.runCarouselLoop();
  986 + }, PRELOAD_TIME * 1000);
  987 +
  988 + }, waitTime);
  989 + },
  990 +
  991 + applyVideoBatch({urls, infos}) {
  992 + this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
  993 + setTimeout(() => {
  994 + urls.forEach((url, index) => {
  995 + setTimeout(() => {
  996 + this.$set(this.videoUrl, index, url);
  997 + this.$set(this.videoDataList, index, infos[index]);
  998 + }, index * 100);
  999 + });
  1000 + }, 200);
349 }, 1001 },
350 1002
351 - // ... 其他轮播辅助函数 (fetchNextBatchData, applyVideoBatch 等) ...  
352 - // 请确保这些函数存在,否则轮播会报错  
353 - fetchNextBatchData() {},  
354 - runCarouselLoop() {},  
355 - applyVideoBatch() {}, 1003 + async fetchNextBatchData() {
  1004 + let pageSize = parseInt(this.windowNum) || 4;
  1005 + if (isNaN(pageSize)) pageSize = 4;
  1006 + let safetyCounter = 0;
  1007 + while (this.channelBuffer.length < pageSize && safetyCounter < 100) {
  1008 + safetyCounter++;
  1009 + if (this.deviceCursor >= this.carouselDeviceList.length) this.deviceCursor = 0;
  1010 +
  1011 + const device = this.carouselDeviceList[this.deviceCursor];
  1012 + if (device && device.children && device.children.length > 0) {
  1013 + const codes = device.children
  1014 + .filter(child => !child.disabled)
  1015 + .map(child => child.code);
  1016 + this.channelBuffer.push(...codes);
  1017 + }
  1018 + this.deviceCursor++;
  1019 + }
  1020 +
  1021 + if (this.channelBuffer.length === 0) return null;
  1022 +
  1023 + const currentCodes = this.channelBuffer.splice(0, pageSize);
  1024 + const streamParams = currentCodes.map(c => c.replaceAll('_', '-').split('-').slice(1).join('-'));
  1025 +
  1026 + try {
  1027 + const res = await this.$axios.post('/api/jt1078/query/beachSend/request/io', streamParams, {timeout: 20000});
  1028 + if (res.data && res.data.data) {
  1029 + const resultList = res.data.data;
  1030 + const urls = new Array(pageSize).fill('');
  1031 + const infos = new Array(pageSize).fill(null);
  1032 +
  1033 + resultList.forEach((item, i) => {
  1034 + if (i < currentCodes.length) {
  1035 + const url = (location.protocol === "https:") ? item.wss_flv : item.ws_flv;
  1036 + urls[i] = url;
  1037 + infos[i] = {
  1038 + code: currentCodes[i],
  1039 + name: `通道 ${i + 1}`,
  1040 + videoUrl: url
  1041 + };
  1042 + }
  1043 + });
  1044 + return {urls, infos};
  1045 + }
  1046 + } catch (e) {
  1047 + console.error("批量请求流地址失败", e);
  1048 + }
  1049 + return null;
  1050 + },
  1051 +
  1052 + checkTimeRange(startStr, endStr) {
  1053 + if (!startStr || !endStr) return true;
  1054 + const now = new Date();
  1055 + const current = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
  1056 + const parse = (str) => {
  1057 + const [h, m, s] = str.split(':').map(Number);
  1058 + return h * 3600 + m * 60 + s;
  1059 + };
  1060 + const start = parse(startStr);
  1061 + const end = parse(endStr);
  1062 + if (end < start) return current >= start || current <= end;
  1063 + return current >= start && current <= end;
  1064 + },
356 1065
357 handleBeforeUnload(e) { 1066 handleBeforeUnload(e) {
358 if (this.isCarouselRunning) { 1067 if (this.isCarouselRunning) {
359 e.preventDefault(); 1068 e.preventDefault();
360 e.returnValue = ''; 1069 e.returnValue = '';
361 } 1070 }
  1071 + this.stopIntercom();
362 }, 1072 },
363 } 1073 }
364 }; 1074 };
365 </script> 1075 </script>
366 1076
367 <style scoped> 1077 <style scoped>
368 -/* 1. 容器高度设为 100%,由 App.vue 的 el-main 决定高度  
369 - 这样就不会出现双重滚动条,Header 也不会被遮挡  
370 -*/  
371 .live-container { 1078 .live-container {
372 height: 100%; 1079 height: 100%;
373 width: 100%; 1080 width: 100%;
374 - overflow: hidden; /* 防止内部溢出 */ 1081 + overflow: hidden;
375 } 1082 }
376 1083
377 -/* 2. 侧边栏样式优化 */  
378 .el-aside { 1084 .el-aside {
379 background-color: #fff; 1085 background-color: #fff;
380 color: #333; 1086 color: #333;
381 text-align: center; 1087 text-align: center;
382 height: 100%; 1088 height: 100%;
383 - overflow: hidden; /* 隐藏收起时的内容 */ 1089 + overflow: hidden;
384 border-right: 1px solid #dcdfe6; 1090 border-right: 1px solid #dcdfe6;
385 - transition: width 0.3s ease-in-out; /* 添加平滑过渡动画 */ 1091 + transition: width 0.3s ease-in-out;
386 } 1092 }
387 1093
388 -/* 侧边栏内容包裹层,保持宽度固定,防止文字挤压 */  
389 .sidebar-content { 1094 .sidebar-content {
390 width: 280px; 1095 width: 280px;
391 height: 100%; 1096 height: 100%;
@@ -393,16 +1098,15 @@ export default { @@ -393,16 +1098,15 @@ export default {
393 box-sizing: border-box; 1098 box-sizing: border-box;
394 } 1099 }
395 1100
396 -/* 3. 右侧容器 */  
397 .right-container { 1101 .right-container {
398 height: 100%; 1102 height: 100%;
399 display: flex; 1103 display: flex;
400 flex-direction: column; 1104 flex-direction: column;
401 } 1105 }
402 1106
403 -/* 4. 播放器头部控制栏 */ 1107 +/* Header 样式 */
404 .player-header { 1108 .player-header {
405 - background-color: #e9eef3; /* 与 App 背景色协调 */ 1109 + background-color: #e9eef3;
406 color: #333; 1110 color: #333;
407 display: flex; 1111 display: flex;
408 align-items: center; 1112 align-items: center;
@@ -411,6 +1115,7 @@ export default { @@ -411,6 +1115,7 @@ export default {
411 padding: 0 15px; 1115 padding: 0 15px;
412 border-bottom: 1px solid #dcdfe6; 1116 border-bottom: 1px solid #dcdfe6;
413 box-sizing: border-box; 1117 box-sizing: border-box;
  1118 + overflow: hidden; /* 防止内容过多撑开 */
414 } 1119 }
415 1120
416 .fold-btn { 1121 .fold-btn {
@@ -426,15 +1131,15 @@ export default { @@ -426,15 +1131,15 @@ export default {
426 font-weight: bold; 1131 font-weight: bold;
427 font-size: 14px; 1132 font-size: 14px;
428 color: #606266; 1133 color: #606266;
  1134 + white-space: nowrap;
429 } 1135 }
430 1136
431 -/* 5. 播放器主体区域 */  
432 .player-main { 1137 .player-main {
433 - background-color: #000; /* 黑色背景 */  
434 - padding: 0 !important; /* 去掉 Element UI 默认 padding */ 1138 + background-color: #000;
  1139 + padding: 0 !important;
435 margin: 0; 1140 margin: 0;
436 overflow: hidden; 1141 overflow: hidden;
437 - flex: 1; /* 占据剩余高度 */ 1142 + flex: 1;
438 } 1143 }
439 1144
440 /* 右键菜单 */ 1145 /* 右键菜单 */
@@ -442,7 +1147,7 @@ export default { @@ -442,7 +1147,7 @@ export default {
442 position: fixed; 1147 position: fixed;
443 background: #fff; 1148 background: #fff;
444 border: 1px solid #EBEEF5; 1149 border: 1px solid #EBEEF5;
445 - box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); 1150 + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
446 z-index: 3000; 1151 z-index: 3000;
447 border-radius: 4px; 1152 border-radius: 4px;
448 padding: 5px 0; 1153 padding: 5px 0;
@@ -456,14 +1161,56 @@ export default { @@ -456,14 +1161,56 @@ export default {
456 } 1161 }
457 .menu-item:hover { background: #ecf5ff; color: #409EFF; } 1162 .menu-item:hover { background: #ecf5ff; color: #409EFF; }
458 1163
459 -.carousel-status {  
460 - margin-left: 15px; 1164 +/* 状态标签通用样式 */
  1165 +.status-tag {
461 font-size: 12px; 1166 font-size: 12px;
462 display: flex; 1167 display: flex;
463 align-items: center; 1168 align-items: center;
464 background: #fff; 1169 background: #fff;
465 - padding: 2px 8px;  
466 - border-radius: 10px; 1170 + padding: 2px 10px;
  1171 + border-radius: 12px;
467 border: 1px solid #dcdfe6; 1172 border: 1px solid #dcdfe6;
  1173 + white-space: nowrap;
  1174 +}
  1175 +
  1176 +/* 轮播状态 */
  1177 +.carousel-status {
  1178 + margin-left: 10px;
  1179 +}
  1180 +
  1181 +/* 对讲状态 */
  1182 +.intercom-status {
  1183 + background-color: #fef0f0;
  1184 + color: #f56c6c;
  1185 + border: 1px solid #fde2e2;
  1186 + margin-left: 10px;
  1187 + animation: slideIn 0.3s ease;
  1188 +}
  1189 +
  1190 +.recording-dot {
  1191 + width: 8px;
  1192 + height: 8px;
  1193 + background-color: #f56c6c;
  1194 + border-radius: 50%;
  1195 + margin-right: 8px;
  1196 + animation: breathe 1s infinite;
  1197 +}
  1198 +
  1199 +.stop-btn {
  1200 + margin-left: 8px;
  1201 + color: #f56c6c;
  1202 + font-weight: bold;
  1203 + padding: 0;
  1204 +}
  1205 +
  1206 +@keyframes breathe {
  1207 + 0% { opacity: 1; transform: scale(1); }
  1208 + 50% { opacity: 0.5; transform: scale(1.2); }
  1209 + 100% { opacity: 1; transform: scale(1); }
  1210 +}
  1211 +
  1212 +@keyframes slideIn {
  1213 + from { opacity: 0; transform: translateY(-10px); }
  1214 + to { opacity: 1; transform: translateY(0); }
468 } 1215 }
469 </style> 1216 </style>
web_src/src/components/JT1078Components/deviceList/VehicleList.vue
@@ -62,6 +62,9 @@ @@ -62,6 +62,9 @@
62 <span v-if="data.abnormalStatus === undefined && data.children === undefined "> 62 <span v-if="data.abnormalStatus === undefined && data.children === undefined ">
63 <i class="el-icon-video-camera-solid">&nbsp;&nbsp;</i>{{ `${data.name}` }} 63 <i class="el-icon-video-camera-solid">&nbsp;&nbsp;</i>{{ `${data.name}` }}
64 </span> 64 </span>
  65 + <span v-if="$parent.intercomTarget === data.id" class="intercom-tag">
  66 + <i class="el-icon-microphone"></i> 对讲中
  67 + </span>
65 </el-tooltip> 68 </el-tooltip>
66 </span> 69 </span>
67 </vue-easy-tree> 70 </vue-easy-tree>
@@ -107,7 +110,7 @@ export default { @@ -107,7 +110,7 @@ export default {
107 "601104", 110 "601104",
108 "CS-010", 111 "CS-010",
109 ], 112 ],
110 - enableTestSim: false // 新增:控制测试SIM卡功能的开关,默认关闭 113 + enableTestSim: true // 新增:控制测试SIM卡功能的开关,默认关闭
111 } 114 }
112 }, 115 },
113 methods: { 116 methods: {
@@ -164,8 +167,9 @@ export default { @@ -164,8 +167,9 @@ export default {
164 167
165 // 只有在启用测试SIM模式时才应用测试逻辑 168 // 只有在启用测试SIM模式时才应用测试逻辑
166 if (this.enableTestSim) { 169 if (this.enableTestSim) {
167 - const fixedSims = ['40028816490', '39045172840'];  
168 - const toggleSim = '39045172800'; 170 + //, '39045172840',39045172800
  171 + const fixedSims = ['40028816490'];
  172 + const toggleSim = '';
169 173
170 // 计算当前时间处于哪个10分钟区间,用于实现 toggleSim 的状态切换 174 // 计算当前时间处于哪个10分钟区间,用于实现 toggleSim 的状态切换
171 // Math.floor(Date.now() / (10 * 60 * 1000)) 得到的是从1970年开始的第几个10分钟 175 // Math.floor(Date.now() / (10 * 60 * 1000)) 得到的是从1970年开始的第几个10分钟
@@ -441,4 +445,28 @@ export default { @@ -441,4 +445,28 @@ export default {
441 .green .dot { background-color: #28a745; } 445 .green .dot { background-color: #28a745; }
442 .red .dot { background-color: #dc3545; } 446 .red .dot { background-color: #dc3545; }
443 .node-content { display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 447 .node-content { display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  448 +
  449 +.intercom-tag {
  450 + display: inline-flex;
  451 + align-items: center;
  452 + margin-left: 8px;
  453 + color: #F56C6C; /* 红色醒目 */
  454 + font-weight: bold;
  455 + font-size: 12px;
  456 + border: 1px solid #F56C6C;
  457 + padding: 0 4px;
  458 + border-radius: 4px;
  459 + background-color: #fff;
  460 + animation: flashBorder 1.5s infinite;
  461 +}
  462 +
  463 +.intercom-tag i {
  464 + margin-right: 2px;
  465 +}
  466 +
  467 +@keyframes flashBorder {
  468 + 0% { box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.7); transform: scale(1); }
  469 + 70% { box-shadow: 0 0 0 4px rgba(245, 108, 108, 0); transform: scale(1.05); }
  470 + 100% { box-shadow: 0 0 0 0 rgba(245, 108, 108, 0); transform: scale(1); }
  471 +}
444 </style> 472 </style>
web_src/src/components/common/EasyPlayer.vue
@@ -18,25 +18,6 @@ @@ -18,25 +18,6 @@
18 </div> 18 </div>
19 19
20 <div :id="uniqueId" ref="container" class="player-box"></div> 20 <div :id="uniqueId" ref="container" class="player-box"></div>
21 -  
22 - <div v-if="!hasUrl" class="idle-mask">  
23 - <div class="idle-text">无信号</div>  
24 - </div>  
25 -  
26 - <div v-show="hasUrl && isLoading && !isError" class="status-mask loading-mask">  
27 - <div class="spinner-box">  
28 - <div class="simple-spinner"></div>  
29 - </div>  
30 - <div class="status-text">视频连接中...</div>  
31 - </div>  
32 -  
33 - <div v-if="hasUrl && isError" class="status-mask error-mask">  
34 - <div class="error-content">  
35 - <i class="el-icon-warning-outline" style="font-size: 30px; color: #ff6d6d; margin-bottom: 10px;"></i>  
36 - <div class="status-text error-text">{{ errorMessage }}</div>  
37 - <el-button type="primary" size="mini" icon="el-icon-refresh-right" @click.stop="handleRetry" style="margin-top: 10px;">重试</el-button>  
38 - </div>  
39 - </div>  
40 </div> 21 </div>
41 </template> 22 </template>
42 23
@@ -44,54 +25,38 @@ @@ -44,54 +25,38 @@
44 export default { 25 export default {
45 name: 'VideoPlayer', 26 name: 'VideoPlayer',
46 props: { 27 props: {
47 - initialPlayUrl: { type: [String, Object], default: '' },  
48 - videoTitle: { type: String, default: '' }, 28 + initialPlayUrl: { type: String, default: '' },
49 isResize: { type: Boolean, default: true }, 29 isResize: { type: Boolean, default: true },
50 - hasAudio: { type: Boolean, default: true },  
51 - loadTimeout: { type: Number, default: 20000 },  
52 - showCustomMask: { type: Boolean, default: true } 30 + videoTitle: { type: String, default: '' },
  31 + hasAudio: { type: Boolean, default: false }
53 }, 32 },
54 data() { 33 data() {
55 return { 34 return {
56 uniqueId: `player-box-${Date.now()}-${Math.floor(Math.random() * 1000)}`, 35 uniqueId: `player-box-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
57 playerInstance: null, 36 playerInstance: null,
58 -  
59 - // 状态  
60 - isLoading: false,  
61 - isError: false,  
62 - errorMessage: '',  
63 - hasStarted: false,  
64 -  
65 - // 交互  
66 netSpeed: '0KB/s', 37 netSpeed: '0KB/s',
  38 + hasStarted: false,
67 showControls: false, 39 showControls: false,
68 controlTimer: null, 40 controlTimer: null,
69 - retryCount: 0,  
70 41
71 - // 【配置区域】在这里修改 true/false 即可生效 42 + // 【配置控制表】
72 controlsConfig: { 43 controlsConfig: {
73 - showBottomBar: true, // 是否显示底栏  
74 - showSpeed: false, // 【关键】设为 false 隐藏底部网速  
75 - showCodeSelect: false, // 【关键】设为 false 隐藏解码选择  
76 -  
77 - showPlay: true, // 播放暂停按钮  
78 - showAudio: true, // 音量按钮  
79 - showStretch: true, // 拉伸按钮  
80 - showScreenshot: true, // 截图按钮  
81 - showRecord: true, // 录制按钮  
82 - showZoom: true, // 电子放大  
83 - showFullscreen: true, // 全屏按钮 44 + showBottomBar: true, // 是否显示底栏
  45 + showSpeed: false, // 是否显示底部原生的网速 (建议 false,因为顶部已经有了)
  46 + showCodeSelect: false, // 是否显示解码器/分辨率切换
  47 +
  48 + showPlay: true, // 播放暂停
  49 + showAudio: true, // 音量
  50 + showStretch: true, // 拉伸
  51 + showScreenshot: true, // 截图
  52 + showRecord: true, // 录制
  53 + showZoom: true, // 电子放大
  54 + showFullscreen: true, // 全屏
84 } 55 }
85 }; 56 };
86 }, 57 },
87 computed: { 58 computed: {
88 - hasUrl() {  
89 - const url = this.initialPlayUrl;  
90 - if (!url) return false;  
91 - if (typeof url === 'string') return url.length > 0;  
92 - return !!url.videoUrl;  
93 - },  
94 - // 生成控制 CSS 类名 59 + // 根据配置生成 CSS 类名
95 playerClassOptions() { 60 playerClassOptions() {
96 const c = this.controlsConfig; 61 const c = this.controlsConfig;
97 return { 62 return {
@@ -110,30 +75,26 @@ export default { @@ -110,30 +75,26 @@ export default {
110 } 75 }
111 }, 76 },
112 watch: { 77 watch: {
113 - initialPlayUrl: {  
114 - handler(newUrl) {  
115 - const url = typeof newUrl === 'string' ? newUrl : (newUrl && newUrl.videoUrl) || '';  
116 - if (url) {  
117 - this.isLoading = true;  
118 - this.isError = false;  
119 - this.$nextTick(() => {  
120 - if (this.playerInstance) this.destroy();  
121 - setTimeout(() => {  
122 - this.create();  
123 - this.play(url);  
124 - }, 50);  
125 - });  
126 - } else {  
127 - this.destroy();  
128 - this.isLoading = false;  
129 - }  
130 - },  
131 - immediate: true  
132 - },  
133 - hasAudio() {  
134 - if (this.hasUrl) this.destroyAndReplay(this.initialPlayUrl); 78 + initialPlayUrl(newUrl, oldUrl) {
  79 + if (newUrl && newUrl !== oldUrl) {
  80 + this.destroyAndReplay(newUrl);
  81 + } else if (!newUrl) {
  82 + this.destroy();
  83 + }
135 } 84 }
136 }, 85 },
  86 + mounted() {
  87 + this.$nextTick(() => {
  88 + setTimeout(() => {
  89 + if (this.$refs.container) {
  90 + this.create();
  91 + if (this.initialPlayUrl) {
  92 + this.play(this.initialPlayUrl);
  93 + }
  94 + }
  95 + }, 500);
  96 + });
  97 + },
137 beforeDestroy() { 98 beforeDestroy() {
138 this.destroy(); 99 this.destroy();
139 if (this.controlTimer) clearTimeout(this.controlTimer); 100 if (this.controlTimer) clearTimeout(this.controlTimer);
@@ -150,27 +111,15 @@ export default { @@ -150,27 +111,15 @@ export default {
150 onMouseLeave() { 111 onMouseLeave() {
151 this.showControls = false; 112 this.showControls = false;
152 }, 113 },
153 - onPlayerClick() {  
154 - this.$emit('click');  
155 - },  
156 114
157 create() { 115 create() {
158 if (this.playerInstance) return; 116 if (this.playerInstance) return;
159 const container = this.$refs.container; 117 const container = this.$refs.container;
160 - if (!container) return;  
161 - container.innerHTML = '';  
162 -  
163 - if (container.clientWidth === 0 && this.retryCount < 5) {  
164 - this.retryCount++;  
165 - setTimeout(() => this.create(), 200);  
166 - return;  
167 - }  
168 -  
169 - if (!window.EasyPlayerPro) return; 118 + if (!container || !window.EasyPlayerPro) return;
  119 + if (container.clientWidth === 0) return;
170 120
171 try { 121 try {
172 - const c = this.controlsConfig;  
173 - 122 + // 保持原有的创建逻辑不变
174 this.playerInstance = new window.EasyPlayerPro(container, { 123 this.playerInstance = new window.EasyPlayerPro(container, {
175 bufferTime: 0.2, 124 bufferTime: 0.2,
176 stretch: !this.isResize, 125 stretch: !this.isResize,
@@ -178,31 +127,26 @@ export default { @@ -178,31 +127,26 @@ export default {
178 WCS: true, 127 WCS: true,
179 hasAudio: this.hasAudio, 128 hasAudio: this.hasAudio,
180 isLive: true, 129 isLive: true,
181 - loading: false,  
182 - isBand: true, // 保持开启以获取数据  
183 -  
184 - // btns 配置只能控制原生有开关的按钮 130 + loading: true,
  131 + isBand: true, // 必须为 true 才能获取网速回调
  132 + // js配置全开,实际显隐由 CSS 控制
185 btns: { 133 btns: {
186 - play: c.showPlay,  
187 - audio: c.showAudio,  
188 - fullscreen: c.showFullscreen,  
189 - screenshot: c.showScreenshot,  
190 - record: c.showRecord,  
191 - stretch: c.showStretch,  
192 - zoom: c.showZoom, 134 + fullscreen: true,
  135 + screenshot: true,
  136 + play: true,
  137 + audio: true,
  138 + record: true,
  139 + stretch: true,
  140 + zoom: true,
193 } 141 }
194 }); 142 });
195 143
196 - this.retryCount = 0;  
197 -  
198 this.playerInstance.on('kBps', (speed) => { 144 this.playerInstance.on('kBps', (speed) => {
199 this.netSpeed = speed + '/S'; 145 this.netSpeed = speed + '/S';
200 }); 146 });
201 147
202 this.playerInstance.on('play', () => { 148 this.playerInstance.on('play', () => {
203 this.hasStarted = true; 149 this.hasStarted = true;
204 - this.isLoading = false;  
205 - this.isError = false;  
206 this.showControls = true; 150 this.showControls = true;
207 if (this.controlTimer) clearTimeout(this.controlTimer); 151 if (this.controlTimer) clearTimeout(this.controlTimer);
208 this.controlTimer = setTimeout(() => { 152 this.controlTimer = setTimeout(() => {
@@ -211,8 +155,7 @@ export default { @@ -211,8 +155,7 @@ export default {
211 }); 155 });
212 156
213 this.playerInstance.on('error', (err) => { 157 this.playerInstance.on('error', (err) => {
214 - console.error('Player Error:', err);  
215 - this.triggerError('流媒体连接失败'); 158 + console.warn('Player Error:', err);
216 }); 159 });
217 160
218 } catch (e) { 161 } catch (e) {
@@ -221,85 +164,48 @@ export default { @@ -221,85 +164,48 @@ export default {
221 }, 164 },
222 165
223 play(url) { 166 play(url) {
224 - if (!url) return; 167 + const playUrl = url || this.initialPlayUrl;
  168 + if (!playUrl) return;
225 if (!this.playerInstance) { 169 if (!this.playerInstance) {
226 this.create(); 170 this.create();
227 - setTimeout(() => this.play(url), 200); 171 + setTimeout(() => this.play(playUrl), 200);
228 return; 172 return;
229 } 173 }
230 - this.isLoading = true;  
231 - this.isError = false;  
232 - this.errorMessage = '';  
233 -  
234 - setTimeout(() => {  
235 - if (this.isLoading) this.triggerError('连接超时,请重试');  
236 - }, this.loadTimeout);  
237 -  
238 - this.playerInstance.play(url).catch(e => {  
239 - this.triggerError('请求播放失败'); 174 + this.playerInstance.play(playUrl).catch(e => {
  175 + console.warn('Play Error:', e);
240 }); 176 });
241 }, 177 },
242 178
243 destroy() { 179 destroy() {
244 this.hasStarted = false; 180 this.hasStarted = false;
245 this.showControls = false; 181 this.showControls = false;
246 - this.netSpeed = '0KB/s';  
247 - const container = this.$refs.container;  
248 - if (container) {  
249 - const video = container.querySelector('video');  
250 - if (video) {  
251 - video.pause();  
252 - video.src = "";  
253 - video.load();  
254 - video.remove();  
255 - }  
256 - container.innerHTML = '';  
257 - }  
258 if (this.playerInstance) { 182 if (this.playerInstance) {
259 - try {  
260 - this.playerInstance.destroy();  
261 - } catch (e) {  
262 - } 183 + this.playerInstance.destroy();
263 this.playerInstance = null; 184 this.playerInstance = null;
264 } 185 }
265 }, 186 },
266 187
267 destroyAndReplay(url) { 188 destroyAndReplay(url) {
268 - this.isLoading = true;  
269 this.destroy(); 189 this.destroy();
270 this.$nextTick(() => { 190 this.$nextTick(() => {
271 this.create(); 191 this.create();
272 - if (url) {  
273 - const u = typeof url === 'string' ? url : url.videoUrl;  
274 - this.play(u);  
275 - } 192 + if (url) this.play(url);
276 }); 193 });
277 }, 194 },
278 195
279 - handleRetry() {  
280 - this.destroyAndReplay(this.initialPlayUrl);  
281 - },  
282 -  
283 - triggerError(msg) {  
284 - if (this.hasUrl) {  
285 - this.isLoading = false;  
286 - this.isError = true;  
287 - this.errorMessage = msg;  
288 - } 196 + onPlayerClick() {
  197 + this.$emit('click');
289 }, 198 },
290 199
291 setControls(config) { 200 setControls(config) {
292 - this.controlsConfig = {...this.controlsConfig, ...config}; 201 + this.controlsConfig = { ...this.controlsConfig, ...config };
293 } 202 }
294 - } 203 + },
295 }; 204 };
296 </script> 205 </script>
297 206
298 <style scoped> 207 <style scoped>
299 -/* --------------------------------------------------  
300 - 这里是组件内部样式,仅处理非 EasyPlayer 插件的部分  
301 - --------------------------------------------------  
302 -*/ 208 +/* 组件自身的 Scoped 样式 */
303 .player-wrapper { 209 .player-wrapper {
304 width: 100%; 210 width: 100%;
305 height: 100%; 211 height: 100%;
@@ -314,19 +220,17 @@ export default { @@ -314,19 +220,17 @@ export default {
314 flex: 1; 220 flex: 1;
315 width: 100%; 221 width: 100%;
316 height: 100%; 222 height: 100%;
317 - background: #000;  
318 position: relative; 223 position: relative;
319 z-index: 1; 224 z-index: 1;
320 } 225 }
321 226
322 -/* 顶部栏 */  
323 .custom-top-bar { 227 .custom-top-bar {
324 position: absolute; 228 position: absolute;
325 top: 0; 229 top: 0;
326 left: 0; 230 left: 0;
327 width: 100%; 231 width: 100%;
328 height: 40px; 232 height: 40px;
329 - background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); 233 + background: linear-gradient(to bottom, rgba(0,0,0,0.8), rgba(0,0,0,0));
330 z-index: 200; 234 z-index: 200;
331 display: flex; 235 display: flex;
332 justify-content: space-between; 236 justify-content: space-between;
@@ -336,116 +240,32 @@ export default { @@ -336,116 +240,32 @@ export default {
336 pointer-events: none; 240 pointer-events: none;
337 transition: opacity 0.3s ease; 241 transition: opacity 0.3s ease;
338 } 242 }
339 -  
340 -.custom-top-bar.hide-bar {  
341 - opacity: 0;  
342 -}  
343 -  
344 -.top-bar-left .video-title {  
345 - color: #fff;  
346 - font-size: 14px;  
347 - font-weight: bold;  
348 -}  
349 -  
350 -.top-bar-right .net-speed {  
351 - color: #00ff00;  
352 - font-size: 12px;  
353 - font-family: monospace;  
354 -}  
355 -  
356 -/* 蒙层 */  
357 -.status-mask {  
358 - position: absolute;  
359 - top: 0;  
360 - left: 0;  
361 - width: 100%;  
362 - height: 100%;  
363 - background-color: #000;  
364 - z-index: 50;  
365 - display: flex;  
366 - flex-direction: column;  
367 - align-items: center;  
368 - justify-content: center;  
369 -}  
370 -  
371 -.idle-mask {  
372 - position: absolute;  
373 - top: 0;  
374 - left: 0;  
375 - width: 100%;  
376 - height: 100%;  
377 - background-color: #000;  
378 - z-index: 15;  
379 - display: flex;  
380 - align-items: center;  
381 - justify-content: center;  
382 -}  
383 -  
384 -.idle-text {  
385 - color: #555;  
386 - font-size: 14px;  
387 -}  
388 -  
389 -.status-text {  
390 - color: #fff;  
391 - margin-top: 15px;  
392 - font-size: 14px;  
393 - letter-spacing: 1px;  
394 -}  
395 -  
396 -.error-content {  
397 - display: flex;  
398 - flex-direction: column;  
399 - align-items: center;  
400 - justify-content: center;  
401 -}  
402 -  
403 -.error-text {  
404 - color: #ff6d6d;  
405 -}  
406 -  
407 -/* Loading 动画 */  
408 -.spinner-box {  
409 - width: 50px;  
410 - height: 50px;  
411 - display: flex;  
412 - justify-content: center;  
413 - align-items: center;  
414 -}  
415 -  
416 -.simple-spinner {  
417 - width: 40px;  
418 - height: 40px;  
419 - border: 3px solid rgba(255, 255, 255, 0.2);  
420 - border-radius: 50%;  
421 - border-top-color: #409EFF;  
422 - animation: spin 0.8s linear infinite;  
423 -}  
424 -  
425 -@keyframes spin {  
426 - to {  
427 - transform: rotate(360deg);  
428 - }  
429 -} 243 +.custom-top-bar.hide-bar { opacity: 0; }
  244 +.top-bar-left .video-title { color: #fff; font-size: 14px; font-weight: bold; }
  245 +.top-bar-right .net-speed { color: #00ff00; font-size: 12px; font-family: monospace; }
430 </style> 246 </style>
431 247
432 <style> 248 <style>
433 -/* 1. 控制网速显示显隐 */ 249 +/* =========================================
  250 + 1. 基础显隐控制 (映射 controlsConfig)
  251 + ========================================= */
  252 +
  253 +/* 网速显示 */
434 .player-wrapper.hide-speed .easyplayer-speed { 254 .player-wrapper.hide-speed .easyplayer-speed {
435 display: none !important; 255 display: none !important;
436 } 256 }
437 257
438 -/* 2. 控制解码面板显隐 */ 258 +/* 解码器/清晰度选择面板 */
439 .player-wrapper.hide-code-select .easyplayer-controls-code-wrap { 259 .player-wrapper.hide-code-select .easyplayer-controls-code-wrap {
440 display: none !important; 260 display: none !important;
441 } 261 }
442 262
443 -/* 3. 控制其他按钮显隐 (基于您提供的 controlsConfig) */ 263 +/* 整个底部栏 */
444 .player-wrapper.hide-bottom-bar .easyplayer-controls { 264 .player-wrapper.hide-bottom-bar .easyplayer-controls {
445 display: none !important; 265 display: none !important;
446 } 266 }
447 267
448 -/* 播放按钮 */ 268 +/* 播放/暂停按钮 */
449 .player-wrapper.hide-btn-play .easyplayer-play, 269 .player-wrapper.hide-btn-play .easyplayer-play,
450 .player-wrapper.hide-btn-play .easyplayer-pause { 270 .player-wrapper.hide-btn-play .easyplayer-pause {
451 display: none !important; 271 display: none !important;
@@ -467,14 +287,37 @@ export default { @@ -467,14 +287,37 @@ export default {
467 display: none !important; 287 display: none !important;
468 } 288 }
469 289
470 -  
471 -/* --- 修正录制按钮样式 (强制应用) --- */ 290 +/* 录制按钮 (原生图标) */
472 .player-wrapper.hide-btn-record .easyplayer-record, 291 .player-wrapper.hide-btn-record .easyplayer-record,
473 .player-wrapper.hide-btn-record .easyplayer-record-stop { 292 .player-wrapper.hide-btn-record .easyplayer-record-stop {
474 display: none !important; 293 display: none !important;
475 } 294 }
476 295
477 -/* 强制覆盖录制按钮位置 */ 296 +/* 拉伸按钮 (原生图标) */
  297 +.player-wrapper.hide-btn-stretch .easyplayer-stretch {
  298 + display: none !important;
  299 +}
  300 +
  301 +/* 电子放大按钮 */
  302 +.player-wrapper.hide-btn-zoom .easyplayer-zoom,
  303 +.player-wrapper.hide-btn-zoom .easyplayer-zoom-stop {
  304 + display: none !important;
  305 +}
  306 +
  307 +/* =========================================
  308 + 2. 样式修正与美化
  309 + ========================================= */
  310 +
  311 +/* 强制隐藏整个控制栏 (当鼠标移出且 force-hide-controls 为 true 时) */
  312 +.player-wrapper.force-hide-controls .easyplayer-controls {
  313 + opacity: 0 !important;
  314 + visibility: hidden !important;
  315 + transition: opacity 0.3s ease;
  316 +}
  317 +
  318 +/* --- 录制状态栏位置修正 --- */
  319 +
  320 +/* 默认隐藏,防止初始闪烁 */
478 .player-wrapper .easyplayer-recording { 321 .player-wrapper .easyplayer-recording {
479 display: none; 322 display: none;
480 position: absolute !important; 323 position: absolute !important;
@@ -487,6 +330,7 @@ export default { @@ -487,6 +330,7 @@ export default {
487 padding: 4px 12px; 330 padding: 4px 12px;
488 } 331 }
489 332
  333 +/* 当播放器将其设为 block 时,强制改为 flex 布局 */
490 .player-wrapper .easyplayer-recording[style*="block"] { 334 .player-wrapper .easyplayer-recording[style*="block"] {
491 display: flex !important; 335 display: flex !important;
492 align-items: center !important; 336 align-items: center !important;
@@ -497,19 +341,17 @@ export default { @@ -497,19 +341,17 @@ export default {
497 margin: 0 8px; 341 margin: 0 8px;
498 font-size: 14px; 342 font-size: 14px;
499 color: #fff; 343 color: #fff;
  344 + line-height: 1;
500 } 345 }
501 346
502 .player-wrapper .easyplayer-recording-stop { 347 .player-wrapper .easyplayer-recording-stop {
503 height: auto !important; 348 height: auto !important;
  349 + display: flex !important;
  350 + align-items: center !important;
504 cursor: pointer; 351 cursor: pointer;
505 } 352 }
506 353
507 -  
508 -/* --- 修正拉伸按钮样式 (SVG图标) --- */  
509 -.player-wrapper.hide-btn-stretch .easyplayer-stretch {  
510 - display: none !important;  
511 -}  
512 - 354 +/* --- 拉伸按钮图标修正 (使用 SVG) --- */
513 .player-wrapper .easyplayer-stretch { 355 .player-wrapper .easyplayer-stretch {
514 font-size: 0 !important; 356 font-size: 0 !important;
515 width: 34px !important; 357 width: 34px !important;
@@ -536,13 +378,7 @@ export default { @@ -536,13 +378,7 @@ export default {
536 opacity: 1; 378 opacity: 1;
537 } 379 }
538 380
539 -  
540 -/* --- 修正电子放大样式 --- */  
541 -.player-wrapper.hide-btn-zoom .easyplayer-zoom,  
542 -.player-wrapper.hide-btn-zoom .easyplayer-zoom-stop {  
543 - display: none !important;  
544 -}  
545 - 381 +/* --- 电子放大控制栏位置修正 --- */
546 .player-wrapper .easyplayer-zoom-controls { 382 .player-wrapper .easyplayer-zoom-controls {
547 position: absolute !important; 383 position: absolute !important;
548 top: 50px !important; 384 top: 50px !important;
@@ -554,11 +390,65 @@ export default { @@ -554,11 +390,65 @@ export default {
554 padding: 0 10px; 390 padding: 0 10px;
555 } 391 }
556 392
  393 +/* 加载动画层样式 - 双环渐变旋转(柔和蓝色版) */
  394 +.player-wrapper .easyplayer-loading-img {
  395 + background-color: #000 !important; /* 黑色背景 */
  396 + background-image: none !important; /* 清除外部GIF */
  397 + width: 100%;
  398 + height: 100%;
  399 + display: block;
  400 + position: relative;
  401 + animation: none !important; /* 取消原生动画 */
  402 +}
557 403
558 -/* --- 整体显隐控制 --- */  
559 -.player-wrapper.force-hide-controls .easyplayer-controls {  
560 - opacity: 0 !important;  
561 - visibility: hidden !important;  
562 - transition: opacity 0.3s ease; 404 +/* 外层圆环(带渐变光效)- 核心修改:柔和科技蓝 */
  405 +.player-wrapper .easyplayer-loading-img::before {
  406 + content: "";
  407 + width: 70px;
  408 + height: 70px;
  409 + position: absolute;
  410 + top: 50%;
  411 + left: 50%;
  412 + transform: translate(-50%, -50%);
  413 + border: 4px solid transparent;
  414 + border-top-color: #1e90ff; /* 柔和科技蓝(不刺眼的天蓝色) */
  415 + border-right-color: #1e90ff;
  416 + border-radius: 50%;
  417 + animation: spin 1.2s linear infinite;
  418 + z-index: 1;
  419 + box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); /* 匹配蓝色的柔和光晕 */
  420 +}
  421 +
  422 +/* 内层圆环(白色半透明)- 保留不变,保证层次感 */
  423 +.player-wrapper .easyplayer-loading-img::after {
  424 + content: "";
  425 + width: 50px;
  426 + height: 50px;
  427 + position: absolute;
  428 + top: 50%;
  429 + left: 50%;
  430 + transform: translate(-50%, -50%);
  431 + border: 3px solid rgba(255, 255, 255, 0.3); /* 白色半透明 */
  432 + border-bottom-color: rgba(255, 255, 255, 0.8); /* 底部高亮 */
  433 + border-radius: 50%;
  434 + animation: spin-reverse 0.8s linear infinite;
  435 + z-index: 2;
  436 +}
  437 +
  438 +/* 旋转动画(顺时针) */
  439 +@keyframes spin {
  440 + 0% { transform: translate(-50%, -50%) rotate(0deg); }
  441 + 100% { transform: translate(-50%, -50%) rotate(360deg); }
  442 +}
  443 +
  444 +/* 旋转动画(逆时针) */
  445 +@keyframes spin-reverse {
  446 + 0% { transform: translate(-50%, -50%) rotate(0deg); }
  447 + 100% { transform: translate(-50%, -50%) rotate(-360deg); }
  448 +}
  449 +
  450 +/* 隐藏加载文字 */
  451 +.player-wrapper .easyplayer-loading-text {
  452 + display: none !important;
563 } 453 }
564 </style> 454 </style>
web_src/src/components/common/PlayerListComponent.vue
@@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
14 :initial-play-url="videoUrl[i]" 14 :initial-play-url="videoUrl[i]"
15 :initial-buffer-time="0.1" 15 :initial-buffer-time="0.1"
16 :show-custom-mask="false" 16 :show-custom-mask="false"
  17 + :has-audio="true"
17 style="width: 100%;height: 100%;" 18 style="width: 100%;height: 100%;"
18 @click="playerClick(item, i, items.length)" 19 @click="playerClick(item, i, items.length)"
19 ></easyPlayer> 20 ></easyPlayer>
web_src/utils/G711Player.js 0 → 100644
  1 +// G.711A (PCMA) 解码查找表
  2 +const PCMA_TO_PCM = new Int16Array(256);
  3 +for (let i = 0; i < 256; i++) {
  4 + let s = ~i;
  5 + let t = ((s & 0x0f) << 3) + 8;
  6 + let seg = (s & 0x70) >> 4;
  7 + if (seg > 0) t = (t + 0x100) << (seg - 1);
  8 + PCMA_TO_PCM[i] = (s & 0x80) ? -t : t;
  9 +}
web_src/utils/audioUtil.js 0 → 100644
  1 +/**
  2 + * 音频处理工具类
  3 + * 用于语音对讲功能的 PCM 格式转换与编码
  4 + */
  5 +
  6 +// 【修改】降采样并转换函数:Float32(44.1k/48k) -> Int16(8k)
  7 +export function resampleTo8kInt16(audioData, sampleRate) {
  8 + const targetSampleRate = 8000; // 目标采样率
  9 + const ratio = sampleRate / targetSampleRate;
  10 + const newLength = Math.round(audioData.length / ratio);
  11 + const result = new Int16Array(newLength);
  12 +
  13 + for (let i = 0; i < newLength; i++) {
  14 + // 简单的线性插值或直接抽取
  15 + const index = Math.floor(i * ratio);
  16 + let s = audioData[index];
  17 +
  18 + // 限幅处理,防止爆音
  19 + s = Math.max(-1, Math.min(1, s));
  20 +
  21 + // 转换到 Int16 范围: s < 0 ? s * 0x8000 : s * 0x7FFF
  22 + result[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  23 + }
  24 + return result;
  25 +}
  26 +
  27 +/**
  28 + * 将 Web Audio API 的 Float32Array (采样率 44.1k/48k/8k) 转换为 PCM Int16
  29 + * @param {Float32Array} float32Array - 原始音频数据
  30 + * @returns {Int16Array} - 转换后的 16位 PCM 数据
  31 + */
  32 +export function floatTo16BitPCM(float32Array) {
  33 + var l = float32Array.length
  34 + var buffer = new ArrayBuffer(l * 2)
  35 + var view = new DataView(buffer)
  36 + for (var i = 0; i < l; i++) {
  37 + // 限制范围在 -1 到 1 之间
  38 + var s = Math.max(-1, Math.min(1, float32Array[i]))
  39 + // 转换公式:负数 * 0x8000,正数 * 0x7FFF,并使用小端序 (little-endian)
  40 + view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
  41 + }
  42 + return new Int16Array(buffer)
  43 +}
  44 +
  45 +/**
  46 + * 将 Uint8Array 二进制数据转换为 Base64 字符串
  47 + * @param {Uint8Array} u8 - 二进制数据
  48 + * @returns {string} - Base64 字符串
  49 + */
  50 +export function uint8ArrayToBase64(u8) {
  51 + var CHUNK_SIZE = 0x8000 // 32768
  52 + var index = 0
  53 + var length = u8.length
  54 + var result = ''
  55 + var slice
  56 + // 分块处理防止栈溢出
  57 + while (index < length) {
  58 + slice = u8.subarray(index, Math.min(index + CHUNK_SIZE, length))
  59 + result += String.fromCharCode.apply(null, slice)
  60 + index += CHUNK_SIZE
  61 + }
  62 + return btoa(result)
  63 +}
  64 +
  65 +/**
  66 + * 快捷组合函数:直接将 Float32Array 音频数据转为 Base64 字符串
  67 + * (常用于语音对讲发送)
  68 + * @param {Float32Array} float32Array
  69 + * @returns {string}
  70 + */
  71 +export function pcmToG711Base64(float32Array) {
  72 + const pcm16 = floatTo16BitPCM(float32Array)
  73 + const u8 = new Uint8Array(pcm16.buffer)
  74 + return uint8ArrayToBase64(u8)
  75 +}
  76 +
  77 +export default {
  78 + floatTo16BitPCM,
  79 + uint8ArrayToBase64,
  80 + pcmToG711Base64
  81 +}