Commit c8f9899be0f61351d99df2944c7795bb2e67f07b

Authored by 王鑫
1 parent b73e5e10

fix():ffmpeg命令更改

Showing 47 changed files with 3220 additions and 618 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,131 @@ public class RTMPPublisher extends Thread @@ -32,29 +39,131 @@ 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 + // 低延迟:缩短 HTTP-FLV 探测与缓冲,尽快向 ZLM 输出(仍受设备 GOP/关键帧限制)
  52 + String inputLowLatency =
  53 + "-fflags +nobuffer+discardcorrupt -flags low_delay -probesize 32 -analyzeduration 0";
  54 + String outputLowLatency = "-flush_packets 1";
  55 +
  56 + // 构造命令:只处理视频流和非对讲的音频流
  57 + String cmd = String.format(
  58 + "%s %s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac %s %s %s",
  59 + Configs.get("ffmpeg.path"),
  60 + inputLowLatency,
  61 + Configs.getInt("server.http.port", 3333),
  62 + tag,
  63 + outputLowLatency,
  64 + formatFlag,
  65 + rtmpUrl
  66 + );
  67 +
  68 + logger.info("FFmpeg Push Started. Tag: {}, CMD: {}", tag, cmd);
  69 +
42 process = Runtime.getRuntime().exec(cmd); 70 process = Runtime.getRuntime().exec(cmd);
43 stderr = process.getErrorStream(); 71 stderr = process.getErrorStream();
44 - while ((len = stderr.read(buff)) > -1) 72 + stdout = process.getInputStream();
  73 +
  74 + // 启动一个线程消费 stdout,防止缓冲区满导致进程阻塞
  75 + final InputStream finalStdout = stdout;
  76 + Thread stdoutConsumer = new Thread(() -> {
  77 + try {
  78 + byte[] buffer = new byte[512];
  79 + while (running && finalStdout.read(buffer) > -1) {
  80 + // 只消费,不输出
  81 + }
  82 + } catch (Exception e) {
  83 + // 忽略异常
  84 + }
  85 + }, "FFmpeg-stdout-" + tag);
  86 + stdoutConsumer.setDaemon(true);
  87 + stdoutConsumer.start();
  88 +
  89 + // 消费 stderr 日志流,防止阻塞
  90 + while (running && (len = stderr.read(buff)) > -1)
45 { 91 {
46 - if (debugMode) System.out.print(new String(buff, 0, len)); 92 + if (debugMode) {
  93 + System.out.print(new String(buff, 0, len));
  94 + }
47 } 95 }
48 - logger.info("Process FFMPEG exited..."); 96 +
  97 + // 进程退出处理
  98 + int exitCode = process.waitFor();
  99 + logger.warn("FFmpeg process exited. Code: {}, Tag: {}", exitCode, tag);
  100 + }
  101 + catch(InterruptedException ex)
  102 + {
  103 + logger.info("RTMPPublisher interrupted: {}", tag);
  104 + Thread.currentThread().interrupt();
49 } 105 }
50 catch(Exception ex) 106 catch(Exception ex)
51 { 107 {
52 - logger.error("publish failed", ex); 108 + logger.error("RTMPPublisher Error: " + tag, ex);
  109 + }
  110 + finally
  111 + {
  112 + // 确保所有流都被关闭
  113 + closeQuietly(stderr);
  114 + closeQuietly(stdout);
  115 + if (process != null) {
  116 + closeQuietly(process.getInputStream());
  117 + closeQuietly(process.getOutputStream());
  118 + closeQuietly(process.getErrorStream());
  119 + }
53 } 120 }
54 } 121 }
55 122
56 public void close() 123 public void close()
57 { 124 {
58 - try { if (process != null) process.destroyForcibly(); } catch(Exception e) { } 125 + try {
  126 + // 设置停止标志
  127 + running = false;
  128 +
  129 + if (process != null) {
  130 + // 先尝试正常终止
  131 + process.destroy();
  132 +
  133 + // 等待最多 3 秒
  134 + boolean exited = process.waitFor(3, TimeUnit.SECONDS);
  135 +
  136 + if (!exited) {
  137 + // 如果还没退出,强制终止
  138 + logger.warn("FFmpeg process did not exit gracefully, forcing termination: {}", tag);
  139 + process.destroyForcibly();
  140 + process.waitFor(2, TimeUnit.SECONDS);
  141 + }
  142 +
  143 + logger.info("FFmpeg process terminated: {}", tag);
  144 + }
  145 +
  146 + // 中断线程(如果还在阻塞读取)
  147 + this.interrupt();
  148 +
  149 + // 等待线程结束
  150 + this.join(2000);
  151 +
  152 + } catch(Exception e) {
  153 + logger.error("Error closing RTMPPublisher: " + tag, e);
  154 + }
  155 + }
  156 +
  157 + /**
  158 + * 安全关闭流,忽略异常
  159 + */
  160 + private void closeQuietly(Closeable stream) {
  161 + if (stream != null) {
  162 + try {
  163 + stream.close();
  164 + } catch (Exception e) {
  165 + // 忽略
  166 + }
  167 + }
59 } 168 }
60 } 169 }
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 = 40001
4 server.backlog = 1024 4 server.backlog = 1024
5 5
6 # ffmpeg可执行文件路径,可以留空 6 # ffmpeg可执行文件路径,可以留空
@@ -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-wx-local.yml
1 my: 1 my:
2 - ip: 127.0.0.1 2 + ip: 192.168.168.21
3 spring: 3 spring:
4 rabbitmq: 4 rabbitmq:
5 host: 10.10.2.21 5 host: 10.10.2.21
@@ -28,7 +28,7 @@ spring: @@ -28,7 +28,7 @@ spring:
28 # [必须修改] 端口号 28 # [必须修改] 端口号
29 port: 6380 29 port: 6380
30 # [可选] 数据库 DB 30 # [可选] 数据库 DB
31 - database: 9 31 + database: 15
32 # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接 32 # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
33 # password: guzijian 33 # password: guzijian
34 # [可选] 超时时间 34 # [可选] 超时时间
@@ -67,8 +67,6 @@ server: @@ -67,8 +67,6 @@ server:
67 key-store-type: JKS 67 key-store-type: JKS
68 68
69 # 作为28181服务器的配置 69 # 作为28181服务器的配置
70 -# 作为28181服务器的配置  
71 -# 作为28181服务器的配置  
72 sip: 70 sip:
73 # [必须修改] 本机的IP,对应你的网卡,监听什么ip就是使用什么网卡, 71 # [必须修改] 本机的IP,对应你的网卡,监听什么ip就是使用什么网卡,
74 # 如果要监听多张网卡,可以使用逗号分隔多个IP, 例如: 192.168.1.4,10.0.0.4 72 # 如果要监听多张网卡,可以使用逗号分隔多个IP, 例如: 192.168.1.4,10.0.0.4
@@ -119,34 +117,6 @@ media: @@ -119,34 +117,6 @@ media:
119 send-port-range: 30000,35000 # 端口范围 117 send-port-range: 30000,35000 # 端口范围
120 # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用 118 # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用
121 record-assist-port: 18081 119 record-assist-port: 18081
122 -#zlm 默认服务器配置  
123 -#media:  
124 -# id: guzijian  
125 -# # [必须修改] zlm服务器的内网IP  
126 -# ip: 10.10.2.22  
127 -# # [必须修改] zlm服务器的http.port  
128 -# http-port: 1090  
129 -# # [可选] 返回流地址时的ip,置空使用 media.ip 1  
130 -# stream-ip: 118.113.164.50  
131 -# # [可选] wvp在国标信令中使用的ip,此ip为摄像机可以访问到的ip, 置空使用 media.ip 1  
132 -# sdp-ip: 118.113.164.50  
133 -# # [可选] zlm服务器的hook所使用的IP, 默认使用sip.ip  
134 -# hook-ip: 10.10.2.22  
135 -# # [可选] zlm服务器的http.sslport, 置空使用zlm配置文件配置  
136 -# http-ssl-port: 8443  
137 -# # [可选] zlm服务器的hook.admin_params=secret  
138 -# secret: RPorcBlIw26uHGnEHYGesIYyFDXpgjkP  
139 -# pushKey: 41db35390ddad33f83944f44b8b75ded  
140 -# # 启用多端口模式, 多端口模式使用端口区分每路流,兼容性更好。 单端口使用流的ssrc区分, 点播超时建议使用多端口测试  
141 -# rtp:  
142 -# # [可选] 是否启用多端口模式, 开启后会在portRange范围内选择端口用于媒体流传输  
143 -# enable: true  
144 -# # [可选] 在此范围内选择端口用于媒体流传输, 必须提前在zlm上配置该属性,不然自动配置此属性可能不成功  
145 -# port-range: 7000,7500 # 端口范围  
146 -# # [可选] 国标级联在此范围内选择端口发送媒体流,  
147 -# send-port-range: 7000,7500 # 端口范围  
148 -# # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用  
149 -# record-assist-port: 18081  
150 # [根据业务需求配置] 120 # [根据业务需求配置]
151 user-settings: 121 user-settings:
152 # 点播/录像回放 等待超时时间,单位:毫秒 122 # 点播/录像回放 等待超时时间,单位:毫秒
@@ -187,7 +157,8 @@ tuohua: @@ -187,7 +157,8 @@ tuohua:
187 rest: 157 rest:
188 # baseURL: http://10.10.2.20:9089/webservice/rest 158 # baseURL: http://10.10.2.20:9089/webservice/rest
189 # password: bafb2b44a07a02e5e9912f42cd197423884116a8 159 # password: bafb2b44a07a02e5e9912f42cd197423884116a8
190 - baseURL: http://113.249.109.139:9089/webservice/rest 160 +# baseURL: http://113.249.109.139:9089/webservice/rest
  161 + baseURL: http://192.168.168.152:9089/webservice/rest
191 password: bafb2b44a07a02e5e9912f42cd197423884116a8 162 password: bafb2b44a07a02e5e9912f42cd197423884116a8
192 tree: 163 tree:
193 url: 164 url:
@@ -202,16 +173,19 @@ tuohua: @@ -202,16 +173,19 @@ tuohua:
202 historyUdpPort: 9999 173 historyUdpPort: 9999
203 ip : 61.169.120.202 174 ip : 61.169.120.202
204 jt1078: 175 jt1078:
205 - ports: 30001,30001  
206 - port: 30000 176 + ws-prefix: ws://192.168.1.117:18090
  177 + ports: 40001,40001
  178 + port: 40000
207 httpPort: 3333 179 httpPort: 3333
208 addPortVal: 0 180 addPortVal: 0
209 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort} 181 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort}
210 stopPushURL: http://${my.ip}:3333/stop/channel/{pushKey}/{port}/{httpPort} 182 stopPushURL: http://${my.ip}:3333/stop/channel/{pushKey}/{port}/{httpPort}
211 # url: http://10.10.2.20:8100/device/{0} 183 # url: http://10.10.2.20:8100/device/{0}
212 # new_url: http://10.10.2.20:8100/device 184 # 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 185 +# url: http://113.249.109.139:8100/device/{0}
  186 +# new_url: http://113.249.109.139:8100/device
  187 + url: http://192.168.168.152:8100/device/{0}
  188 + new_url: http://192.168.168.152:8100/device
215 historyListPort: 9205 189 historyListPort: 9205
216 history_upload: 9206 190 history_upload: 9206
217 playHistoryPort: 9201 191 playHistoryPort: 9201
@@ -227,6 +201,7 @@ tuohua: @@ -227,6 +201,7 @@ tuohua:
227 201
228 ftp: 202 ftp:
229 basePath: /wvp-local 203 basePath: /wvp-local
  204 + within_host: 61.169.120.202
230 host: 61.169.120.202 205 host: 61.169.120.202
231 httpPath: ftp://61.169.120.202 206 httpPath: ftp://61.169.120.202
232 filePathPrefix: http://61.169.120.202:10021/wvp-local 207 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,27 +219,23 @@ export default { @@ -145,27 +219,23 @@ 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 - // 触发 resize 事件让播放器自适应  
152 setTimeout(() => { 227 setTimeout(() => {
153 const event = new Event('resize'); 228 const event = new Event('resize');
154 window.dispatchEvent(event); 229 window.dispatchEvent(event);
155 }, 310); 230 }, 310);
156 }, 231 },
157 -  
158 - // --- 数据同步 ---  
159 handleTreeLoaded(data) { 232 handleTreeLoaded(data) {
160 this.deviceTreeData = data; 233 this.deviceTreeData = data;
161 }, 234 },
162 -  
163 - // --- 播放器交互 ---  
164 handleClick(data, index) { 235 handleClick(data, index) {
165 this.windowClickIndex = index + 1; 236 this.windowClickIndex = index + 1;
166 this.windowClickData = data; 237 this.windowClickData = data;
167 }, 238 },
168 -  
169 toggleFullscreen() { 239 toggleFullscreen() {
170 const element = this.$refs.videoMain.$el; 240 const element = this.$refs.videoMain.$el;
171 if (!this.isFullscreen) { 241 if (!this.isFullscreen) {
@@ -180,42 +250,564 @@ export default { @@ -180,42 +250,564 @@ export default {
180 this.isFullscreen = !!document.fullscreenElement; 250 this.isFullscreen = !!document.fullscreenElement;
181 }, 251 },
182 252
183 - // ==========================================  
184 - // 1. 左键点击播放逻辑  
185 - // ========================================== 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 + // ===========================
186 nodeClick(data, node) { 788 nodeClick(data, node) {
187 if (this.isCarouselRunning) { 789 if (this.isCarouselRunning) {
188 this.$message.warning("请先停止轮播再手动播放"); 790 this.$message.warning("请先停止轮播再手动播放");
189 return; 791 return;
190 } 792 }
191 - // 判断是否为叶子节点(通道)  
192 if (!data.children || data.children.length === 0) { 793 if (!data.children || data.children.length === 0) {
193 this.playSingleChannel(data); 794 this.playSingleChannel(data);
194 } 795 }
195 }, 796 },
196 797
197 playSingleChannel(data) { 798 playSingleChannel(data) {
198 - // data.code 格式假设为 "deviceId_sim_channel"  
199 - let stream = data.code.replace('-', '_'); // 容错处理 799 + let stream = data.code.replace('-', '_');
200 let arr = stream.split("_"); 800 let arr = stream.split("_");
201 -  
202 - // 容错: 确保能解析出 sim 和 channel  
203 if (arr.length < 3) { 801 if (arr.length < 3) {
204 console.warn("Invalid channel code:", data.code); 802 console.warn("Invalid channel code:", data.code);
205 return; 803 return;
206 } 804 }
207 -  
208 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 => {
209 if (res.data.code === 0 || res.data.code === 200) { 806 if (res.data.code === 0 || res.data.code === 200) {
210 - // 兼容 http/https 协议  
211 const url = (location.protocol === "https:") ? res.data.data.wss_flv : res.data.data.ws_flv; 807 const url = (location.protocol === "https:") ? res.data.data.wss_flv : res.data.data.ws_flv;
212 const idx = this.windowClickIndex - 1; 808 const idx = this.windowClickIndex - 1;
213 -  
214 - // 使用 $set 确保数组响应式更新  
215 this.$set(this.videoUrl, idx, url); 809 this.$set(this.videoUrl, idx, url);
216 this.$set(this.videoDataList, idx, {...data, videoUrl: url}); 810 this.$set(this.videoDataList, idx, {...data, videoUrl: url});
217 -  
218 - // 自动跳到下一个窗口  
219 const maxWindow = parseInt(this.windowNum) || 4; 811 const maxWindow = parseInt(this.windowNum) || 4;
220 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1; 812 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1;
221 } else { 813 } else {
@@ -226,87 +818,57 @@ export default { @@ -226,87 +818,57 @@ export default {
226 }); 818 });
227 }, 819 },
228 820
229 - // ==========================================  
230 - // 2. 右键菜单逻辑  
231 - // ==========================================  
232 - nodeContextmenu(event, data, node) {  
233 - // 只有父设备节点(有子级)才显示菜单  
234 - if (data.children && data.children.length > 0) {  
235 - this.rightClickNode = node;  
236 - this.contextMenuVisible = true;  
237 - this.contextMenuLeft = event.clientX;  
238 - this.contextMenuTop = event.clientY;  
239 - }  
240 - },  
241 -  
242 - hideContextMenu() {  
243 - this.contextMenuVisible = false;  
244 - },  
245 -  
246 - handleContextCommand(command) {  
247 - this.hideContextMenu();  
248 - if (command === 'playback') {  
249 - if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作");  
250 -  
251 - const channels = this.rightClickNode.data.children;  
252 - if (channels && channels.length > 0) {  
253 - // 1. 清屏  
254 - this.videoUrl = [];  
255 - this.videoDataList = [];  
256 -  
257 - // 2. 自动切换分屏布局  
258 - if (channels.length > 16) this.windowNum = '25';  
259 - else if (channels.length > 9) this.windowNum = '16';  
260 - else if (channels.length > 4) this.windowNum = '9';  
261 - else this.windowNum = '4';  
262 -  
263 - // 3. 构造批量请求参数  
264 - const ids = channels.map(c => {  
265 - // 假设 code 是 id_sim_channel,后端需要 sim-channel  
266 - const parts = c.code.replaceAll('_', '-').split('-');  
267 - return parts.slice(1).join('-');  
268 - }); 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';
  831 +
  832 + const ids = channels.map(c => {
  833 + const parts = c.code.replaceAll('_', '-').split('-');
  834 + return parts.slice(1).join('-');
  835 + });
269 836
270 - this.$axios.post('/api/jt1078/query/beachSend/request/io', ids).then(res => {  
271 - if (res.data && res.data.data) {  
272 - const list = res.data.data || [];  
273 - list.forEach((item, i) => {  
274 - if (channels[i]) {  
275 - const url = (location.protocol === "https:") ? item.wss_flv : item.ws_flv;  
276 - this.$set(this.videoUrl, i, url);  
277 - this.$set(this.videoDataList, i, {...channels[i], videoUrl: url});  
278 - }  
279 - });  
280 - } else {  
281 - this.$message.warning("批量获取流地址为空");  
282 - }  
283 - }).catch(e => {  
284 - this.$message.error("批量播放失败");  
285 - });  
286 - } else {  
287 - this.$message.warning("该设备下没有通道");  
288 - } 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("该设备下没有通道");
289 } 854 }
290 }, 855 },
291 856
292 - // ==========================================  
293 - // 3. 视频关闭逻辑  
294 - // ========================================== 857 + // ===========================
  858 + // 4. 关闭视频逻辑
  859 + // ===========================
295 async checkCarouselPermission(actionName) { 860 async checkCarouselPermission(actionName) {
296 if (!this.isCarouselRunning) return true; 861 if (!this.isCarouselRunning) return true;
297 try { 862 try {
298 await this.$confirm(`正在轮播,"${actionName}"将停止轮播,是否继续?`, '提示', {type: 'warning'}); 863 await this.$confirm(`正在轮播,"${actionName}"将停止轮播,是否继续?`, '提示', {type: 'warning'});
299 this.stopCarousel(); 864 this.stopCarousel();
300 return true; 865 return true;
301 - } catch (e) {  
302 - return false;  
303 - } 866 + } catch (e) { return false; }
304 }, 867 },
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() {
@@ -317,14 +879,18 @@ export default { @@ -317,14 +879,18 @@ export default {
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);
320 - }).catch(() => {  
321 - }); 882 + }).catch(()=>{});
322 } 883 }
323 }, 884 },
324 885
325 - // ==========================================  
326 - // 4. 轮播逻辑 (补全部分)  
327 - // ========================================== 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 + // ===========================
328 openCarouselConfig() { 894 openCarouselConfig() {
329 if (this.isCarouselRunning) { 895 if (this.isCarouselRunning) {
330 this.$confirm('确定要停止轮播吗?', '提示').then(() => this.stopCarousel()); 896 this.$confirm('确定要停止轮播吗?', '提示').then(() => this.stopCarousel());
@@ -344,7 +910,6 @@ export default { @@ -344,7 +910,6 @@ export default {
344 910
345 async startCarousel(config) { 911 async startCarousel(config) {
346 this.carouselConfig = config; 912 this.carouselConfig = config;
347 -  
348 // 1. 筛选目标设备 913 // 1. 筛选目标设备
349 let targetNodes = []; 914 let targetNodes = [];
350 if (config.sourceType === 'all_online') { 915 if (config.sourceType === 'all_online') {
@@ -372,8 +937,6 @@ export default { @@ -372,8 +937,6 @@ export default {
372 this.isWithinSchedule = true; 937 this.isWithinSchedule = true;
373 938
374 this.$message.success(`轮播已启动,共 ${targetNodes.length} 台在线设备`); 939 this.$message.success(`轮播已启动,共 ${targetNodes.length} 台在线设备`);
375 -  
376 - // 3. 立即执行第一轮  
377 await this.executeFirstRound(); 940 await this.executeFirstRound();
378 }, 941 },
379 942
@@ -381,7 +944,6 @@ export default { @@ -381,7 +944,6 @@ export default {
381 if (this.windowNum !== this.carouselConfig.layout) { 944 if (this.windowNum !== this.carouselConfig.layout) {
382 this.windowNum = this.carouselConfig.layout; 945 this.windowNum = this.carouselConfig.layout;
383 } 946 }
384 -  
385 const batch = await this.fetchNextBatchData(); 947 const batch = await this.fetchNextBatchData();
386 if (batch) { 948 if (batch) {
387 this.applyVideoBatch(batch); 949 this.applyVideoBatch(batch);
@@ -394,7 +956,6 @@ export default { @@ -394,7 +956,6 @@ export default {
394 956
395 runCarouselLoop() { 957 runCarouselLoop() {
396 if (!this.isCarouselRunning) return; 958 if (!this.isCarouselRunning) return;
397 -  
398 const {runMode, timeRange, interval} = this.carouselConfig; 959 const {runMode, timeRange, interval} = this.carouselConfig;
399 960
400 // 1. 检查定时 961 // 1. 检查定时
@@ -416,7 +977,6 @@ export default { @@ -416,7 +977,6 @@ export default {
416 // 3. 计时循环 977 // 3. 计时循环
417 this.carouselTimer = setTimeout(async () => { 978 this.carouselTimer = setTimeout(async () => {
418 if (!this.isCarouselRunning) return; 979 if (!this.isCarouselRunning) return;
419 -  
420 const nextBatch = await this.fetchNextBatchData(); 980 const nextBatch = await this.fetchNextBatchData();
421 981
422 this.carouselTimer = setTimeout(() => { 982 this.carouselTimer = setTimeout(() => {
@@ -443,14 +1003,10 @@ export default { @@ -443,14 +1003,10 @@ export default {
443 async fetchNextBatchData() { 1003 async fetchNextBatchData() {
444 let pageSize = parseInt(this.windowNum) || 4; 1004 let pageSize = parseInt(this.windowNum) || 4;
445 if (isNaN(pageSize)) pageSize = 4; 1005 if (isNaN(pageSize)) pageSize = 4;
446 -  
447 - // 填充缓冲区  
448 let safetyCounter = 0; 1006 let safetyCounter = 0;
449 while (this.channelBuffer.length < pageSize && safetyCounter < 100) { 1007 while (this.channelBuffer.length < pageSize && safetyCounter < 100) {
450 safetyCounter++; 1008 safetyCounter++;
451 - if (this.deviceCursor >= this.carouselDeviceList.length) {  
452 - this.deviceCursor = 0;  
453 - } 1009 + if (this.deviceCursor >= this.carouselDeviceList.length) this.deviceCursor = 0;
454 1010
455 const device = this.carouselDeviceList[this.deviceCursor]; 1011 const device = this.carouselDeviceList[this.deviceCursor];
456 if (device && device.children && device.children.length > 0) { 1012 if (device && device.children && device.children.length > 0) {
@@ -507,43 +1063,34 @@ export default { @@ -507,43 +1063,34 @@ export default {
507 return current >= start && current <= end; 1063 return current >= start && current <= end;
508 }, 1064 },
509 1065
510 - closeAllVideoNoConfirm() {  
511 - this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);  
512 - this.videoDataList = new Array(parseInt(this.windowNum)).fill(null);  
513 - },  
514 -  
515 handleBeforeUnload(e) { 1066 handleBeforeUnload(e) {
516 if (this.isCarouselRunning) { 1067 if (this.isCarouselRunning) {
517 e.preventDefault(); 1068 e.preventDefault();
518 e.returnValue = ''; 1069 e.returnValue = '';
519 } 1070 }
  1071 + this.stopIntercom();
520 }, 1072 },
521 } 1073 }
522 }; 1074 };
523 </script> 1075 </script>
524 1076
525 <style scoped> 1077 <style scoped>
526 -/* 1. 容器高度设为 100%,由 App.vue 的 el-main 决定高度  
527 - 这样就不会出现双重滚动条,Header 也不会被遮挡  
528 -*/  
529 .live-container { 1078 .live-container {
530 height: 100%; 1079 height: 100%;
531 width: 100%; 1080 width: 100%;
532 - overflow: hidden; /* 防止内部溢出 */ 1081 + overflow: hidden;
533 } 1082 }
534 1083
535 -/* 2. 侧边栏样式优化 */  
536 .el-aside { 1084 .el-aside {
537 background-color: #fff; 1085 background-color: #fff;
538 color: #333; 1086 color: #333;
539 text-align: center; 1087 text-align: center;
540 height: 100%; 1088 height: 100%;
541 - overflow: hidden; /* 隐藏收起时的内容 */ 1089 + overflow: hidden;
542 border-right: 1px solid #dcdfe6; 1090 border-right: 1px solid #dcdfe6;
543 - transition: width 0.3s ease-in-out; /* 添加平滑过渡动画 */ 1091 + transition: width 0.3s ease-in-out;
544 } 1092 }
545 1093
546 -/* 侧边栏内容包裹层,保持宽度固定,防止文字挤压 */  
547 .sidebar-content { 1094 .sidebar-content {
548 width: 280px; 1095 width: 280px;
549 height: 100%; 1096 height: 100%;
@@ -551,16 +1098,15 @@ export default { @@ -551,16 +1098,15 @@ export default {
551 box-sizing: border-box; 1098 box-sizing: border-box;
552 } 1099 }
553 1100
554 -/* 3. 右侧容器 */  
555 .right-container { 1101 .right-container {
556 height: 100%; 1102 height: 100%;
557 display: flex; 1103 display: flex;
558 flex-direction: column; 1104 flex-direction: column;
559 } 1105 }
560 1106
561 -/* 4. 播放器头部控制栏 */ 1107 +/* Header 样式 */
562 .player-header { 1108 .player-header {
563 - background-color: #e9eef3; /* 与 App 背景色协调 */ 1109 + background-color: #e9eef3;
564 color: #333; 1110 color: #333;
565 display: flex; 1111 display: flex;
566 align-items: center; 1112 align-items: center;
@@ -569,6 +1115,7 @@ export default { @@ -569,6 +1115,7 @@ export default {
569 padding: 0 15px; 1115 padding: 0 15px;
570 border-bottom: 1px solid #dcdfe6; 1116 border-bottom: 1px solid #dcdfe6;
571 box-sizing: border-box; 1117 box-sizing: border-box;
  1118 + overflow: hidden; /* 防止内容过多撑开 */
572 } 1119 }
573 1120
574 .fold-btn { 1121 .fold-btn {
@@ -577,25 +1124,22 @@ export default { @@ -577,25 +1124,22 @@ export default {
577 cursor: pointer; 1124 cursor: pointer;
578 color: #606266; 1125 color: #606266;
579 } 1126 }
580 -  
581 -.fold-btn:hover {  
582 - color: #409EFF;  
583 -} 1127 +.fold-btn:hover { color: #409EFF; }
584 1128
585 .header-right-info { 1129 .header-right-info {
586 margin-left: auto; 1130 margin-left: auto;
587 font-weight: bold; 1131 font-weight: bold;
588 font-size: 14px; 1132 font-size: 14px;
589 color: #606266; 1133 color: #606266;
  1134 + white-space: nowrap;
590 } 1135 }
591 1136
592 -/* 5. 播放器主体区域 */  
593 .player-main { 1137 .player-main {
594 - background-color: #000; /* 黑色背景 */  
595 - padding: 0 !important; /* 去掉 Element UI 默认 padding */ 1138 + background-color: #000;
  1139 + padding: 0 !important;
596 margin: 0; 1140 margin: 0;
597 overflow: hidden; 1141 overflow: hidden;
598 - flex: 1; /* 占据剩余高度 */ 1142 + flex: 1;
599 } 1143 }
600 1144
601 /* 右键菜单 */ 1145 /* 右键菜单 */
@@ -609,27 +1153,64 @@ export default { @@ -609,27 +1153,64 @@ export default {
609 padding: 5px 0; 1153 padding: 5px 0;
610 min-width: 120px; 1154 min-width: 120px;
611 } 1155 }
612 -  
613 .menu-item { 1156 .menu-item {
614 padding: 8px 15px; 1157 padding: 8px 15px;
615 font-size: 14px; 1158 font-size: 14px;
616 color: #606266; 1159 color: #606266;
617 cursor: pointer; 1160 cursor: pointer;
618 } 1161 }
  1162 +.menu-item:hover { background: #ecf5ff; color: #409EFF; }
619 1163
620 -.menu-item:hover {  
621 - background: #ecf5ff;  
622 - color: #409EFF;  
623 -}  
624 -  
625 -.carousel-status {  
626 - margin-left: 15px; 1164 +/* 状态标签通用样式 */
  1165 +.status-tag {
627 font-size: 12px; 1166 font-size: 12px;
628 display: flex; 1167 display: flex;
629 align-items: center; 1168 align-items: center;
630 background: #fff; 1169 background: #fff;
631 - padding: 2px 8px;  
632 - border-radius: 10px; 1170 + padding: 2px 10px;
  1171 + border-radius: 12px;
633 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); }
634 } 1215 }
635 </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
@@ -17,26 +17,7 @@ @@ -17,26 +17,7 @@
17 </div> 17 </div>
18 </div> 18 </div>
19 19
20 - <div :id="uniqueId" :key="uniqueId" ref="container" class="player-box"></div>  
21 -  
22 - <div v-show="!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> 20 + <div :id="uniqueId" ref="container" class="player-box"></div>
40 </div> 21 </div>
41 </template> 22 </template>
42 23
@@ -44,58 +25,45 @@ @@ -44,58 +25,45 @@
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 - // 初始 ID  
57 uniqueId: `player-box-${Date.now()}-${Math.floor(Math.random() * 1000)}`, 35 uniqueId: `player-box-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
58 playerInstance: null, 36 playerInstance: null,
59 -  
60 - // 状态  
61 - isLoading: false,  
62 - isError: false,  
63 - errorMessage: '',  
64 - hasStarted: false,  
65 -  
66 - // 交互  
67 netSpeed: '0KB/s', 37 netSpeed: '0KB/s',
  38 + hasStarted: false,
68 showControls: false, 39 showControls: false,
69 controlTimer: null, 40 controlTimer: null,
70 - retryCount: 0,  
71 41
  42 + // 【配置控制表】
72 controlsConfig: { 43 controlsConfig: {
73 - showBottomBar: true,  
74 - showSpeed: false,  
75 - showCodeSelect: false,  
76 - showPlay: true,  
77 - showAudio: true,  
78 - showStretch: true,  
79 - showScreenshot: true,  
80 - showRecord: true,  
81 - showZoom: true,  
82 - 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, // 全屏
83 } 55 }
84 }; 56 };
85 }, 57 },
86 computed: { 58 computed: {
87 - hasUrl() {  
88 - const url = this.initialPlayUrl;  
89 - if (!url) return false;  
90 - if (typeof url === 'string') return url.length > 0;  
91 - return !!url.videoUrl;  
92 - }, 59 + // 根据配置生成 CSS 类名
93 playerClassOptions() { 60 playerClassOptions() {
94 const c = this.controlsConfig; 61 const c = this.controlsConfig;
95 return { 62 return {
96 'hide-bottom-bar': !c.showBottomBar, 63 'hide-bottom-bar': !c.showBottomBar,
97 - 'hide-speed': !c.showSpeed,  
98 - 'hide-code-select': !c.showCodeSelect, 64 + 'hide-speed': !c.showSpeed, // 对应下方 CSS
  65 + 'hide-code-select': !c.showCodeSelect, // 对应下方 CSS
  66 +
99 'hide-btn-play': !c.showPlay, 67 'hide-btn-play': !c.showPlay,
100 'hide-btn-audio': !c.showAudio, 68 'hide-btn-audio': !c.showAudio,
101 'hide-btn-stretch': !c.showStretch, 69 'hide-btn-stretch': !c.showStretch,
@@ -107,40 +75,26 @@ export default { @@ -107,40 +75,26 @@ export default {
107 } 75 }
108 }, 76 },
109 watch: { 77 watch: {
110 - initialPlayUrl: {  
111 - handler(newUrl) {  
112 - const url = typeof newUrl === 'string' ? newUrl : (newUrl && newUrl.videoUrl) || '';  
113 -  
114 - if (url) {  
115 - // 有地址:准备播放  
116 - this.isLoading = true;  
117 - this.isError = false;  
118 -  
119 - if (this.playerInstance) {  
120 - // 实例已存在(比如轮播切换下一路),直接切换地址,不要销毁  
121 - this.play(url);  
122 - } else {  
123 - // 实例不存在(比如从全部关闭状态恢复),创建并播放  
124 - this.$nextTick(() => {  
125 - this.create();  
126 - setTimeout(() => this.play(url), 100);  
127 - });  
128 - }  
129 - } else {  
130 - // 地址为空(全部关闭):彻底销毁  
131 - this.destroy();  
132 - this.isLoading = false;  
133 - this.isError = false;  
134 - }  
135 - },  
136 - immediate: true  
137 - },  
138 - hasAudio() {  
139 - if (this.hasUrl && this.playerInstance) {  
140 - this.destroyAndReplay(this.initialPlayUrl); 78 + initialPlayUrl(newUrl, oldUrl) {
  79 + if (newUrl && newUrl !== oldUrl) {
  80 + this.destroyAndReplay(newUrl);
  81 + } else if (!newUrl) {
  82 + this.destroy();
141 } 83 }
142 } 84 }
143 }, 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 + },
144 beforeDestroy() { 98 beforeDestroy() {
145 this.destroy(); 99 this.destroy();
146 if (this.controlTimer) clearTimeout(this.controlTimer); 100 if (this.controlTimer) clearTimeout(this.controlTimer);
@@ -157,31 +111,15 @@ export default { @@ -157,31 +111,15 @@ export default {
157 onMouseLeave() { 111 onMouseLeave() {
158 this.showControls = false; 112 this.showControls = false;
159 }, 113 },
160 - onPlayerClick() {  
161 - this.$emit('click');  
162 - },  
163 114
164 create() { 115 create() {
165 if (this.playerInstance) return; 116 if (this.playerInstance) return;
166 -  
167 const container = this.$refs.container; 117 const container = this.$refs.container;
168 - // 容错:如果容器不存在,或者刚才被销毁还没渲染出来,延迟重试  
169 - if (!container || container.clientWidth === 0) {  
170 - if (this.retryCount < 10) { // 增加重试次数  
171 - this.retryCount++;  
172 - setTimeout(() => this.create(), 100);  
173 - }  
174 - return;  
175 - }  
176 -  
177 - // 再次确保清空内容  
178 - container.innerHTML = '';  
179 -  
180 - if (!window.EasyPlayerPro) return; 118 + if (!container || !window.EasyPlayerPro) return;
  119 + if (container.clientWidth === 0) return;
181 120
182 try { 121 try {
183 - const c = this.controlsConfig;  
184 - 122 + // 保持原有的创建逻辑不变
185 this.playerInstance = new window.EasyPlayerPro(container, { 123 this.playerInstance = new window.EasyPlayerPro(container, {
186 bufferTime: 0.2, 124 bufferTime: 0.2,
187 stretch: !this.isResize, 125 stretch: !this.isResize,
@@ -189,31 +127,26 @@ export default { @@ -189,31 +127,26 @@ export default {
189 WCS: true, 127 WCS: true,
190 hasAudio: this.hasAudio, 128 hasAudio: this.hasAudio,
191 isLive: true, 129 isLive: true,
192 - loading: false, // 使用我们的自定义 loading  
193 - isBand: true, 130 + loading: true,
  131 + isBand: true, // 必须为 true 才能获取网速回调
  132 + // js配置全开,实际显隐由 CSS 控制
194 btns: { 133 btns: {
195 - play: c.showPlay,  
196 - audio: c.showAudio,  
197 - fullscreen: c.showFullscreen,  
198 - screenshot: c.showScreenshot,  
199 - record: c.showRecord,  
200 - stretch: c.showStretch,  
201 - zoom: c.showZoom, 134 + fullscreen: true,
  135 + screenshot: true,
  136 + play: true,
  137 + audio: true,
  138 + record: true,
  139 + stretch: true,
  140 + zoom: true,
202 } 141 }
203 }); 142 });
204 143
205 - this.retryCount = 0;  
206 -  
207 this.playerInstance.on('kBps', (speed) => { 144 this.playerInstance.on('kBps', (speed) => {
208 this.netSpeed = speed + '/S'; 145 this.netSpeed = speed + '/S';
209 }); 146 });
210 147
211 this.playerInstance.on('play', () => { 148 this.playerInstance.on('play', () => {
212 - console.log(`[${this.uniqueId}] 播放成功`);  
213 this.hasStarted = true; 149 this.hasStarted = true;
214 - this.isLoading = false;  
215 - this.isError = false;  
216 - // 播放成功后显示一下控制栏  
217 this.showControls = true; 150 this.showControls = true;
218 if (this.controlTimer) clearTimeout(this.controlTimer); 151 if (this.controlTimer) clearTimeout(this.controlTimer);
219 this.controlTimer = setTimeout(() => { 152 this.controlTimer = setTimeout(() => {
@@ -222,183 +155,300 @@ export default { @@ -222,183 +155,300 @@ export default {
222 }); 155 });
223 156
224 this.playerInstance.on('error', (err) => { 157 this.playerInstance.on('error', (err) => {
225 - console.error('Player Error:', err);  
226 - this.triggerError('视频流连接异常'); 158 + console.warn('Player Error:', err);
227 }); 159 });
228 160
229 } catch (e) { 161 } catch (e) {
230 - console.error("Create Instance Error:", e);  
231 - // 如果创建报错,可能是容器脏了,执行销毁逻辑刷新DOM  
232 - this.destroy(); 162 + console.error("Create Error:", e);
233 } 163 }
234 }, 164 },
235 165
236 play(url) { 166 play(url) {
237 - if (!url) return;  
238 - 167 + const playUrl = url || this.initialPlayUrl;
  168 + if (!playUrl) return;
239 if (!this.playerInstance) { 169 if (!this.playerInstance) {
240 this.create(); 170 this.create();
241 - setTimeout(() => this.play(url), 200); 171 + setTimeout(() => this.play(playUrl), 200);
242 return; 172 return;
243 } 173 }
244 -  
245 - this.isLoading = true;  
246 - this.isError = false;  
247 - this.errorMessage = '';  
248 -  
249 - // 设置超时检测  
250 - setTimeout(() => {  
251 - if (this.isLoading) this.triggerError('连接超时,请重试');  
252 - }, this.loadTimeout);  
253 -  
254 - this.playerInstance.play(url).catch(e => {  
255 - console.warn("Play error:", e);  
256 - this.triggerError('请求播放失败'); 174 + this.playerInstance.play(playUrl).catch(e => {
  175 + console.warn('Play Error:', e);
257 }); 176 });
258 }, 177 },
259 178
260 - // 【核心修复】彻底销毁并刷新DOM ID  
261 destroy() { 179 destroy() {
262 - // 1. 重置状态  
263 this.hasStarted = false; 180 this.hasStarted = false;
264 this.showControls = false; 181 this.showControls = false;
265 - this.netSpeed = '0KB/s';  
266 - this.isLoading = false;  
267 - this.isError = false;  
268 -  
269 - const container = this.$refs.container;  
270 -  
271 - // 2. 销毁实例  
272 if (this.playerInstance) { 182 if (this.playerInstance) {
273 - try {  
274 - this.playerInstance.destroy();  
275 - } catch(e) {} 183 + this.playerInstance.destroy();
276 this.playerInstance = null; 184 this.playerInstance = null;
277 } 185 }
278 -  
279 - // 3. 暴力清理旧 DOM  
280 - if (container) {  
281 - container.innerHTML = '';  
282 - }  
283 -  
284 - // 4. 【关键】更新 uniqueId  
285 - // 这会强制 Vue 在下一次渲染时移除当前的 div,并创建一个全新的 div  
286 - // 从而彻底解决 "EasyPlayerPro err container" 错误  
287 - this.uniqueId = `player-box-${Date.now()}-${Math.floor(Math.random() * 1000)}`;  
288 }, 186 },
289 187
290 destroyAndReplay(url) { 188 destroyAndReplay(url) {
291 this.destroy(); 189 this.destroy();
292 - this.isLoading = true;  
293 this.$nextTick(() => { 190 this.$nextTick(() => {
294 this.create(); 191 this.create();
295 - if (url) {  
296 - const u = typeof url === 'string' ? url : url.videoUrl;  
297 - this.play(u);  
298 - } 192 + if (url) this.play(url);
299 }); 193 });
300 }, 194 },
301 195
302 - handleRetry() {  
303 - this.destroyAndReplay(this.initialPlayUrl);  
304 - },  
305 -  
306 - triggerError(msg) {  
307 - if (this.hasUrl) {  
308 - this.isLoading = false;  
309 - this.isError = true;  
310 - this.errorMessage = msg;  
311 - } 196 + onPlayerClick() {
  197 + this.$emit('click');
312 }, 198 },
313 199
314 setControls(config) { 200 setControls(config) {
315 this.controlsConfig = { ...this.controlsConfig, ...config }; 201 this.controlsConfig = { ...this.controlsConfig, ...config };
316 } 202 }
317 - } 203 + },
318 }; 204 };
319 </script> 205 </script>
320 206
321 <style scoped> 207 <style scoped>
322 -/* 基础布局 */ 208 +/* 组件自身的 Scoped 样式 */
323 .player-wrapper { 209 .player-wrapper {
324 - width: 100%; height: 100%; display: flex; flex-direction: column;  
325 - position: relative; background: #000; overflow: hidden; 210 + width: 100%;
  211 + height: 100%;
  212 + display: flex;
  213 + flex-direction: column;
  214 + position: relative;
  215 + background: #000;
  216 + overflow: hidden;
326 } 217 }
  218 +
327 .player-box { 219 .player-box {
328 - flex: 1; width: 100%; height: 100%; background: #000; position: relative; z-index: 1; 220 + flex: 1;
  221 + width: 100%;
  222 + height: 100%;
  223 + position: relative;
  224 + z-index: 1;
329 } 225 }
330 226
331 -/* 顶部栏 */  
332 .custom-top-bar { 227 .custom-top-bar {
333 - position: absolute; top: 0; left: 0; width: 100%; height: 40px; 228 + position: absolute;
  229 + top: 0;
  230 + left: 0;
  231 + width: 100%;
  232 + height: 40px;
334 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));
335 - z-index: 200; display: flex; justify-content: space-between; align-items: center;  
336 - padding: 0 15px; box-sizing: border-box; pointer-events: none; transition: opacity 0.3s ease; 234 + z-index: 200;
  235 + display: flex;
  236 + justify-content: space-between;
  237 + align-items: center;
  238 + padding: 0 15px;
  239 + box-sizing: border-box;
  240 + pointer-events: none;
  241 + transition: opacity 0.3s ease;
337 } 242 }
338 .custom-top-bar.hide-bar { opacity: 0; } 243 .custom-top-bar.hide-bar { opacity: 0; }
339 .top-bar-left .video-title { color: #fff; font-size: 14px; font-weight: bold; } 244 .top-bar-left .video-title { color: #fff; font-size: 14px; font-weight: bold; }
340 .top-bar-right .net-speed { color: #00ff00; font-size: 12px; font-family: monospace; } 245 .top-bar-right .net-speed { color: #00ff00; font-size: 12px; font-family: monospace; }
  246 +</style>
341 247
342 -/* 无信号遮罩  
343 - z-index 必须高于 player-box (z-index 1)  
344 - 并且背景设为纯黑,以盖住可能残留的画面  
345 -*/  
346 -.idle-mask {  
347 - position: absolute; top: 0; left: 0; width: 100%; height: 100%;  
348 - background-color: #000; z-index: 10;  
349 - display: flex; align-items: center; justify-content: center; 248 +<style>
  249 +/* =========================================
  250 + 1. 基础显隐控制 (映射 controlsConfig)
  251 + ========================================= */
  252 +
  253 +/* 网速显示 */
  254 +.player-wrapper.hide-speed .easyplayer-speed {
  255 + display: none !important;
350 } 256 }
351 -.idle-text { color: #555; font-size: 14px; }  
352 257
353 -/* 状态蒙层 */  
354 -.status-mask {  
355 - position: absolute; top: 0; left: 0; width: 100%; height: 100%;  
356 - background-color: #000; z-index: 50;  
357 - display: flex; flex-direction: column; align-items: center; justify-content: center; 258 +/* 解码器/清晰度选择面板 */
  259 +.player-wrapper.hide-code-select .easyplayer-controls-code-wrap {
  260 + display: none !important;
358 } 261 }
359 -.status-text { color: #fff; margin-top: 15px; font-size: 14px; letter-spacing: 1px; }  
360 -.error-content { display: flex; flex-direction: column; align-items: center; justify-content: center; }  
361 -.error-text { color: #ff6d6d; }  
362 -  
363 -/* Loading */  
364 -.spinner-box { width: 50px; height: 50px; display: flex; justify-content: center; align-items: center; }  
365 -.simple-spinner {  
366 - width: 40px; height: 40px; border: 3px solid rgba(255, 255, 255, 0.2);  
367 - border-radius: 50%; border-top-color: #409EFF; animation: spin 0.8s linear infinite; 262 +
  263 +/* 整个底部栏 */
  264 +.player-wrapper.hide-bottom-bar .easyplayer-controls {
  265 + display: none !important;
368 } 266 }
369 -@keyframes spin { to { transform: rotate(360deg); } }  
370 -</style>  
371 267
372 -<style>  
373 -.player-wrapper.hide-speed .easyplayer-speed { display: none !important; }  
374 -.player-wrapper.hide-code-select .easyplayer-controls-code-wrap { display: none !important; }  
375 -.player-wrapper.hide-bottom-bar .easyplayer-controls { display: none !important; }  
376 -.player-wrapper.hide-btn-play .easyplayer-play, .player-wrapper.hide-btn-play .easyplayer-pause { display: none !important; }  
377 -.player-wrapper.hide-btn-audio .easyplayer-audio-box { display: none !important; }  
378 -.player-wrapper.hide-btn-screenshot .easyplayer-screenshot { display: none !important; }  
379 -.player-wrapper.hide-btn-fullscreen .easyplayer-fullscreen, .player-wrapper.hide-btn-fullscreen .easyplayer-fullscreen-exit { display: none !important; }  
380 -.player-wrapper.hide-btn-record .easyplayer-record, .player-wrapper.hide-btn-record .easyplayer-record-stop { display: none !important; } 268 +/* 播放/暂停按钮 */
  269 +.player-wrapper.hide-btn-play .easyplayer-play,
  270 +.player-wrapper.hide-btn-play .easyplayer-pause {
  271 + display: none !important;
  272 +}
  273 +
  274 +/* 音量按钮 */
  275 +.player-wrapper.hide-btn-audio .easyplayer-audio-box {
  276 + display: none !important;
  277 +}
  278 +
  279 +/* 截图按钮 */
  280 +.player-wrapper.hide-btn-screenshot .easyplayer-screenshot {
  281 + display: none !important;
  282 +}
  283 +
  284 +/* 全屏按钮 */
  285 +.player-wrapper.hide-btn-fullscreen .easyplayer-fullscreen,
  286 +.player-wrapper.hide-btn-fullscreen .easyplayer-fullscreen-exit {
  287 + display: none !important;
  288 +}
  289 +
  290 +/* 录制按钮 (原生图标) */
  291 +.player-wrapper.hide-btn-record .easyplayer-record,
  292 +.player-wrapper.hide-btn-record .easyplayer-record-stop {
  293 + display: none !important;
  294 +}
  295 +
  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 +/* 默认隐藏,防止初始闪烁 */
381 .player-wrapper .easyplayer-recording { 321 .player-wrapper .easyplayer-recording {
382 - display: none; position: absolute !important; top: 50px !important; left: 50% !important;  
383 - transform: translateX(-50%) !important; z-index: 200 !important;  
384 - background: rgba(255, 0, 0, 0.6); border-radius: 4px; padding: 4px 12px; 322 + display: none;
  323 + position: absolute !important;
  324 + top: 50px !important;
  325 + left: 50% !important;
  326 + transform: translateX(-50%) !important;
  327 + z-index: 200 !important;
  328 + background: rgba(255, 0, 0, 0.6);
  329 + border-radius: 4px;
  330 + padding: 4px 12px;
  331 +}
  332 +
  333 +/* 当播放器将其设为 block 时,强制改为 flex 布局 */
  334 +.player-wrapper .easyplayer-recording[style*="block"] {
  335 + display: flex !important;
  336 + align-items: center !important;
  337 + justify-content: center !important;
  338 +}
  339 +
  340 +.player-wrapper .easyplayer-recording-time {
  341 + margin: 0 8px;
  342 + font-size: 14px;
  343 + color: #fff;
  344 + line-height: 1;
  345 +}
  346 +
  347 +.player-wrapper .easyplayer-recording-stop {
  348 + height: auto !important;
  349 + display: flex !important;
  350 + align-items: center !important;
  351 + cursor: pointer;
385 } 352 }
386 -.player-wrapper .easyplayer-recording[style*="block"] { display: flex !important; align-items: center !important; justify-content: center !important; }  
387 -.player-wrapper .easyplayer-recording-time { margin: 0 8px; font-size: 14px; color: #fff; }  
388 -.player-wrapper .easyplayer-recording-stop { height: auto !important; cursor: pointer; }  
389 -.player-wrapper.hide-btn-stretch .easyplayer-stretch { display: none !important; }  
390 -.player-wrapper .easyplayer-stretch { font-size: 0 !important; width: 34px !important; height: 100% !important; display: flex !important; align-items: center; justify-content: center; cursor: pointer; } 353 +
  354 +/* --- 拉伸按钮图标修正 (使用 SVG) --- */
  355 +.player-wrapper .easyplayer-stretch {
  356 + font-size: 0 !important;
  357 + width: 34px !important;
  358 + height: 100% !important;
  359 + display: flex !important;
  360 + align-items: center;
  361 + justify-content: center;
  362 + cursor: pointer;
  363 +}
  364 +
391 .player-wrapper .easyplayer-stretch::after { 365 .player-wrapper .easyplayer-stretch::after {
392 - content: ''; display: block; width: 20px; height: 20px; 366 + content: '';
  367 + display: block;
  368 + width: 20px;
  369 + height: 20px;
393 background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M128 128h298.667v85.333H195.2l201.6 201.6-60.373 60.374-201.6-201.6v231.466H49.067V49.067h78.933V128zM896 896H597.333v-85.333H828.8l-201.6-201.6 60.373-60.374 201.6 201.6V518.933h85.334v377.067h-78.934V896z' fill='%23ffffff'/%3E%3C/svg%3E"); 370 background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M128 128h298.667v85.333H195.2l201.6 201.6-60.373 60.374-201.6-201.6v231.466H49.067V49.067h78.933V128zM896 896H597.333v-85.333H828.8l-201.6-201.6 60.373-60.374 201.6 201.6V518.933h85.334v377.067h-78.934V896z' fill='%23ffffff'/%3E%3C/svg%3E");
394 - background-repeat: no-repeat; background-position: center; background-size: contain; opacity: 0.8; 371 + background-repeat: no-repeat;
  372 + background-position: center;
  373 + background-size: contain;
  374 + opacity: 0.8;
395 } 375 }
396 -.player-wrapper .easyplayer-stretch:hover::after { opacity: 1; }  
397 -.player-wrapper.hide-btn-zoom .easyplayer-zoom, .player-wrapper.hide-btn-zoom .easyplayer-zoom-stop { display: none !important; } 376 +
  377 +.player-wrapper .easyplayer-stretch:hover::after {
  378 + opacity: 1;
  379 +}
  380 +
  381 +/* --- 电子放大控制栏位置修正 --- */
398 .player-wrapper .easyplayer-zoom-controls { 382 .player-wrapper .easyplayer-zoom-controls {
399 - position: absolute !important; top: 50px !important; left: 50% !important;  
400 - transform: translateX(-50%); z-index: 199 !important;  
401 - background: rgba(0,0,0,0.6); border-radius: 20px; padding: 0 10px; 383 + position: absolute !important;
  384 + top: 50px !important;
  385 + left: 50% !important;
  386 + transform: translateX(-50%);
  387 + z-index: 199 !important;
  388 + background: rgba(0, 0, 0, 0.6);
  389 + border-radius: 20px;
  390 + padding: 0 10px;
  391 +}
  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 +}
  403 +
  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;
402 } 453 }
403 -.player-wrapper.force-hide-controls .easyplayer-controls { opacity: 0 !important; visibility: hidden !important; transition: opacity 0.3s ease; }  
404 </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 +}