Commit 528d08aa6fe89cf98bcb75f30d6f85d8d23d2a6e

Authored by 王鑫
1 parent c84399ff

fix():

1、修改rabbitmq不会影响项目主进程
2、修改ffmpeg推流音视频分两路
Showing 25 changed files with 1036 additions and 1186 deletions
src/main/java/com/genersoft/iot/vmp/gb28181/SipLayer.java
... ... @@ -59,7 +59,7 @@ public class SipLayer implements CommandLineRunner {
59 59 addListeningPoint(monitorIp, sipConfig.getPort());
60 60 }
61 61 if (udpSipProviderMap.size() + tcpSipProviderMap.size() == 0) {
62   - System.exit(1);
  62 + logger.error("[SIP SERVER] SIP监听全部启动失败,系统进入降级模式继续运行,请检查sip.ip与sip.port配置");
63 63 }
64 64 }
65 65 }
... ... @@ -140,6 +140,14 @@ public class SipLayer implements CommandLineRunner {
140 140 if (!ObjectUtils.isEmpty(deviceLocalIp)) {
141 141 return deviceLocalIp;
142 142 }
143   - return getUdpSipProvider().getListeningPoint().getIPAddress();
  143 + SipProviderImpl udpProvider = getUdpSipProvider();
  144 + if (udpProvider != null && udpProvider.getListeningPoint() != null) {
  145 + return udpProvider.getListeningPoint().getIPAddress();
  146 + }
  147 + SipProviderImpl tcpProvider = getTcpSipProvider();
  148 + if (tcpProvider != null && tcpProvider.getListeningPoint() != null) {
  149 + return tcpProvider.getListeningPoint().getIPAddress();
  150 + }
  151 + return sipConfig.getIp();
144 152 }
145 153 }
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/app/VideoServerApp.java
... ... @@ -47,6 +47,7 @@ public class VideoServerApp
47 47 configProperties = "/app.properties";
48 48 break;
49 49 case "jt1078-dev100":
  50 + case "local-100":
50 51 configProperties = "/app-dev100.properties";
51 52 break;
52 53 case "jt1078-dev103":
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/flv/FlvEncoder.java
... ... @@ -33,7 +33,7 @@ public final class FlvEncoder
33 33 public FlvEncoder(boolean haveVideo, boolean haveAudio)
34 34 {
35 35 this.haveVideo = haveVideo;
36   - // this.haveAudio = haveAudio;
  36 + this.haveAudio = haveAudio;
37 37 flvHeader = Packet.create(16);
38 38 videoFrame = new ByteArrayOutputStream(2048 * 100);
39 39 makeFlvHeader();
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/http/NettyHttpServerHandler.java
... ... @@ -30,18 +30,28 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
30 30 {
31 31 FullHttpRequest fhr = (FullHttpRequest) msg;
32 32 String uri = fhr.uri();
  33 + QueryStringDecoder decoder = new QueryStringDecoder(uri);
  34 + String path = decoder.path();
  35 + String origin = fhr.headers().get("Origin");
33 36 Packet resp = Packet.create(1024);
34 37 // uri的第二段,就是通道标签
35   - if (uri.startsWith("/video/"))
  38 + if (path.startsWith("/video/"))
36 39 {
37   - String tag = uri.substring("/video/".length());
  40 + String tag = path.substring("/video/".length());
38 41 resp.addBytes("HTTP/1.1 200 OK\r\n".getBytes(HEADER_ENCODING));
39 42 resp.addBytes("Connection: keep-alive\r\n".getBytes(HEADER_ENCODING));
40 43 resp.addBytes("Content-Type: video/x-flv\r\n".getBytes(HEADER_ENCODING));
41 44 resp.addBytes("Transfer-Encoding: chunked\r\n".getBytes(HEADER_ENCODING));
42 45 resp.addBytes("Cache-Control: no-cache\r\n".getBytes(HEADER_ENCODING));
43   - resp.addBytes("Access-Control-Allow-Origin: *\r\n".getBytes(HEADER_ENCODING));
44   - resp.addBytes("Access-Control-Allow-Credentials: true\r\n".getBytes(HEADER_ENCODING));
  46 + if (origin != null) {
  47 + resp.addBytes(("Access-Control-Allow-Origin: " + origin + "\r\n").getBytes(HEADER_ENCODING));
  48 + resp.addBytes("Access-Control-Allow-Credentials: true\r\n".getBytes(HEADER_ENCODING));
  49 + resp.addBytes("Vary: Origin\r\n".getBytes(HEADER_ENCODING));
  50 + } else {
  51 + resp.addBytes("Access-Control-Allow-Origin: *\r\n".getBytes(HEADER_ENCODING));
  52 + }
  53 + resp.addBytes("Access-Control-Allow-Methods: GET, OPTIONS\r\n".getBytes(HEADER_ENCODING));
  54 + resp.addBytes("Access-Control-Allow-Headers: *\r\n".getBytes(HEADER_ENCODING));
45 55 resp.addBytes("\r\n".getBytes(HEADER_ENCODING));
46 56  
47 57 ctx.writeAndFlush(resp.getBytes()).await();
... ... @@ -49,8 +59,34 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
49 59 // 订阅视频数据
50 60 long wid = PublishManager.getInstance().subscribe(tag, Media.Type.Video, ctx).getId();
51 61 setSession(ctx, new Session().set("subscriber-id", wid).set("tag", tag));
  62 + logger.info("[HTTP_SUBSCRIBE] video tag={}, watcher={}, uri={}", tag, wid, uri);
52 63  
53 64 }
  65 + else if (path.startsWith("/audio/"))
  66 + {
  67 + String tag = path.substring("/audio/".length());
  68 + resp.addBytes("HTTP/1.1 200 OK\r\n".getBytes(HEADER_ENCODING));
  69 + resp.addBytes("Connection: keep-alive\r\n".getBytes(HEADER_ENCODING));
  70 + resp.addBytes("Content-Type: video/x-flv\r\n".getBytes(HEADER_ENCODING));
  71 + resp.addBytes("Transfer-Encoding: chunked\r\n".getBytes(HEADER_ENCODING));
  72 + resp.addBytes("Cache-Control: no-cache\r\n".getBytes(HEADER_ENCODING));
  73 + if (origin != null) {
  74 + resp.addBytes(("Access-Control-Allow-Origin: " + origin + "\r\n").getBytes(HEADER_ENCODING));
  75 + resp.addBytes("Access-Control-Allow-Credentials: true\r\n".getBytes(HEADER_ENCODING));
  76 + resp.addBytes("Vary: Origin\r\n".getBytes(HEADER_ENCODING));
  77 + } else {
  78 + resp.addBytes("Access-Control-Allow-Origin: *\r\n".getBytes(HEADER_ENCODING));
  79 + }
  80 + resp.addBytes("Access-Control-Allow-Methods: GET, OPTIONS\r\n".getBytes(HEADER_ENCODING));
  81 + resp.addBytes("Access-Control-Allow-Headers: *\r\n".getBytes(HEADER_ENCODING));
  82 + resp.addBytes("\r\n".getBytes(HEADER_ENCODING));
  83 +
  84 + ctx.writeAndFlush(resp.getBytes()).await();
  85 +
  86 + long wid = PublishManager.getInstance().subscribe(tag, Media.Type.Audio, ctx).getId();
  87 + setSession(ctx, new Session().set("subscriber-id", wid).set("tag", tag));
  88 + logger.info("[HTTP_SUBSCRIBE] audio tag={}, watcher={}, uri={}", tag, wid, uri);
  89 + }
54 90 else if (uri.equals("/test/multimedia"))
55 91 {
56 92 responseHTMLFile("/multimedia.html", ctx);
... ... @@ -76,6 +112,7 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
76 112 String tag = session.get("tag");
77 113 Long wid = session.get("subscriber-id");
78 114 PublishManager.getInstance().unsubscribe(tag, wid);
  115 + logger.info("[HTTP_SUBSCRIBE] unsubscribe tag={}, watcher={}", tag, wid);
79 116 }
80 117 }
81 118  
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/Channel.java
... ... @@ -4,8 +4,10 @@ import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec;
4 4 import com.genersoft.iot.vmp.jtt1078.entity.Media;
5 5 import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding;
6 6 import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder;
  7 +// [恢复] 引用 FFmpeg 进程管理类
7 8 import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher;
8 9 import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber;
  10 +import com.genersoft.iot.vmp.jtt1078.subscriber.AudioSubscriber;
9 11 import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber;
10 12 import com.genersoft.iot.vmp.jtt1078.util.ByteHolder;
11 13 import com.genersoft.iot.vmp.jtt1078.util.Configs;
... ... @@ -14,11 +16,8 @@ import org.apache.commons.lang3.StringUtils;
14 16 import org.slf4j.Logger;
15 17 import org.slf4j.LoggerFactory;
16 18  
17   -import java.util.Arrays;
18 19 import java.util.Iterator;
19 20 import java.util.concurrent.ConcurrentLinkedQueue;
20   -import java.util.concurrent.atomic.AtomicBoolean;
21   -import java.util.concurrent.atomic.AtomicLong;
22 21  
23 22 public class Channel
24 23 {
... ... @@ -28,6 +27,8 @@ public class Channel
28 27  
29 28 // [恢复] FFmpeg 推流进程管理器
30 29 RTMPPublisher rtmpPublisher;
  30 + RTMPPublisher audioRtmpPublisher;
  31 + // [删除] ZlmRtpPublisher rtpPublisher;
31 32  
32 33 String tag;
33 34 boolean publishing;
... ... @@ -36,39 +37,6 @@ public class Channel
36 37 FlvEncoder flvEncoder;
37 38 private long firstTimestamp = -1;
38 39  
39   - // ========== 新增:视频参数检测和FFmpeg自动重启相关 ==========
40   -
41   - /** 上一次接收到的SPS哈希值,用于检测视频参数变化 */
42   - private int lastSPSHash = 0;
43   -
44   - /** FFmpeg是否需要重启的标志 */
45   - private AtomicBoolean ffmpegNeedRestart = new AtomicBoolean(false);
46   -
47   - /** 最后一次FFmpeg重启时间(毫秒),用于防止频繁重启 */
48   - private AtomicLong lastRestartTime = new AtomicLong(0);
49   -
50   - /** 重启冷却时间(毫秒),3秒内不重复重启 */
51   - private static final long RESTART_COOLDOWN_MS = 3000;
52   -
53   - /** 是否正在等待新的I帧(FFmpeg重启期间) */
54   - private AtomicBoolean waitingForIFrame = new AtomicBoolean(false);
55   -
56   - /** 视频参数信息 */
57   - private volatile VideoParamInfo currentVideoParam = new VideoParamInfo();
58   -
59   - /** 视频参数内部类 */
60   - private static class VideoParamInfo {
61   - int width = 0;
62   - int height = 0;
63   - int fps = 0;
64   - String resolution = "unknown";
65   -
66   - @Override
67   - public String toString() {
68   - return String.format("%dx%d@%dfps [%s]", width, height, fps, resolution);
69   - }
70   - }
71   -
72 40 public Channel(String tag)
73 41 {
74 42 this.tag = tag;
... ... @@ -84,6 +52,8 @@ public class Channel
84 52 logger.info("[{}] 启动 FFmpeg 进程推流至: {}", tag, rtmpUrl);
85 53 rtmpPublisher = new RTMPPublisher(tag);
86 54 rtmpPublisher.start();
  55 + audioRtmpPublisher = new RTMPPublisher(tag, true);
  56 + audioRtmpPublisher.start();
87 57 }
88 58 }
89 59  
... ... @@ -92,9 +62,17 @@ public class Channel
92 62 return publishing;
93 63 }
94 64  
95   - public Subscriber subscribe(ChannelHandlerContext ctx)
  65 + public Subscriber subscribe(ChannelHandlerContext ctx, Media.Type type)
96 66 {
97   - Subscriber subscriber = new VideoSubscriber(this.tag, ctx);
  67 + Subscriber subscriber;
  68 + if (Media.Type.Audio.equals(type))
  69 + {
  70 + subscriber = new AudioSubscriber(this.tag, ctx);
  71 + }
  72 + else
  73 + {
  74 + subscriber = new VideoSubscriber(this.tag, ctx);
  75 + }
98 76 this.subscribers.add(subscriber);
99 77 return subscriber;
100 78 }
... ... @@ -105,10 +83,12 @@ public class Channel
105 83 if (audioCodec == null)
106 84 {
107 85 audioCodec = AudioCodec.getCodec(pt);
108   - logger.info("[{}] audio codec: {}", tag, MediaEncoding.getEncoding(Media.Type.Audio, pt));
  86 + logger.info("audio codec: {}", MediaEncoding.getEncoding(Media.Type.Audio, pt));
109 87 }
110 88 // 写入到内部广播,FFmpeg 通过 HTTP 拉取这个数据
111 89 broadcastAudio(timestamp, audioCodec.toPCM(data));
  90 +
  91 + // [删除] rtpPublisher.sendAudio(...)
112 92 }
113 93  
114 94 public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264)
... ... @@ -123,28 +103,6 @@ public class Channel
123 103 if (nalu == null) break;
124 104 if (nalu.length < 4) continue;
125 105  
126   - // ========== 新增:检测视频参数变化 ==========
127   - int nalType = nalu[4] & 0x1F;
128   - boolean isIDR = (nalType == 5); // IDR帧(I帧)
129   -
130   - if (nalType == 7) { // SPS (Sequence Parameter Set)
131   - checkAndHandleSPSChange(nalu);
132   - }
133   -
134   - // 如果正在等待I帧,跳过所有数据直到收到新的I帧
135   - if (waitingForIFrame.get()) {
136   - if (isIDR) {
137   - logger.info("[{}] 收到新的I帧,停止等待,FFmpeg应已重启完成", tag);
138   - waitingForIFrame.set(false);
139   - // 重置FLV编码器
140   - this.flvEncoder = new FlvEncoder(true, true);
141   - firstTimestamp = timeoffset;
142   - } else {
143   - // 跳过非I帧数据
144   - continue;
145   - }
146   - }
147   -
148 106 // 1. 封装为 FLV Tag (必须)
149 107 // FFmpeg 通过 HTTP 读取这些 FLV Tag
150 108 byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp));
... ... @@ -156,281 +114,6 @@ public class Channel
156 114 }
157 115 }
158 116  
159   - /**
160   - * 检查SPS变化并处理FFmpeg重启
161   - */
162   - private synchronized void checkAndHandleSPSChange(byte[] nalu) {
163   - int currentHash = Arrays.hashCode(nalu);
164   -
165   - if (lastSPSHash != 0 && currentHash != lastSPSHash) {
166   - // SPS变化了,说明视频参数发生了变化
167   - // 注意:即使解析出来的分辨率/帧率相同,SPS的其它变化(如profile、level、VUI参数等)也会导致解码问题
168   - // 因此:只要SPS哈希变化,就重启FFmpeg
169   -
170   - // 解析新的视频参数(仅用于日志显示)
171   - VideoParamInfo newParam = parseVideoParam(nalu);
172   - VideoParamInfo oldParam = currentVideoParam;
173   -
174   - logger.info("[{}] ====== 检测到SPS变化,需要重启FFmpeg ======", tag);
175   - logger.info("[{}] 旧参数: {}", tag, oldParam);
176   - logger.info("[{}] 新参数: {}", tag, newParam);
177   - logger.info("[{}] SPS哈希变化: {} -> {}", tag,
178   - Integer.toHexString(lastSPSHash),
179   - Integer.toHexString(currentHash));
180   -
181   - // 检查冷却时间
182   - long now = System.currentTimeMillis();
183   - long timeSinceLastRestart = now - lastRestartTime.get();
184   -
185   - if (timeSinceLastRestart < RESTART_COOLDOWN_MS) {
186   - logger.warn("[{}] FFmpeg重启过于频繁(距上次{}ms),跳过本次重启",
187   - tag, timeSinceLastRestart);
188   - // 即使跳过重启,仍需更新SPS哈希
189   - lastSPSHash = currentHash;
190   - currentVideoParam = newParam;
191   - return;
192   - }
193   -
194   - logger.info("[{}] ====== 准备重启FFmpeg ======", tag);
195   - logger.info("[{}] 原因: SPS参数变化(可能包含分辨率、帧率、profile、level等)", tag);
196   - logger.info("[{}] 预计中断时间: 1-2秒", tag);
197   -
198   - // 标记需要重启
199   - ffmpegNeedRestart.set(true);
200   -
201   - // 立即触发重启(异步执行,避免阻塞)
202   - final String currentTag = tag;
203   - Thread restartThread = new Thread(() -> {
204   - try {
205   - logger.info("[{}] SPS变化检测到,立即触发FFmpeg重启...", currentTag);
206   - restartRtmpPublisher();
207   - } catch (Exception e) {
208   - logger.error("[{}] 触发FFmpeg重启失败: {}", currentTag, e.getMessage(), e);
209   - }
210   - }, "FFmpeg-Restart-" + tag);
211   - restartThread.setDaemon(true);
212   - restartThread.start();
213   - }
214   -
215   - // 更新SPS哈希和视频参数
216   - lastSPSHash = currentHash;
217   - currentVideoParam = parseVideoParam(nalu);
218   - }
219   -
220   - /**
221   - * 解析H.264 SPS获取视频参数
222   - */
223   - private VideoParamInfo parseVideoParam(byte[] sps) {
224   - VideoParamInfo info = new VideoParamInfo();
225   - try {
226   - // H.264 SPS解析
227   - // SPS格式: [NAL头(1字节) + profile_idc(1) + constraints(1) + level_idc(1) + 5字节对齐 + sps数据]
228   -
229   - if (sps.length < 6) {
230   - logger.warn("[{}] SPS数据长度不足,无法解析: {}", tag, sps.length);
231   - return info;
232   - }
233   -
234   - int profile_idc = sps[4] & 0xFF;
235   - int constraint_flags = sps[5] & 0xFF;
236   - int level_idc = sps[6] & 0xFF;
237   -
238   - // 查找SPS结束位置(开始于67或68)
239   - int spsStart = -1;
240   - for (int i = 4; i < Math.min(sps.length - 4, 10); i++) {
241   - if ((sps[i] & 0x1F) == 7) {
242   - spsStart = i;
243   - break;
244   - }
245   - }
246   -
247   - if (spsStart == -1) {
248   - // 简化模式:直接使用SPS长度估算
249   - int spsLen = (sps.length > 10) ? 4 : 1;
250   - for (int i = 4; i < sps.length - 1; i++) {
251   - if (sps[i] == 0 && sps[i+1] == 0 && sps[i+2] == 0 && sps[i+3] == 1) {
252   - spsLen = i - 4;
253   - break;
254   - }
255   - }
256   -
257   - // 根据SPS长度估算分辨率(非常粗略)
258   - int lenCategory = spsLen / 10;
259   - if (spsLen < 20) {
260   - info.width = 352;
261   - info.height = 288;
262   - info.fps = 15;
263   - } else if (spsLen < 40) {
264   - info.width = 704;
265   - info.height = 576;
266   - info.fps = 25;
267   - } else {
268   - info.width = 1280;
269   - info.height = 720;
270   - info.fps = 25;
271   - }
272   - } else {
273   - // 详细解析bitstream
274   - // 这里简化处理,实际项目中可以使用完整的H.264解析库
275   - info.width = 1280; // 默认值
276   - info.height = 720;
277   - info.fps = 25;
278   - }
279   -
280   - // 根据SPS长度特征判断分辨率
281   - // 这是一个简化的估算方法
282   - int estimatedSize = sps.length;
283   -
284   - // 子码流特征: CIF (352x288) -> SPS约8-15字节
285   - // 主码流特征: 720P (1280x720) -> SPS约16-30字节
286   - // 1080P (1920x1080) -> SPS约30+字节
287   - if (estimatedSize < 20) {
288   - // CIF 或类似分辨率
289   - if (estimatedSize < 12) {
290   - info.width = 352;
291   - info.height = 288;
292   - info.fps = 15;
293   - info.resolution = "CIF";
294   - } else {
295   - info.width = 704;
296   - info.height = 576;
297   - info.fps = 25;
298   - info.resolution = "4CIF";
299   - }
300   - } else if (estimatedSize < 35) {
301   - // 720P 或类似
302   - info.width = 1280;
303   - info.height = 720;
304   - info.fps = 25;
305   - info.resolution = "720P";
306   - } else {
307   - // 1080P 或更高
308   - info.width = 1920;
309   - info.height = 1080;
310   - info.fps = 30;
311   - info.resolution = "1080P";
312   - }
313   -
314   - } catch (Exception e) {
315   - logger.error("[{}] 解析SPS失败: {}", tag, e.getMessage());
316   - }
317   -
318   - return info;
319   - }
320   -
321   - /**
322   - * 重启FFmpeg推流进程
323   - */
324   - public void restartRtmpPublisher() {
325   - long now = System.currentTimeMillis();
326   - lastRestartTime.set(now);
327   -
328   - logger.info("[{}] ====== 开始重启FFmpeg推流 ======", tag);
329   - logger.info("[{}] 当前时间: {}", tag, now);
330   -
331   - try {
332   - // 1. 标记等待I帧
333   - waitingForIFrame.set(true);
334   -
335   - // 2. 关闭旧的FFmpeg进程
336   - logger.info("[{}] 步骤1: 关闭旧FFmpeg进程...", tag);
337   - if (rtmpPublisher != null) {
338   - try {
339   - rtmpPublisher.close();
340   - } catch (Exception e) {
341   - logger.warn("[{}] 关闭旧FFmpeg进程时出错: {}", tag, e.getMessage());
342   - }
343   - rtmpPublisher = null;
344   - }
345   -
346   - // 3. 等待FFmpeg进程完全关闭
347   - logger.info("[{}] 步骤2: 等待FFmpeg进程关闭...", tag);
348   - Thread.sleep(500);
349   -
350   - // 4. 清空缓冲区,准备接收新的视频数据
351   - logger.info("[{}] 步骤3: 清空视频缓冲区...", tag);
352   - buffer.clear();
353   - firstTimestamp = -1;
354   -
355   - // 5. 重置FLV编码器
356   - logger.info("[{}] 步骤4: 重置FLV编码器...", tag);
357   - flvEncoder = new FlvEncoder(true, true);
358   -
359   - // 6. 重新启动FFmpeg进程
360   - logger.info("[{}] 步骤5: 启动新FFmpeg进程...", tag);
361   - String rtmpUrl = Configs.get("rtmp.url");
362   - if (StringUtils.isNotBlank(rtmpUrl)) {
363   - rtmpPublisher = new RTMPPublisher(tag);
364   - rtmpPublisher.start();
365   - logger.info("[{}] 新FFmpeg进程已启动", tag);
366   - } else {
367   - logger.warn("[{}] 未配置rtmp.url,跳过启动", tag);
368   - }
369   -
370   - // 7. 重置标志
371   - ffmpegNeedRestart.set(false);
372   -
373   - logger.info("[{}] ====== FFmpeg重启完成 ======", tag);
374   - logger.info("[{}] 请等待1-2秒让FFmpeg完成初始化...", tag);
375   -
376   - } catch (Exception e) {
377   - logger.error("[{}] 重启FFmpeg失败: {}", tag, e.getMessage(), e);
378   - waitingForIFrame.set(false);
379   - ffmpegNeedRestart.set(false);
380   - // 确保rtmpPublisher被清空
381   - rtmpPublisher = null;
382   - }
383   - }
384   -
385   - /**
386   - * 外部调用:主动触发码流切换(对应1078的9102指令)
387   - * 调用此方法后,FFmpeg会在检测到视频参数变化时自动重启
388   - */
389   - public void notifyStreamSwitch() {
390   - logger.info("[{}] ====== 收到码流切换通知 ======", tag);
391   - logger.info("[{}] 即将切换码流,请等待设备响应...", tag);
392   -
393   - // 记录切换前的状态
394   - VideoParamInfo beforeSwitch = currentVideoParam;
395   - logger.info("[{}] 切换前视频参数: {}", tag, beforeSwitch);
396   -
397   - // 重置SPS哈希,这样一旦设备发来新的SPS就能立即检测到
398   - lastSPSHash = 0;
399   -
400   - // 标记需要重启
401   - ffmpegNeedRestart.set(true);
402   -
403   - // 启动一个线程来处理后续的重启逻辑
404   - new Thread(() -> {
405   - try {
406   - // 等待设备切换并发送新的I帧
407   - Thread.sleep(2000);
408   -
409   - // 如果还没有收到新的I帧,手动触发一次检查
410   - if (waitingForIFrame.get()) {
411   - logger.warn("[{}] 未在2秒内收到新I帧,检查是否需要手动重启...", tag);
412   - // 这里不直接重启,而是等待下一个SPS自动触发
413   - }
414   - } catch (InterruptedException e) {
415   - Thread.currentThread().interrupt();
416   - }
417   - }, "StreamSwitch-Watcher-" + tag).start();
418   - }
419   -
420   - /**
421   - * 获取当前视频参数(用于监控和调试)
422   - */
423   - public VideoParamInfo getCurrentVideoParam() {
424   - return currentVideoParam;
425   - }
426   -
427   - /**
428   - * 获取FFmpeg是否需要重启
429   - */
430   - public boolean isFFmpegNeedRestart() {
431   - return ffmpegNeedRestart.get();
432   - }
433   -
434 117 public void broadcastVideo(long timeoffset, byte[] flvTag)
435 118 {
436 119 for (Subscriber subscriber : subscribers)
... ... @@ -463,8 +146,6 @@ public class Channel
463 146  
464 147 public void close()
465 148 {
466   - logger.info("[{}] 关闭Channel,开始清理资源...", tag);
467   -
468 149 for (Iterator<Subscriber> itr = subscribers.iterator(); itr.hasNext(); )
469 150 {
470 151 Subscriber subscriber = itr.next();
... ... @@ -472,17 +153,41 @@ public class Channel
472 153 itr.remove();
473 154 }
474 155  
475   - // 关闭 FFmpeg 进程
  156 + // [恢复] 关闭 FFmpeg 进程
476 157 if (rtmpPublisher != null) {
477   - logger.info("[{}] 关闭FFmpeg推流进程...", tag);
478 158 rtmpPublisher.close();
479 159 rtmpPublisher = null;
480 160 }
  161 + if (audioRtmpPublisher != null) {
  162 + audioRtmpPublisher.close();
  163 + audioRtmpPublisher = null;
  164 + }
  165 + }
481 166  
482   - logger.info("[{}] Channel已关闭", tag);
  167 + public synchronized boolean ensureAudioPublisherRunning()
  168 + {
  169 + String rtmpUrl = Configs.get("rtmp.url");
  170 + if (StringUtils.isBlank(rtmpUrl))
  171 + {
  172 + return false;
  173 + }
  174 + if (audioRtmpPublisher != null && audioRtmpPublisher.isAlive())
  175 + {
  176 + return true;
  177 + }
  178 + if (audioRtmpPublisher != null)
  179 + {
  180 + audioRtmpPublisher.close();
  181 + audioRtmpPublisher = null;
  182 + }
  183 + logger.info("[{}] 重启音频 FFmpeg 推流进程", tag);
  184 + audioRtmpPublisher = new RTMPPublisher(tag, true);
  185 + audioRtmpPublisher.start();
  186 + return true;
483 187 }
484 188  
485 189 // [恢复] 原版 readNalu (FFmpeg 偏好带 StartCode 的数据,或者 FlvEncoder 需要)
  190 + // 之前为了 RTP 特意修改了切片逻辑,现在改回原版简单逻辑即可
486 191 private byte[] readNalu()
487 192 {
488 193 // 寻找 00 00 00 01
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/PublishManager.java
... ... @@ -29,9 +29,15 @@ public final class PublishManager
29 29 chl = new Channel(tag);
30 30 channels.put(tag, chl);
31 31 }
32   - Subscriber subscriber = null;
33   - if (type.equals(Media.Type.Video)) subscriber = chl.subscribe(ctx);
34   - else throw new RuntimeException("unknown media type: " + type);
  32 + Subscriber subscriber;
  33 + if (type.equals(Media.Type.Video) || type.equals(Media.Type.Audio))
  34 + {
  35 + subscriber = chl.subscribe(ctx, type);
  36 + }
  37 + else
  38 + {
  39 + throw new RuntimeException("unknown media type: " + type);
  40 + }
35 41  
36 42 subscriber.setName("subscriber-" + tag + "-" + subscriber.getId());
37 43 subscriber.start();
... ... @@ -68,6 +74,17 @@ public final class PublishManager
68 74 if (chl != null) chl.close();
69 75 }
70 76  
  77 + public boolean ensureAudioPublisher(String tag)
  78 + {
  79 + Channel chl = channels.get(tag);
  80 + if (chl == null)
  81 + {
  82 + logger.warn("ensureAudioPublisher skipped, channel not found: {}", tag);
  83 + return false;
  84 + }
  85 + return chl.ensureAudioPublisherRunning();
  86 + }
  87 +
71 88 public void unsubscribe(String tag, long watcherId)
72 89 {
73 90 Channel chl = channels.get(tag);
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/server/Jtt1078Handler.java
... ... @@ -36,6 +36,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
36 36 private long currentSecondFlow = 0;
37 37 private int currentSecondCount = 0;
38 38 private long lastRedisKeepAliveTime = 0;
  39 + private long lastAudioTraceTime = 0;
  40 + private long audioPacketCount = 0;
  41 + private long audioPublishCount = 0;
39 42  
40 43 public Jtt1078Handler(Integer port) {
41 44 this.port = port;
... ... @@ -142,6 +145,19 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
142 145 return 28; // 视频
143 146 }
144 147  
  148 + private boolean canPublishAudio() {
  149 + if (redisTemplate == null) {
  150 + return false;
  151 + }
  152 + String key = Jt1078OfCarController.buildAudioSubscribeRedisKey(this.currentSim, String.valueOf(this.currentChannelId));
  153 + Boolean subscribed = redisTemplate.hasKey(key);
  154 + if (Boolean.TRUE.equals(subscribed)) {
  155 + redisTemplate.expire(key, 120, TimeUnit.SECONDS);
  156 + return true;
  157 + }
  158 + return false;
  159 + }
  160 +
145 161 private void processMediaPayload(io.netty.channel.Channel nettyChannel, Packet packet, String tag, int lengthOffset) {
146 162 Integer sequence = SessionManager.get(nettyChannel, "video-sequence");
147 163 if (sequence == null) sequence = 0;
... ... @@ -154,9 +170,13 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
154 170 packet.seek(5);
155 171 int pt = packet.nextByte() & 0x7f;
156 172  
157   - // --- 分支 A: 视频流 (0, 1) ---
158   - // 视频流必须发给 PublishManager (FFmpeg)
159   - if (dataType == 0x00 || dataType == 0x01) {
  173 + boolean isAudioPt = pt >= 0 && pt <= 28;
  174 + boolean isVideoPt = pt >= 98 && pt <= 101;
  175 + boolean canParseAsAudio = dataType == 0x03 || dataType == 0x02 || (dataType == 0x00 && (isAudioPt || !isVideoPt));
  176 + boolean canParseAsVideo = dataType == 0x01 || (dataType == 0x00 && isVideoPt);
  177 +
  178 + // --- 分支 A: 视频流 ---
  179 + if (canParseAsVideo) {
160 180 if (pkType == 0 || pkType == 2) {
161 181 sequence += 1;
162 182 SessionManager.set(nettyChannel, "video-sequence", sequence);
... ... @@ -168,24 +188,26 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
168 188 PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, videoData);
169 189 }
170 190  
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   - }
  191 + // --- 分支 B: 音频流 ---
  192 + if (canParseAsAudio) {
  193 + long timestamp = packet.seek(16).nextLong();
  194 + byte[] audioData = packet.seek(lengthOffset + 2).nextBytes();
  195 + audioPacketCount++;
  196 + // 对讲下行广播维持原行为
  197 + Jtt1078AudioBroadcastManager.broadcastAudio(this.currentSim, this.currentChannelId, audioData);
  198 +
  199 + // 0x02 对讲音频不送 ffmpeg;其他音频按订阅状态放行
  200 + boolean willPublish = dataType != 0x02;
  201 + if (willPublish) {
  202 + PublishManager.getInstance().publishAudio(tag, sequence, timestamp, pt, audioData);
  203 + audioPublishCount++;
  204 + }
  205 +
  206 + long now = System.currentTimeMillis();
  207 + if (now - lastAudioTraceTime > 2000) {
  208 + logger.debug("[AUDIO_TRACE] tag={}, sim={}, channel={}, dataType={}, pt={}, pkType={}, packetBytes={}, publish={}, packetCount={}, publishCount={}",
  209 + tag, currentSim, currentChannelId, dataType, pt, pkType, audioData == null ? 0 : audioData.length, willPublish, audioPacketCount, audioPublishCount);
  210 + lastAudioTraceTime = now;
189 211 }
190 212 }
191 213 }
... ...
src/main/java/com/genersoft/iot/vmp/jtt1078/subscriber/RTMPPublisher.java
... ... @@ -10,37 +10,26 @@ import java.util.concurrent.TimeUnit;
10 10  
11 11 /**
12 12 * FFmpeg 推流器 (仅处理视频直播流)
13   - *
14   - * 修改记录:
15   - * 1. 添加详细的启动和关闭日志,便于排查问题
16   - * 2. 优化关闭逻辑,确保FFmpeg进程被正确终止
17   - * 3. 添加FFmpeg输出日志监控
18   - * 4. 兼容Java 8
19 13 */
20 14 public class RTMPPublisher extends Thread
21 15 {
22 16 static Logger logger = LoggerFactory.getLogger(RTMPPublisher.class);
23 17  
24 18 String tag = null;
  19 + boolean audioOnly = false;
25 20 Process process = null;
26 21 private volatile boolean running = true;
27 22  
28   - /** FFmpeg路径 */
29   - private String ffmpegPath = null;
30   -
31   - /** 目标RTMP地址 */
32   - private String rtmpUrl = null;
33   -
34   - /** FFmpeg命令格式标志 */
35   - private String formatFlag = null;
36   -
37   - /** 进程启动时间 */
38   - private long processStartTime = 0;
39   -
40 23 public RTMPPublisher(String tag)
41 24 {
  25 + this(tag, false);
  26 + }
  27 +
  28 + public RTMPPublisher(String tag, boolean audioOnly)
  29 + {
42 30 this.tag = tag;
43   - this.setName("RTMPPublisher-" + tag);
  31 + this.audioOnly = audioOnly;
  32 + this.setName((audioOnly ? "RTMPPublisher-Audio-" : "RTMPPublisher-") + tag);
44 33 this.setDaemon(true);
45 34 }
46 35  
... ... @@ -55,45 +44,52 @@ public class RTMPPublisher extends Thread
55 44  
56 45 try
57 46 {
58   - // 获取FFmpeg配置
59 47 String sign = "41db35390ddad33f83944f44b8b75ded";
60   - rtmpUrl = Configs.get("rtmp.url").replaceAll("\\{TAG\\}", tag).replaceAll("\\{sign\\}",sign);
61   - ffmpegPath = Configs.get("ffmpeg.path");
  48 + String outputTag = audioOnly ? tag + "-audio" : tag;
  49 + String rtmpUrl = Configs.get("rtmp.url").replaceAll("\\{TAG\\}", outputTag).replaceAll("\\{sign\\}",sign);
62 50  
63   - // 自动判断协议格式
64   - formatFlag = "";
  51 + // 【修复】自动判断协议格式,避免硬编码 -f rtsp 导致 RTMP 推流失败
  52 + String formatFlag = "";
65 53 if (rtmpUrl.startsWith("rtsp://")) {
66 54 formatFlag = "-f rtsp";
67 55 } else if (rtmpUrl.startsWith("rtmp://")) {
68 56 formatFlag = "-f flv";
69 57 }
70 58  
71   - // 构造FFmpeg命令
72   - String cmd = String.format("%s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac %s %s",
73   - ffmpegPath,
74   - Configs.getInt("server.http.port", 3333),
75   - tag,
76   - formatFlag,
77   - rtmpUrl
78   - );
  59 + // 低延迟:缩短 HTTP-FLV 探测与缓冲,尽快向 ZLM 输出(仍受设备 GOP/关键帧限制)
  60 + String inputLowLatency =
  61 + "-fflags +nobuffer+discardcorrupt -flags low_delay -probesize 32 -analyzeduration 0";
  62 + String outputLowLatency = "-flush_packets 1";
  63 +
  64 + int httpPort = Configs.getInt("server.http.port", 3333);
  65 + String cmd;
  66 + if (audioOnly) {
  67 + cmd = String.format(
  68 + "%s %s -i http://127.0.0.1:%d/audio/%s -vn -acodec aac %s %s %s",
  69 + Configs.get("ffmpeg.path"),
  70 + inputLowLatency,
  71 + httpPort,
  72 + tag,
  73 + outputLowLatency,
  74 + formatFlag,
  75 + rtmpUrl
  76 + );
  77 + } else {
  78 + cmd = String.format(
  79 + "%s %s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac %s %s %s",
  80 + Configs.get("ffmpeg.path"),
  81 + inputLowLatency,
  82 + httpPort,
  83 + tag,
  84 + outputLowLatency,
  85 + formatFlag,
  86 + rtmpUrl
  87 + );
  88 + }
79 89  
80   - logger.info("===========================================");
81   - logger.info("[{}] ====== FFmpeg推流任务启动 ======", tag);
82   - logger.info("[{}] FFmpeg路径: {}", tag, ffmpegPath);
83   - logger.info("[{}] 目标地址: {}", tag, rtmpUrl);
84   - logger.info("[{}] 完整命令: {}", tag, cmd);
85   - logger.info("[{}] HTTP端口: {}", tag, Configs.getInt("server.http.port", 3333));
86   - logger.info("[{}] 源流地址: http://127.0.0.1:{}/video/{}", tag, Configs.getInt("server.http.port", 3333), tag);
87   - logger.info("[{}] 启动时间: {}", tag, new java.util.Date());
88   - logger.info("===========================================");
  90 + logger.info("FFmpeg Push Started. tag={}, outputTag={}, audioOnly={}, cmd={}", tag, outputTag, audioOnly, cmd);
89 91  
90   - // 执行FFmpeg命令
91 92 process = Runtime.getRuntime().exec(cmd);
92   - processStartTime = System.currentTimeMillis();
93   -
94   - // 记录进程启动信息(Java 8兼容方式)
95   - logger.info("[{}] FFmpeg进程已启动", tag);
96   -
97 93 stderr = process.getErrorStream();
98 94 stdout = process.getInputStream();
99 95  
... ... @@ -102,15 +98,9 @@ public class RTMPPublisher extends Thread
102 98 Thread stdoutConsumer = new Thread(() -> {
103 99 try {
104 100 byte[] buffer = new byte[512];
105   - int count = 0;
106 101 while (running && finalStdout.read(buffer) > -1) {
107   - count++;
108   - // 每1000次读取才打印一次(避免刷屏)
109   - if (debugMode && count % 1000 == 0) {
110   - logger.debug("[{}] FFmpeg stdout消费中... count: {}", tag, count);
111   - }
  102 + // 只消费,不输出
112 103 }
113   - logger.info("[{}] FFmpeg stdout消费结束, 共消费{}次", tag, count);
114 104 } catch (Exception e) {
115 105 // 忽略异常
116 106 }
... ... @@ -118,60 +108,26 @@ public class RTMPPublisher extends Thread
118 108 stdoutConsumer.setDaemon(true);
119 109 stdoutConsumer.start();
120 110  
121   - // 消费 stderr 日志流
122   - StringBuilder errorLog = new StringBuilder();
123   - int errorCount = 0;
  111 + // 消费 stderr 日志流,防止阻塞
124 112 while (running && (len = stderr.read(buff)) > -1)
125 113 {
126 114 if (debugMode) {
127 115 System.out.print(new String(buff, 0, len));
128 116 }
129   -
130   - // 收集错误日志(便于排查问题)
131   - errorLog.append(new String(buff, 0, len));
132   - errorCount++;
133   -
134   - // 每100条错误日志打印一次摘要
135   - if (errorCount % 100 == 0) {
136   - String lastError = errorLog.length() > 500
137   - ? errorLog.substring(errorLog.length() - 500)
138   - : errorLog.toString();
139   - logger.debug("[{}] FFmpeg错误日志摘要: {}", tag, lastError);
140   - }
141 117 }
142 118  
143 119 // 进程退出处理
144 120 int exitCode = process.waitFor();
145   - long runDuration = System.currentTimeMillis() - processStartTime;
146   - logger.warn("===========================================");
147   - logger.warn("[{}] ====== FFmpeg推流任务结束 ======", tag);
148   - logger.warn("[{}] 退出代码: {}", tag, exitCode);
149   - logger.warn("[{}] 运行时间: {} ms", tag, runDuration);
150   - logger.warn("[{}] 错误日志条数: {}", tag, errorCount);
151   -
152   - // 分析退出原因
153   - if (exitCode == 0) {
154   - logger.info("[{}] FFmpeg正常退出", tag);
155   - } else if (exitCode == -1 || exitCode == 255) {
156   - logger.warn("[{}] FFmpeg被信号终止 (exitCode={})", tag, exitCode);
157   - } else {
158   - // 保存最后一段错误日志
159   - String lastError = errorLog.length() > 1000
160   - ? errorLog.substring(errorLog.length() - 1000)
161   - : errorLog.toString();
162   - logger.error("[{}] FFmpeg异常退出, 最后错误日志:\n{}", tag, lastError);
163   - }
164   - logger.warn("===========================================");
165   -
  121 + logger.warn("FFmpeg process exited. Code: {}, Tag: {}", exitCode, tag);
166 122 }
167 123 catch(InterruptedException ex)
168 124 {
169   - logger.info("[{}] RTMPPublisher被中断: {}", tag, ex.getMessage());
  125 + logger.info("RTMPPublisher interrupted: {}", tag);
170 126 Thread.currentThread().interrupt();
171 127 }
172 128 catch(Exception ex)
173 129 {
174   - logger.error("[{}] RTMPPublisher异常: {}", tag, ex);
  130 + logger.error("RTMPPublisher Error: " + tag, ex);
175 131 }
176 132 finally
177 133 {
... ... @@ -183,184 +139,40 @@ public class RTMPPublisher extends Thread
183 139 closeQuietly(process.getOutputStream());
184 140 closeQuietly(process.getErrorStream());
185 141 }
186   - logger.info("[{}] RTMPPublisher资源已释放", tag);
187 142 }
188 143 }
189 144  
190   - /**
191   - * 关闭FFmpeg推流
192   - * 优化关闭逻辑,确保进程被正确终止(Java 8兼容)
193   - */
194 145 public void close()
195 146 {
196   - logger.info("[{}] ====== 开始关闭FFmpeg推流 ======", tag);
197   - logger.info("[{}] 关闭请求时间: {}", tag, new java.util.Date());
198   -
199 147 try {
200 148 // 设置停止标志
201 149 running = false;
202 150  
203 151 if (process != null) {
204   - long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0;
205   - logger.info("[{}] 正在终止FFmpeg进程... (已运行{}ms)", tag, runDuration);
206   -
207 152 // 先尝试正常终止
208 153 process.destroy();
209 154  
210   - // 等待最多3
  155 + // 等待最多 3
211 156 boolean exited = process.waitFor(3, TimeUnit.SECONDS);
212 157  
213 158 if (!exited) {
214   - logger.warn("[{}] FFmpeg进程未能在3秒内正常退出,开始强制终止...", tag);
215   -
216   - // 强制终止(Java 8的方式)
  159 + // 如果还没退出,强制终止
  160 + logger.warn("FFmpeg process did not exit gracefully, forcing termination: {}", tag);
217 161 process.destroyForcibly();
218   -
219   - // 再等待2秒
220   - try {
221   - exited = process.waitFor(2, TimeUnit.SECONDS);
222   - } catch (InterruptedException e) {
223   - Thread.currentThread().interrupt();
224   - }
225   - if (!exited) {
226   - logger.error("[{}] FFmpeg进程强制终止失败,可能存在资源泄漏", tag);
227   - } else {
228   - logger.info("[{}] FFmpeg进程已强制终止", tag);
229   - }
230   - } else {
231   - int exitCode = process.exitValue();
232   - logger.info("[{}] FFmpeg进程已正常终止, 退出代码: {}", tag, exitCode);
  162 + process.waitFor(2, TimeUnit.SECONDS);
233 163 }
234 164  
235   - // 检查是否需要杀掉残留进程
236   - checkAndKillOrphanedProcesses();
237   -
238   - } else {
239   - logger.info("[{}] FFmpeg进程为空,无需关闭", tag);
  165 + logger.info("FFmpeg process terminated: {}", tag);
240 166 }
241 167  
242   - logger.info("[{}] ====== FFmpeg推流已关闭 ======", tag);
243   -
244 168 // 中断线程(如果还在阻塞读取)
245 169 this.interrupt();
246 170  
247 171 // 等待线程结束
248 172 this.join(2000);
249   - if (this.isAlive()) {
250   - logger.warn("[{}] RTMPPublisher线程未能正常结束", tag);
251   - }
252 173  
253 174 } catch(Exception e) {
254   - logger.error("[{}] 关闭RTMPPublisher时出错: {}", tag, e);
255   - }
256   - }
257   -
258   - /**
259   - * 检查并杀掉可能残留的FFmpeg进程
260   - * Java 8兼容实现,使用系统命令查找和终止进程
261   - */
262   - private void checkAndKillOrphanedProcesses() {
263   - try {
264   - logger.debug("[{}] 检查是否有FFmpeg残留进程...", tag);
265   - // 判断操作系统类型
266   - String os = System.getProperty("os.name").toLowerCase();
267   - boolean isWindows = os.contains("win");
268   -
269   - if (isWindows) {
270   - // Windows: 使用tasklist和taskkill
271   - killOrphanedProcessesWindows();
272   - } else {
273   - // Linux/Unix: 使用ps和kill
274   - killOrphanedProcessesUnix();
275   - }
276   - } catch (Exception e) {
277   - logger.error("[{}] 检查残留进程时出错: {}", tag, e.getMessage());
278   - }
279   - }
280   -
281   - /**
282   - * Windows环境下查找并终止残留的FFmpeg进程
283   - */
284   - private void killOrphanedProcessesWindows() {
285   - try {
286   - // 查找包含ffmpeg的进程
287   - ProcessBuilder pb = new ProcessBuilder("tasklist", "/FI", "IMAGENAME eq ffmpeg.exe", "/FO", "CSV", "/NH");
288   - Process process = pb.start();
289   - java.io.BufferedReader reader = new java.io.BufferedReader(
290   - new java.io.InputStreamReader(process.getInputStream()));
291   -
292   - String line;
293   - while ((line = reader.readLine()) != null) {
294   - // CSV格式: "Image Name","PID","Session Name","Session#","Mem Usage"
295   - String[] parts = line.split(",");
296   - if (parts.length > 1) {
297   - String pidStr = parts[1].replaceAll("\"", "").trim();
298   - try {
299   - long pid = Long.parseLong(pidStr);
300   - // 检查命令行是否包含当前tag或wvp-jt1078
301   - ProcessBuilder cmdPb = new ProcessBuilder("wmic", "process", "where", "ProcessId=" + pid, "get", "CommandLine");
302   - Process cmdProcess = cmdPb.start();
303   - java.io.BufferedReader cmdReader = new java.io.BufferedReader(
304   - new java.io.InputStreamReader(cmdProcess.getInputStream()));
305   -
306   - String cmdLine;
307   - boolean shouldKill = false;
308   - while ((cmdLine = cmdReader.readLine()) != null) {
309   - if (cmdLine.toLowerCase().contains("ffmpeg")
310   - && (cmdLine.contains(tag) || cmdLine.contains("wvp-jt1078"))) {
311   - shouldKill = true;
312   - break;
313   - }
314   - }
315   -
316   - if (shouldKill) {
317   - logger.warn("[{}] 发现残留FFmpeg进程[PID:{}],正在终止...", tag, pid);
318   - Process killProcess = Runtime.getRuntime().exec("taskkill /PID " + pid + " /F");
319   - killProcess.waitFor();
320   - logger.info("[{}] 残留FFmpeg进程已终止[PID:{}]", tag, pid);
321   - }
322   - } catch (NumberFormatException e) {
323   - // 忽略非数字PID
324   - }
325   - }
326   - }
327   - } catch (Exception e) {
328   - logger.error("[{}] Windows检查残留进程时出错: {}", tag, e.getMessage());
329   - }
330   - }
331   -
332   - /**
333   - * Linux/Unix环境下查找并终止残留的FFmpeg进程
334   - */
335   - private void killOrphanedProcessesUnix() {
336   - try {
337   - // 查找包含ffmpeg的进程
338   - ProcessBuilder pb = new ProcessBuilder("ps", "-ef");
339   - Process process = pb.start();
340   - java.io.BufferedReader reader = new java.io.BufferedReader(
341   - new java.io.InputStreamReader(process.getInputStream()));
342   -
343   - String line;
344   - while ((line = reader.readLine()) != null) {
345   - // ps -ef 输出格式: UID PID PPID C STIME TTY TIME CMD
346   - if (line.toLowerCase().contains("ffmpeg") && line.contains(tag)) {
347   - String[] parts = line.split("\\s+");
348   - if (parts.length > 1) {
349   - String pidStr = parts[1];
350   - try {
351   - long pid = Long.parseLong(pidStr);
352   - logger.warn("[{}] 发现残留FFmpeg进程[PID:{}],正在终止...", tag, pid);
353   - Process killProcess = Runtime.getRuntime().exec("kill -9 " + pid);
354   - killProcess.waitFor();
355   - logger.info("[{}] 残留FFmpeg进程已终止[PID:{}]", tag, pid);
356   - } catch (NumberFormatException e) {
357   - // 忽略非数字PID
358   - }
359   - }
360   - }
361   - }
362   - } catch (Exception e) {
363   - logger.error("[{}] Unix检查残留进程时出错: {}", tag, e.getMessage());
  175 + logger.error("Error closing RTMPPublisher: " + tag, e);
364 176 }
365 177 }
366 178  
... ... @@ -376,27 +188,4 @@ public class RTMPPublisher extends Thread
376 188 }
377 189 }
378 190 }
379   -
380   - /**
381   - * 获取推流状态
382   - */
383   - public boolean isRunning() {
384   - return running && this.isAlive() && process != null;
385   - }
386   -
387   - /**
388   - * 获取FFmpeg进程信息(用于调试)
389   - */
390   - public String getProcessInfo() {
391   - if (process == null) {
392   - return "process is null";
393   - }
394   - try {
395   - long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0;
396   - return String.format("FFmpeg进程, 已运行%dms, alive=%s",
397   - runDuration, process.isAlive());
398   - } catch (Exception e) {
399   - return "获取进程信息失败: " + e.getMessage();
400   - }
401   - }
402 191 }
... ...
src/main/java/com/genersoft/iot/vmp/media/zlm/ZLMHttpHookListener.java
... ... @@ -211,7 +211,7 @@ public class ZLMHttpHookListener {
211 211 return new HookResultForOnPublish(200, "success");
212 212 }
213 213 // 推流鉴权的处理
214   - if (!"rtp".equals(param.getApp()) && !"schedule".equals(param.getApp())) {
  214 + if (!"rtp".equals(param.getApp()) && !"schedule".equals(param.getApp()) && !"rtp".equals(param.getSchema())) {
215 215 StreamProxyItem stream = streamProxyService.getStreamProxyByAppAndStream(param.getApp(), param.getStream());
216 216 if (stream != null) {
217 217 HookResultForOnPublish result = HookResultForOnPublish.SUCCESS();
... ...
src/main/java/com/genersoft/iot/vmp/service/impl/StreamPushServiceImpl.java
... ... @@ -10,7 +10,6 @@ import com.genersoft.iot.vmp.conf.UserSetting;
10 10 import com.genersoft.iot.vmp.gb28181.bean.*;
11 11 import com.genersoft.iot.vmp.gb28181.event.EventPublisher;
12 12 import com.genersoft.iot.vmp.gb28181.event.subscribe.catalog.CatalogEvent;
13   -import com.genersoft.iot.vmp.jtt1078.util.Configs;
14 13 import com.genersoft.iot.vmp.media.zlm.ZLMRESTfulUtils;
15 14 import com.genersoft.iot.vmp.media.zlm.dto.*;
16 15 import com.genersoft.iot.vmp.media.zlm.dto.hook.OnStreamChangedHookParam;
... ... @@ -24,10 +23,8 @@ import com.genersoft.iot.vmp.storager.mapper.*;
24 23 import com.genersoft.iot.vmp.utils.DateUtil;
25 24 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo;
26 25 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean;
27   -import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.RtspConfigBean;
28 26 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.ThirdPartyHttpService;
29 27 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch;
30   -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9101;
31 28 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9102;
32 29 import com.genersoft.iot.vmp.vmanager.util.RedisCache;
33 30 import com.github.pagehelper.PageHelper;
... ... @@ -58,9 +55,6 @@ public class StreamPushServiceImpl implements IStreamPushService {
58 55 private GbStreamMapper gbStreamMapper;
59 56  
60 57 @Autowired
61   - private RtspConfigBean rtspConfigBean;
62   -
63   - @Autowired
64 58 private StreamPushMapper streamPushMapper;
65 59  
66 60 @Autowired
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/bean/StreamContent.java
... ... @@ -65,6 +65,18 @@ public class StreamContent {
65 65 @Schema(description = "Websockets-FLV流地址")
66 66 private String wss_flv;
67 67  
  68 + @Schema(description = "音频HTTP-FLV流地址")
  69 + private String audio_flv;
  70 +
  71 + @Schema(description = "音频HTTPS-FLV流地址")
  72 + private String audio_https_flv;
  73 +
  74 + @Schema(description = "音频Websocket-FLV流地址")
  75 + private String audio_ws_flv;
  76 +
  77 + @Schema(description = "音频Websockets-FLV流地址")
  78 + private String audio_wss_flv;
  79 +
68 80 /**
69 81 * HTTP-FMP4流地址
70 82 */
... ... @@ -368,6 +380,38 @@ public class StreamContent {
368 380 this.wss_flv = wss_flv;
369 381 }
370 382  
  383 + public String getAudio_flv() {
  384 + return audio_flv;
  385 + }
  386 +
  387 + public void setAudio_flv(String audio_flv) {
  388 + this.audio_flv = audio_flv;
  389 + }
  390 +
  391 + public String getAudio_https_flv() {
  392 + return audio_https_flv;
  393 + }
  394 +
  395 + public void setAudio_https_flv(String audio_https_flv) {
  396 + this.audio_https_flv = audio_https_flv;
  397 + }
  398 +
  399 + public String getAudio_ws_flv() {
  400 + return audio_ws_flv;
  401 + }
  402 +
  403 + public void setAudio_ws_flv(String audio_ws_flv) {
  404 + this.audio_ws_flv = audio_ws_flv;
  405 + }
  406 +
  407 + public String getAudio_wss_flv() {
  408 + return audio_wss_flv;
  409 + }
  410 +
  411 + public void setAudio_wss_flv(String audio_wss_flv) {
  412 + this.audio_wss_flv = audio_wss_flv;
  413 + }
  414 +
371 415 public String getFmp4() {
372 416 return fmp4;
373 417 }
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/Jt1078OfCarController.java
... ... @@ -17,6 +17,7 @@ import com.genersoft.iot.vmp.conf.security.JwtUtils;
17 17 import com.genersoft.iot.vmp.conf.security.dto.JwtUser;
18 18 import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp;
19 19 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager;
  20 +import com.genersoft.iot.vmp.jtt1078.util.Configs;
20 21 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem;
21 22 import com.genersoft.iot.vmp.service.IStreamPushService;
22 23 import com.genersoft.iot.vmp.service.StremProxyService1078;
... ... @@ -66,6 +67,7 @@ import javax.validation.constraints.NotBlank;
66 67 import java.io.ByteArrayOutputStream;
67 68 import java.io.IOException;
68 69 import java.io.InputStream;
  70 +import java.net.URI;
69 71 import java.net.URISyntaxException;
70 72 import java.security.Key;
71 73 import java.security.KeyFactory;
... ... @@ -132,6 +134,10 @@ public class Jt1078OfCarController {
132 134 private static final ConcurrentHashMap<String, String> CHANNEL1_MAP = new ConcurrentHashMap<>();
133 135 private static final ConcurrentHashMap<String, String> CHANNEL2_MAP = new ConcurrentHashMap<>();
134 136 private static final ConcurrentHashMap<String, String> CHANNEL3_MAP = new ConcurrentHashMap<>();
  137 + private static final String AUDIO_SUBSCRIBE_PREFIX = "jt1078:audio:subscribe:";
  138 + private static final long AUDIO_SUBSCRIBE_TTL_SECONDS = 120L;
  139 + private static final int AUDIO_RETRY_TIMES = 6;
  140 + private static final long AUDIO_RETRY_INTERVAL_MS = 300L;
135 141  
136 142 static {
137 143 //'ADAS', 'DSM', '路况', '司机', '整车前', '中门', '倒车', '前门客流', '后面客流'
... ... @@ -428,7 +434,7 @@ public class Jt1078OfCarController {
428 434 * @return
429 435 */
430 436 @GetMapping({"/send/request/io/{sim}/{channel}"})
431   - public StreamContent sendIORequest(@PathVariable String sim, @PathVariable String channel) {
  437 + public StreamContent sendIORequest(@PathVariable String sim, @PathVariable String channel, HttpServletRequest request) {
432 438 if (StringUtils.isBlank(sim)) {
433 439 throw new ControllerException(-100, "sim 不能为空");
434 440 }
... ... @@ -440,12 +446,61 @@ public class Jt1078OfCarController {
440 446 if (Objects.isNull(streamContent) || StringUtils.isBlank(streamContent.getWs_flv())) {
441 447 sendIORequest(stream);
442 448 }
443   - streamContent = getStreamContent(stream);
  449 + streamContent = getStreamContent(stream, request);
444 450 streamContent.setPort(jt1078ConfigBean.getPort());
445   - streamContent.setHttpPort(jt1078ConfigBean.getHttpPort());
  451 + streamContent.setHttpPort(getJttHttpPort());
446 452 return streamContent;
447 453 }
448 454  
  455 + @PostMapping("/audio/subscribe/{sim}/{channel}")
  456 + public Map<String, Object> subscribeAudio(@PathVariable String sim, @PathVariable String channel) {
  457 + if (StringUtils.isBlank(sim) || StringUtils.isBlank(channel)) {
  458 + throw new ControllerException(ErrorCode.ERROR400);
  459 + }
  460 + String stream = buildAudioStream(sim, channel);
  461 + String key = buildAudioSubscribeRedisKeyByStream(stream);
  462 + redisTemplate.opsForValue().set(key, "1", AUDIO_SUBSCRIBE_TTL_SECONDS, TimeUnit.SECONDS);
  463 + Map<String, Object> result = new HashMap<>();
  464 + result.put("stream", stream);
  465 + result.put("enabled", true);
  466 + result.put("ttl", AUDIO_SUBSCRIBE_TTL_SECONDS);
  467 + StreamContent audioStreamContent = ensureAudioStreamReady(stream);
  468 + if (audioStreamContent != null) {
  469 + result.put("audio_flv", audioStreamContent.getAudio_flv());
  470 + result.put("audio_https_flv", audioStreamContent.getAudio_https_flv());
  471 + result.put("audio_ws_flv", audioStreamContent.getAudio_ws_flv());
  472 + result.put("audio_wss_flv", audioStreamContent.getAudio_wss_flv());
  473 + }
  474 + return result;
  475 + }
  476 +
  477 + @DeleteMapping("/audio/subscribe/{sim}/{channel}")
  478 + public Map<String, Object> unsubscribeAudio(@PathVariable String sim, @PathVariable String channel) {
  479 + if (StringUtils.isBlank(sim) || StringUtils.isBlank(channel)) {
  480 + throw new ControllerException(ErrorCode.ERROR400);
  481 + }
  482 + String stream = buildAudioStream(sim, channel);
  483 + String key = buildAudioSubscribeRedisKeyByStream(stream);
  484 + redisTemplate.delete(key);
  485 + Map<String, Object> result = new HashMap<>();
  486 + result.put("stream", stream);
  487 + result.put("enabled", false);
  488 + return result;
  489 + }
  490 +
  491 + @DeleteMapping("/audio/subscribe/all")
  492 + public Map<String, Object> clearAudioSubscribe() {
  493 + Set<Object> keys = redisTemplate.keys(AUDIO_SUBSCRIBE_PREFIX + "*");
  494 + int deleted = 0;
  495 + if (CollectionUtils.isNotEmpty(keys)) {
  496 + deleted = keys.size();
  497 + redisTemplate.delete(keys);
  498 + }
  499 + Map<String, Object> result = new HashMap<>();
  500 + result.put("deleted", deleted);
  501 + return result;
  502 + }
  503 +
449 504  
450 505 @PostMapping("/switch/stream")
451 506 public int switchStream(@RequestBody StreamSwitch streamSwitch){
... ... @@ -462,14 +517,14 @@ public class Jt1078OfCarController {
462 517 * @param streamList 流唯一值集合 {sim-channel}
463 518 */
464 519 @PostMapping({"/beachSend/request/io"})
465   - public List<StreamContent> beachSendIORequest(@RequestBody List<String> streamList) {
  520 + public List<StreamContent> beachSendIORequest(@RequestBody List<String> streamList, HttpServletRequest request) {
466 521 if (CollectionUtils.isEmpty(streamList)) {
467 522 throw new ControllerException(ErrorCode.ERROR400);
468 523 }
469 524 List<StreamContent> list = new ArrayList<>();
470 525 for (String stream : streamList) {
471 526 sendIORequest(stream);
472   - list.add(getStreamContent(stream));
  527 + list.add(getStreamContent(stream, request));
473 528 }
474 529 return list;
475 530 }
... ... @@ -492,7 +547,7 @@ public class Jt1078OfCarController {
492 547 redisTemplate.opsForValue().set("patrol:stream:" + stream, patrolDataReq);
493 548 return stream;
494 549 }).distinct().collect(Collectors.toList());
495   - return beachSendIORequest(simList);
  550 + return beachSendIORequest(simList, null);
496 551 }
497 552  
498 553 /**
... ... @@ -504,6 +559,26 @@ public class Jt1078OfCarController {
504 559 redisTemplate.delete(keys);
505 560 }
506 561  
  562 + public static String normalizeSim(String sim) {
  563 + if (StringUtils.isBlank(sim)) {
  564 + return sim;
  565 + }
  566 + String normalized = sim.replaceAll("^0+", "");
  567 + return StringUtils.isBlank(normalized) ? "0" : normalized;
  568 + }
  569 +
  570 + public static String buildAudioStream(String sim, String channel) {
  571 + return StringUtils.join(normalizeSim(sim), "-", StringUtils.trim(channel));
  572 + }
  573 +
  574 + public static String buildAudioSubscribeRedisKey(String sim, String channel) {
  575 + return AUDIO_SUBSCRIBE_PREFIX + buildAudioStream(sim, channel);
  576 + }
  577 +
  578 + public static String buildAudioSubscribeRedisKeyByStream(String stream) {
  579 + return AUDIO_SUBSCRIBE_PREFIX + stream;
  580 + }
  581 +
507 582 /**
508 583 * 获取播放地址对外接口
509 584 *
... ... @@ -1019,7 +1094,7 @@ public class Jt1078OfCarController {
1019 1094 HttpClientPostEntity entity1 = this.httpClientUtil.doPost(url, msg, (String) null);
1020 1095 //chooseEntity(entity1, url, true);
1021 1096 resultMap.put("code", "1");
1022   - streamContent = this.getStreamContent(channelMapping);
  1097 + streamContent = this.getStreamContent(channelMapping, request);
1023 1098 log.info("StreamContent:[{}]", streamContent);
1024 1099 resultMap.put("data", streamContent);
1025 1100 return resultMap;
... ... @@ -1155,7 +1230,7 @@ public class Jt1078OfCarController {
1155 1230  
1156 1231  
1157 1232 @Nullable
1158   - private StreamContent getStreamContent(String stream) {
  1233 + private StreamContent getStreamContent(String stream, HttpServletRequest request) {
1159 1234 StreamContent streamContent = getStreamContentPlayURL(stream);
1160 1235 if (Objects.isNull(streamContent) || StringUtils.isEmpty(streamContent.getWs_flv())) {
1161 1236 streamContent = new StreamContent();
... ... @@ -1164,6 +1239,7 @@ public class Jt1078OfCarController {
1164 1239 streamContent.setWss_flv(StringUtils.replace(jt1078ConfigBean.getWss() + authKey, "{stream}", stream));
1165 1240 streamContent.setFlv(StringUtils.replace(jt1078ConfigBean.getDownloadFlv() + authKey, "{stream}", stream));
1166 1241 }
  1242 + appendAudioPlayUrl(streamContent, stream, request);
1167 1243 return streamContent;
1168 1244 }
1169 1245  
... ... @@ -1180,6 +1256,106 @@ public class Jt1078OfCarController {
1180 1256 return streamContent;
1181 1257 }
1182 1258  
  1259 + private void appendAudioPlayUrl(StreamContent streamContent, String stream, HttpServletRequest request) {
  1260 + if (streamContent == null || StringUtils.isBlank(stream)) {
  1261 + return;
  1262 + }
  1263 + StreamContent audioStreamContent = getAudioStreamContent(stream);
  1264 + if (audioStreamContent == null) {
  1265 + return;
  1266 + }
  1267 + streamContent.setAudio_flv(audioStreamContent.getAudio_flv());
  1268 + streamContent.setAudio_https_flv(audioStreamContent.getAudio_https_flv());
  1269 + streamContent.setAudio_ws_flv(audioStreamContent.getAudio_ws_flv());
  1270 + streamContent.setAudio_wss_flv(audioStreamContent.getAudio_wss_flv());
  1271 + }
  1272 +
  1273 + private StreamContent ensureAudioStreamReady(String stream) {
  1274 + StreamContent streamContent = getRegisteredAudioStreamContent(stream);
  1275 + if (streamContent != null) {
  1276 + return streamContent;
  1277 + }
  1278 + boolean restarted = PublishManager.getInstance().ensureAudioPublisher(stream);
  1279 + if (restarted) {
  1280 + log.info("音频流未注册,已触发音频 FFmpeg 重启: {}", stream);
  1281 + } else {
  1282 + log.warn("音频流未注册,且未找到可重启的推流通道: {}", stream);
  1283 + }
  1284 + for (int i = 0; i < AUDIO_RETRY_TIMES; i++) {
  1285 + try {
  1286 + Thread.sleep(AUDIO_RETRY_INTERVAL_MS);
  1287 + } catch (InterruptedException e) {
  1288 + Thread.currentThread().interrupt();
  1289 + break;
  1290 + }
  1291 + streamContent = getRegisteredAudioStreamContent(stream);
  1292 + if (streamContent != null) {
  1293 + return streamContent;
  1294 + }
  1295 + }
  1296 + log.warn("音频流重注册超时,返回回退地址: {}", stream);
  1297 + return getAudioStreamContent(stream);
  1298 + }
  1299 +
  1300 + private StreamContent getRegisteredAudioStreamContent(String stream) {
  1301 + String audioStream = stream + "-audio";
  1302 + StreamContent streamContent = getStreamContentPlayURL(audioStream);
  1303 + if (streamContent == null || StringUtils.isBlank(streamContent.getWs_flv())) {
  1304 + return null;
  1305 + }
  1306 + streamContent.setAudio_flv(streamContent.getFlv());
  1307 + streamContent.setAudio_https_flv(streamContent.getHttps_flv());
  1308 + streamContent.setAudio_ws_flv(streamContent.getWs_flv());
  1309 + streamContent.setAudio_wss_flv(streamContent.getWss_flv());
  1310 + return streamContent;
  1311 + }
  1312 +
  1313 + private StreamContent getAudioStreamContent(String stream) {
  1314 + StreamContent streamContent = getRegisteredAudioStreamContent(stream);
  1315 + if (streamContent != null) {
  1316 + return streamContent;
  1317 + }
  1318 +
  1319 + String audioStream = stream + "-audio";
  1320 + StreamContent fallback = new StreamContent();
  1321 + String authKey = jt1078ConfigBean.getPushKey();
  1322 + fallback.setAudio_flv(StringUtils.replace(jt1078ConfigBean.getDownloadFlv() + authKey, "{stream}", audioStream));
  1323 + fallback.setAudio_ws_flv(StringUtils.replace(jt1078ConfigBean.getWs() + authKey, "{stream}", audioStream));
  1324 + fallback.setAudio_wss_flv(StringUtils.replace(jt1078ConfigBean.getWss() + authKey, "{stream}", audioStream));
  1325 + try {
  1326 + URI wssUri = new URI(fallback.getAudio_wss_flv());
  1327 + fallback.setAudio_https_flv("https://" + wssUri.getRawAuthority() + "/schedule/" + audioStream + ".live.flv" + authKey);
  1328 + } catch (Exception e) {
  1329 + fallback.setAudio_https_flv(fallback.getAudio_wss_flv());
  1330 + }
  1331 + return fallback;
  1332 + }
  1333 +
  1334 + private String buildAudioUrlByVideoUrl(String videoUrl, String audioPath) {
  1335 + if (StringUtils.isBlank(videoUrl) || StringUtils.isBlank(audioPath)) {
  1336 + return null;
  1337 + }
  1338 + try {
  1339 + URI uri = new URI(videoUrl);
  1340 + String scheme = uri.getScheme();
  1341 + String authority = uri.getRawAuthority();
  1342 + if (StringUtils.isBlank(scheme) || StringUtils.isBlank(authority)) {
  1343 + return null;
  1344 + }
  1345 + String query = uri.getRawQuery();
  1346 + if (StringUtils.isNotBlank(query)) {
  1347 + return String.format("%s://%s%s?%s", scheme, authority, audioPath, query);
  1348 + }
  1349 + return String.format("%s://%s%s", scheme, authority, audioPath);
  1350 + } catch (Exception e) {
  1351 + return null;
  1352 + }
  1353 + }
  1354 +
  1355 + private int getJttHttpPort() {
  1356 + return Configs.getInt("server.http.port", jt1078ConfigBean.getHttpPort());
  1357 + }
  1358 +
1183 1359 /**
1184 1360 * 获取视频播放地址
1185 1361 *
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/config/GB28181MessageListener.java
... ... @@ -32,7 +32,7 @@ public class GB28181MessageListener {
32 32 @Resource
33 33 private HisToryRecordService hisToryRecordService;
34 34  
35   - @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME)
  35 + @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME, autoStartup = "false")
36 36 public void receiveMessage(String convert, Channel channel, Message message) {
37 37 // 处理消息逻辑(示例:打印消息内容)
38 38 log.info("rabbitmq 接收 [{}] 队列消息 : {}",RabbitMQConfig.QUEUE_NAME, message);
... ...
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.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; } }
2 1 \ No newline at end of file
  2 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.config; import com.genersoft.iot.vmp.jtt1078.util.Configs; 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 3 \ No newline at end of file
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/controller/IntercomController.java
... ... @@ -31,4 +31,14 @@ public class IntercomController {
31 31 return AjaxResult.error("建立连接失败: " + e.getMessage());
32 32 }
33 33 }
  34 +
  35 + @PostMapping("/fail/{sim}")
  36 + public AjaxResult intercomStartFail(@PathVariable String sim) {
  37 + try {
  38 + intercomService.releaseIntercomLock(sim);
  39 + return AjaxResult.success();
  40 + } catch (Exception e) {
  41 + return AjaxResult.error("释放对讲占用锁失败: " + e.getMessage());
  42 + }
  43 + }
34 44 }
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/IntercomService.java
... ... @@ -12,4 +12,10 @@ public interface IntercomService {
12 12 String startIntercom(String sim) throws ServiceException;
13 13  
14 14 void stopIntercom(String sim);
  15 +
  16 + /**
  17 + * 对讲启动失败时释放占用锁(仅清理Redis锁,不下发9102)
  18 + * @param sim 设备SIM卡号
  19 + */
  20 + void releaseIntercomLock(String sim);
15 21 }
... ...
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/service/impl/IntercomServiceImpl.java
... ... @@ -120,6 +120,19 @@ public class IntercomServiceImpl implements IntercomService {
120 120 }
121 121  
122 122 /**
  123 + * 对讲初始化失败时,释放Redis占用锁,避免后续被误判占用
  124 + */
  125 + @Override
  126 + public void releaseIntercomLock(String sim) {
  127 + if (StringUtils.isBlank(sim)) {
  128 + return;
  129 + }
  130 + String trimmedSim = sim.replaceFirst("^0+(?=\\d)", "");
  131 + redisCache.deleteObject("intercom:" + trimmedSim);
  132 + log.info("已释放对讲占用锁: intercom:{}", trimmedSim);
  133 + }
  134 +
  135 + /**
123 136 * 构造 WebSocket 地址
124 137 * 目标网关: 118.113.164.50:10202
125 138 * 协议格式参考提供的 HTML: ws://IP:PORT?sim={sim}
... ...
src/main/resources/app.properties
... ... @@ -14,7 +14,7 @@ rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign}
14 14  
15 15 #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign}
16 16 # 设置为on时,控制台将输出ffmpeg的输出
17   -debug.mode = on
  17 +debug.mode = off
18 18  
19 19 zlm.host = 127.0.0.1
20 20 zlm.http.port = 80
... ...
src/main/resources/application-local-100.yml 0 → 100644
  1 +my:
  2 + ip: 61.169.120.202
  3 +spring:
  4 + rabbitmq:
  5 + host: 10.10.2.21
  6 + port: 5672
  7 + username: bsth
  8 + password: bsth001
  9 + virtual-host: /dsm
  10 + listener:
  11 + simple:
  12 + acknowledge-mode: manual # 设置为手动确认
  13 + # 设置接口超时时间
  14 + mvc:
  15 + async:
  16 + request-timeout: 20000
  17 + thymeleaf:
  18 + cache: false
  19 + # [可选]上传文件大小限制
  20 + servlet:
  21 + multipart:
  22 + max-file-size: 10MB
  23 + max-request-size: 100MB
  24 + # REDIS数据库配置
  25 + redis:
  26 + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
  27 + host: 192.168.169.100
  28 + # [必须修改] 端口号
  29 + port: 6879
  30 + # [可选] 数据库 DB
  31 + database: 7
  32 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
  33 + password: wvp4@444
  34 + # [可选] 超时时间
  35 + timeout: 10000
  36 + jedis:
  37 + pool:
  38 + max-active: 32
  39 + max-wait: 10000
  40 + max-idle: 16
  41 + min-idle: 8
  42 + # mysql数据源
  43 + datasource:
  44 + dynamic:
  45 + primary: master
  46 + datasource:
  47 + master:
  48 + type: com.zaxxer.hikari.HikariDataSource
  49 + driver-class-name: com.mysql.cj.jdbc.Driver
  50 + url: jdbc:mysql://192.168.169.100:3306/wvp4?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=TRUE&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&sessionVariables=sql_mode='NO_ENGINE_SUBSTITUTION'&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull
  51 + username: root
  52 + password: guzijian
  53 + hikari:
  54 + connection-timeout: 20000 # 是客户端等待连接池连接的最大毫秒数
  55 + initialSize: 50 # 连接池初始化连接数
  56 + maximum-pool-size: 200 # 连接池最大连接数
  57 + minimum-idle: 10 # 连接池最小空闲连接数
  58 + idle-timeout: 300000 # 允许连接在连接池中空闲的最长时间(以毫秒为单位)
  59 + max-lifetime: 1200000 # 是池中连接关闭后的最长生命周期(以毫秒为单位)
  60 +#[可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
  61 +server:
  62 + port: 18090
  63 + # [可选] HTTPS配置, 默认不开启
  64 + ssl:
  65 + # [可选] 是否开启HTTPS访问
  66 + enabled: false
  67 + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
  68 + key-store: classpath:Aserver.keystore
  69 + # [可选] 证书密码
  70 + key-store-password: guzijian
  71 + # [可选] 证书类型, 默认为jks,根据实际修改
  72 + key-store-type: JKS
  73 +
  74 +# 作为28181服务器的配置
  75 +sip:
  76 + # [必须修改] 本机的IP,对应你的网卡,监听什么ip就是使用什么网卡,
  77 + # 如果要监听多张网卡,可以使用逗号分隔多个IP, 例如: 192.168.1.4,10.0.0.4
  78 + # 如果不明白,就使用0.0.0.0,大部分情况都是可以的
  79 + # 请不要使用127.0.0.1,任何包括localhost在内的域名都是不可以的。
  80 + ip: ${my.ip}
  81 + # wanIp: 61.169.120.202 # zlm所在服务器的公网IP
  82 + # [可选] 28181服务监听的端口
  83 + port: 17008
  84 +
  85 + # 根据国标6.1.2中规定,domain宜采用ID统一编码的前十位编码。国标附录D中定义前8位为中心编码(由省级、市级、区级、基层编号组成,参照GB/T 2260-2007)
  86 + # 后两位为行业编码,定义参照附录D.3
  87 + # 3701020049标识山东济南历下区 信息行业接入
  88 + # [可选]
  89 + domain: 4401000000
  90 + # [可选]
  91 + id: 44010000001110008003
  92 + # [可选] 默认设备认证密码,后续扩展使用设备单独密码, 移除密码将不进行校验
  93 + password: 12345678
  94 + # 是否存储alarm信息
  95 + alarm: true
  96 +
  97 +#zlm 默认服务器配置
  98 +#zlm 默认服务器配置
  99 +media:
  100 + id: guzijian1
  101 + # [必须修改] zlm服务器的内网IP
  102 + ip: 192.168.169.100
  103 + # [必须修改] zlm服务器的http.port
  104 + http-port: 1909
  105 + # [可选] 返回流地址时的ip,置空使用 media.ip 1
  106 + stream-ip: 61.169.120.202
  107 + # [可选] wvp在国标信令中使用的ip,此ip为摄像机可以访问到的ip, 置空使用 media.ip 1
  108 + sdp-ip: 61.169.120.202
  109 + # [可选] zlm服务器的hook所使用的IP, 默认使用sip.ip
  110 + hook-ip: 192.168.169.100
  111 + # [可选] zlm服务器的http.sslport, 置空使用zlm配置文件配置
  112 + http-ssl-port: 2939
  113 + # [可选] zlm服务器的hook.admin_params=secret
  114 + secret: 8KMYsD5ItKkHN1CIcPI9VeLa6u4S8deU
  115 + pushKey: 41db35390ddad33f83944f44b8b75ded
  116 + # 启用多端口模式, 多端口模式使用端口区分每路流,兼容性更好。 单端口使用流的ssrc区分, 点播超时建议使用多端口测试
  117 + rtp:
  118 + # [可选] 是否启用多端口模式, 开启后会在portRange范围内选择端口用于媒体流传输
  119 + enable: true
  120 + # [可选] 在此范围内选择端口用于媒体流传输, 必须提前在zlm上配置该属性,不然自动配置此属性可能不成功
  121 + port-range: 52000,52500 # 端口范围
  122 + # [可选] 国标级联在此范围内选择端口发送媒体流,
  123 + send-port-range: 52000,52500 # 端口范围
  124 + # 录像辅助服务, 部署此服务可以实现zlm录像的管理与下载, 0 表示不使用
  125 + record-assist-port: 18081
  126 +# [根据业务需求配置]
  127 +user-settings:
  128 + # 点播/录像回放 等待超时时间,单位:毫秒
  129 + play-timeout: 180000
  130 + # [可选] 自动点播, 使用固定流地址进行播放时,如果未点播则自动进行点播, 需要rtp.enable=true
  131 + auto-apply-play: true
  132 + # 设备/通道状态变化时发送消息
  133 + device-status-notify: true
  134 + # 推流直播是否录制
  135 + # record-push-live: true
  136 + # 是否开启接口鉴权
  137 + # interface-authentication: false
  138 + # 国标是否录制
  139 + # record-sip: true
  140 + # 等待音视频编码信息再返回, true: 可以根据编码选择合适的播放器,false: 可以更快点播
  141 +# wait-track: true
  142 +# [可选] 日志配置, 一般不需要改
  143 +logging:
  144 + config: classpath:logback-spring.xml
  145 +jt1078:
  146 + enable: true
  147 + port: 9998
  148 +
  149 +# MyBatis配置
  150 +mybatis-plus:
  151 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  152 + mapperLocations: classpath*:mapper/**/*Mapper.xml
  153 + # 配置mapper的扫描,找到所有的mapper.xml映射文件
  154 + #mapperLocations: classpath*:mapper/**/*Mapper.xml
  155 +tuohua:
  156 + bsth:
  157 + login:
  158 + pageURL: http://${my.ip}:9088/user/login/jCryptionKey
  159 + url: http://${my.ip}:9088/user/login
  160 +
  161 + userName: yuanxiaohu
  162 + password: Yxiaohu1.0
  163 + rest:
  164 + # baseURL: http://10.10.2.20:9089/webservice/rest
  165 + # password: bafb2b44a07a02e5e9912f42cd197423884116a8
  166 + # baseURL: http://113.249.109.139:9089/webservice/rest
  167 + baseURL: http://192.168.168.152:9089/webservice/rest
  168 + password: bafb2b44a07a02e5e9912f42cd197423884116a8
  169 + tree:
  170 + url:
  171 + company: http://${my.ip}:9088/video/tree
  172 + car: http://${my.ip}:9088/video/tree/carNo/{0}
  173 + sim: http://${my.ip}:9088/video/tree/caNO/sim/{0}
  174 + wvp28181:
  175 + rtsp:
  176 + tcpPort: 11078
  177 + udpPort: 11078
  178 + historyTcpPort: 9999
  179 + historyUdpPort: 9999
  180 + ip : 61.169.120.202
  181 + jt1078:
  182 + ws-prefix: ws://192.168.1.117:18090
  183 + ports: 49101,49101
  184 + port: 9100
  185 + httpPort: 3333
  186 + addPortVal: 0
  187 + pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort}
  188 + stopPushURL: http://${my.ip}:3333/stop/channel/{pushKey}/{port}/{httpPort}
  189 + # url: http://10.10.2.20:8100/device/{0}
  190 + # new_url: http://10.10.2.20:8100/device
  191 + # url: http://113.249.109.139:8100/device/{0}
  192 + # new_url: http://113.249.109.139:8100/device
  193 + url: http://192.168.168.152:8100/device/{0}
  194 + new_url: http://192.168.168.152:8100/device
  195 + historyListPort: 9205
  196 + history_upload: 9206
  197 + playHistoryPort: 9201
  198 + sendPort: 9101
  199 + stopSendPort: 9102
  200 + ws: ws://61.169.120.202:1909/schedule/{stream}.live.flv
  201 + wss: wss://61.169.120.202:443/schedule/{stream}.live.flv
  202 + downloadFLV: http://61.169.120.202/schedule/{stream}.live.flv
  203 + get:
  204 + #url: http://192.169.1.92:{port}/video/{stream}.flv
  205 + url: http://192.168.169.100:3333/video/{stream}
  206 + playURL: /play/wasm/ws%3A%2F%2F{ip}%3A{port}%2Fschedule%2F{sim}-{channel}.live.flv%3FcallId%{publickey}
  207 +
  208 +ftp:
  209 + basePath: /wvp-local
  210 + within_host: 61.169.120.202
  211 + host: 61.169.120.202
  212 + httpPath: ftp://61.169.120.202
  213 + filePathPrefix: http://61.169.120.202:10021/wvp-local
  214 + password: ftp@123
  215 + port: 10021
  216 + username: ftpadmin
  217 + retryTimes: 5
  218 + retryWaitTimes: 3000
  219 + # 视频过期时间 单位:天
  220 + expirationTime: 7
  221 + # 未上传成功超时时间 单位:秒
  222 + timeOut: 300
  223 +
  224 +forest:
  225 + backend: okhttp3 # 后端HTTP框架(默认为 okhttp3)
  226 + max-connections: 1000 # 连接池最大连接数(默认为 500)
  227 + max-route-connections: 500 # 每个路由的最大连接数(默认为 500)
  228 + max-request-queue-size: 100 # [自v1.5.22版本起可用] 最大请求等待队列大小
  229 + max-async-thread-size: 300 # [自v1.5.21版本起可用] 最大异步线程数
  230 + max-async-queue-size: 16 # [自v1.5.22版本起可用] 最大异步线程池队列大小
  231 + timeout: 3000 # [已不推荐使用] 请求超时时间,单位为毫秒(默认为 3000)
  232 + connect-timeout: 3000 # 连接超时时间,单位为毫秒(默认为 timeout)
  233 + read-timeout: 3000 # 数据读取超时时间,单位为毫秒(默认为 timeout)
  234 + max-retry-count: 0 # 请求失败后重试次数(默认为 0 次不重试)
  235 + # ssl-protocol: TLS # 单向验证的HTTPS的默认TLS协议(默认为 TLS)
  236 + log-enabled: true # 打开或关闭日志(默认为 true)
  237 + log-request: true # 打开/关闭Forest请求日志(默认为 true)
  238 + log-response-status: true # 打开/关闭Forest响应状态日志(默认为 true)
  239 + log-response-content: true # 打开/关闭Forest响应内容日志(默认为 false)
  240 +# async-mode: platform # [自v1.5.27版本起可用] 异步模式(默认为 platform)
  241 +
  242 +
  243 +
... ...
src/main/resources/application-wx-local.yml
... ... @@ -174,8 +174,8 @@ tuohua:
174 174 ip : 61.169.120.202
175 175 jt1078:
176 176 ws-prefix: ws://192.168.1.117:18090
177   - ports: 40001,40001
178   - port: 40000
  177 + ports: 40011,40011
  178 + port: 40010
179 179 httpPort: 3333
180 180 addPortVal: 0
181 181 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort}
... ...
web_src/config/index.js
... ... @@ -27,9 +27,9 @@ module.exports = {
27 27 },
28 28  
29 29 // Various Dev Server settings
30   - host: "127.0.0.1",
  30 + host: "192.168.168.21",
31 31 useLocalIp: false, // can be overwritten by process.env.HOST
32   - port: 8085, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
  32 + port: 40018, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
33 33 autoOpenBrowser: false,
34 34 errorOverlay: true,
35 35 notifyOnErrors: true,
... ...
web_src/src/components/DeviceList1078.vue
... ... @@ -84,12 +84,14 @@
84 84 <player-list-component
85 85 ref="playListComponent"
86 86 @playerClick="handleClick"
  87 + @audio-toggle="handlePlayerAudioToggle"
87 88 :video-url="videoUrl"
88 89 :videoDataList="videoDataList"
89 90 :has-audio="true"
90 91 v-model="windowNum"
91 92 style="width: 100%; height: 100%;"
92 93 ></player-list-component>
  94 + <audio-player1078 ref="audioPlayer1078"></audio-player1078>
93 95 </el-main>
94 96 </el-container>
95 97 <el-dialog
... ... @@ -126,6 +128,7 @@ import VehicleList from &quot;./JT1078Components/deviceList/VehicleList.vue&quot;;
126 128 import CarouselConfig from "./CarouselConfig.vue";
127 129 import WindowNumSelect from "./WindowNumSelect.vue";
128 130 import PlayerListComponent from './common/PlayerListComponent.vue';
  131 +import AudioPlayer1078 from './common/AudioPlayer1078.vue';
129 132 import VideoPlayer from './common/EasyPlayer.vue';
130 133 const PCMA_TO_PCM = new Int16Array(256);
131 134 for (let i = 0; i < 256; i++) {
... ... @@ -143,6 +146,7 @@ export default {
143 146 CarouselConfig,
144 147 WindowNumSelect,
145 148 PlayerListComponent,
  149 + AudioPlayer1078,
146 150 VideoPlayer
147 151 },
148 152 data() {
... ... @@ -192,7 +196,13 @@ export default {
192 196 retryTimer: null,
193 197 // 对讲音频播放器
194 198 talkFlvPlayer: null,
195   - talkAudioElement: null
  199 + talkAudioElement: null,
  200 +
  201 + activeAudioPlayerIndex: -1,
  202 + activeAudioStream: '',
  203 + activeAudioSim: '',
  204 + activeAudioChannel: '',
  205 + isAudioSwitching: false
196 206 };
197 207 },
198 208 mounted() {
... ... @@ -206,6 +216,8 @@ export default {
206 216 document.removeEventListener('click', this.hideContextMenu);
207 217 this.stopCarousel();
208 218 this.stopIntercom(); // 确保组件销毁时停止对讲
  219 + this.stopActiveAudio(true);
  220 + this.clearAudioSubscribe();
209 221 },
210 222 // 路由离开守卫
211 223 beforeRouteLeave(to, from, next) {
... ... @@ -214,9 +226,13 @@ export default {
214 226 type: 'warning'
215 227 }).then(() => {
216 228 this.stopCarousel();
  229 + this.stopActiveAudio(true);
  230 + this.clearAudioSubscribe();
217 231 next();
218 232 }).catch(() => next(false));
219 233 } else {
  234 + this.stopActiveAudio(true);
  235 + this.clearAudioSubscribe();
220 236 next();
221 237 }
222 238 },
... ... @@ -238,6 +254,123 @@ export default {
238 254 this.windowClickIndex = index + 1;
239 255 this.windowClickData = data;
240 256 },
  257 + resolveAudioUrl(channelInfo) {
  258 + if (!channelInfo) return '';
  259 + if (location.protocol === 'https:') {
  260 + return channelInfo.audio_wss_flv || channelInfo.audio_https_flv || channelInfo.audio_ws_flv || channelInfo.audio_flv || '';
  261 + }
  262 + return channelInfo.audio_ws_flv || channelInfo.audio_flv || channelInfo.audio_wss_flv || channelInfo.audio_https_flv || '';
  263 + },
  264 + resolveAudioUrlFromSubscribeData(data) {
  265 + if (!data) return '';
  266 + if (location.protocol === 'https:') {
  267 + return data.audio_wss_flv || data.audio_https_flv || data.audio_ws_flv || data.audio_flv || '';
  268 + }
  269 + return data.audio_ws_flv || data.audio_flv || data.audio_wss_flv || data.audio_https_flv || '';
  270 + },
  271 + subscribeAudio(sim, channel) {
  272 + return this.$axios.post(`/api/jt1078/query/audio/subscribe/${sim}/${channel}`);
  273 + },
  274 + unsubscribeAudio(sim, channel) {
  275 + return this.$axios.delete(`/api/jt1078/query/audio/subscribe/${sim}/${channel}`);
  276 + },
  277 + clearAudioSubscribe() {
  278 + return this.$axios.delete('/api/jt1078/query/audio/subscribe/all').catch(() => {});
  279 + },
  280 + async resetAudioToDefaultMute() {
  281 + if (this.activeAudioSim && this.activeAudioChannel) {
  282 + await this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  283 + }
  284 + this.stopActiveAudio(true);
  285 + },
  286 + stopActiveAudio(clearState = false) {
  287 + const audioPlayer = this.$refs.audioPlayer1078;
  288 + if (audioPlayer && typeof audioPlayer.stopAudio === 'function') {
  289 + audioPlayer.stopAudio();
  290 + }
  291 + if (clearState) {
  292 + this.activeAudioPlayerIndex = -1;
  293 + this.activeAudioStream = '';
  294 + this.activeAudioSim = '';
  295 + this.activeAudioChannel = '';
  296 + }
  297 + },
  298 + async handlePlayerAudioToggle(payload) {
  299 + if (!payload || payload.playerIndex === undefined || this.isAudioSwitching) return;
  300 +
  301 + const { playerIndex, muted, sim, channel, channelInfo } = payload;
  302 + const fallbackAudioUrl = this.resolveAudioUrl(channelInfo);
  303 + console.info('[DeviceList1078] audio-toggle payload', payload, 'audioUrl=', fallbackAudioUrl);
  304 + const playList = this.$refs.playListComponent;
  305 + this.isAudioSwitching = true;
  306 +
  307 + try {
  308 + if (muted) {
  309 + if (this.activeAudioPlayerIndex === playerIndex) {
  310 + if (sim && channel) {
  311 + const res = await this.unsubscribeAudio(sim, channel);
  312 + console.info('[DeviceList1078] unsubscribe response', res && res.data);
  313 + }
  314 + this.stopActiveAudio(true);
  315 + }
  316 + return;
  317 + }
  318 +
  319 + if (!sim || !channel) {
  320 + this.$message.warning('当前通道标识不可用');
  321 + if (playList && typeof playList.setPlayerMuted === 'function') {
  322 + playList.setPlayerMuted(playerIndex, true);
  323 + }
  324 + return;
  325 + }
  326 +
  327 + const isSameActiveStream = this.activeAudioSim === sim && this.activeAudioChannel === channel;
  328 + if (isSameActiveStream) {
  329 + if (playList && typeof playList.muteAllPlayersExcept === 'function') {
  330 + playList.muteAllPlayersExcept(playerIndex);
  331 + }
  332 + this.activeAudioPlayerIndex = playerIndex;
  333 + return;
  334 + }
  335 +
  336 + if (playList && typeof playList.muteAllPlayersExcept === 'function') {
  337 + playList.muteAllPlayersExcept(playerIndex);
  338 + }
  339 +
  340 + if (this.activeAudioSim && this.activeAudioChannel &&
  341 + (this.activeAudioSim !== sim || this.activeAudioChannel !== channel)) {
  342 + await this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  343 + }
  344 +
  345 + const subRes = await this.subscribeAudio(sim, channel);
  346 + console.info('[DeviceList1078] subscribe response', subRes && subRes.data);
  347 + const subscribeData = subRes && subRes.data ? subRes.data.data : null;
  348 + const audioUrl = this.resolveAudioUrlFromSubscribeData(subscribeData) || fallbackAudioUrl;
  349 + if (!audioUrl) {
  350 + this.$message.warning('后端未返回音频播放地址');
  351 + if (playList && typeof playList.setPlayerMuted === 'function') {
  352 + playList.setPlayerMuted(playerIndex, true);
  353 + }
  354 + return;
  355 + }
  356 + const audioPlayer = this.$refs.audioPlayer1078;
  357 + if (audioPlayer && typeof audioPlayer.playAudio === 'function') {
  358 + audioPlayer.playAudio(audioUrl);
  359 + }
  360 +
  361 + this.activeAudioPlayerIndex = playerIndex;
  362 + this.activeAudioStream = audioUrl;
  363 + this.activeAudioSim = sim;
  364 + this.activeAudioChannel = channel;
  365 + } catch (e) {
  366 + this.$message.error('切换音频失败');
  367 + if (playList && typeof playList.setPlayerMuted === 'function') {
  368 + playList.setPlayerMuted(playerIndex, true);
  369 + }
  370 + } finally {
  371 + this.isAudioSwitching = false;
  372 + }
  373 + },
241 374 toggleFullscreen() {
242 375 const element = this.$refs.videoMain.$el;
243 376 if (!this.isFullscreen) {
... ... @@ -375,6 +508,7 @@ export default {
375 508  
376 509 if (!wssUrl || !wssUrl.startsWith('ws')) {
377 510 loading.close();
  511 + this.notifyIntercomStartFailed(data.sim, 'invalid_ws_url');
378 512 this.$message.error("后端返回的 WebSocket 地址无效");
379 513 return;
380 514 }
... ... @@ -392,6 +526,15 @@ export default {
392 526 });
393 527 },
394 528  
  529 + notifyIntercomStartFailed(sim, reason = '') {
  530 + if (sim === undefined || sim === null || sim === '') return;
  531 + const simStr = String(sim);
  532 + this.$axios.post(`/api/jt1078/intercom/fail/${simStr}`).catch(() => {});
  533 + if (reason) {
  534 + console.warn('[intercom] start failed, lock released:', simStr, reason);
  535 + }
  536 + },
  537 +
395 538 // 3. 初始化 WebSocket (信令交互)
396 539 initIntercomSession(data, url, loadingInstance) {
397 540 try {
... ... @@ -427,12 +570,15 @@ export default {
427 570 socket.onerror = (e) => {
428 571 loadingInstance.close();
429 572 console.error("WS Error", e);
  573 + this.notifyIntercomStartFailed(this.currentIntercomSim || data.sim, 'ws_error');
430 574 this.stopIntercom();
431 575 this.$message.error("对讲连接中断");
432 576 };
433 577  
434 578 socket.onclose = () => {
435   - if (this.isTalking) {
  579 + if (!this.isTalking) {
  580 + this.notifyIntercomStartFailed(data.sim, 'ws_close_before_talking');
  581 + } else {
436 582 console.warn("WebSocket 意外断开");
437 583 this.stopIntercom();
438 584 }
... ... @@ -440,6 +586,7 @@ export default {
440 586  
441 587 } catch (e) {
442 588 loadingInstance.close();
  589 + this.notifyIntercomStartFailed(data.sim, 'ws_init_exception');
443 590 this.$message.error("初始化失败: " + e.message);
444 591 }
445 592 },
... ... @@ -803,19 +950,34 @@ export default {
803 950 this.batchPlayback(data);
804 951 },
805 952  
806   - playSingleChannel(data) {
  953 + async playSingleChannel(data) {
807 954 let stream = data.code.replace('-', '_');
808 955 let arr = stream.split("_");
809 956 if (arr.length < 3) {
810 957 console.warn("Invalid channel code:", data.code);
811 958 return;
812 959 }
  960 + if(arr[1] === undefined || arr[1] === null || arr[1] === "" || arr[1] === '001'){
  961 + this.$message.error("车辆没有sim");
  962 + return;
  963 + }
  964 + if(arr[2] === undefined || arr[2] === null || arr[2] === ""){
  965 + this.$message.error("车辆通道异常");
  966 + return;
  967 + }
  968 + await this.resetAudioToDefaultMute();
813 969 this.$axios.get(`/api/jt1078/query/send/request/io/${arr[1]}/${arr[2]}`).then(res => {
814 970 if (res.data.code === 0 || res.data.code === 200) {
815   - const url = (location.protocol === "https:") ? res.data.data.wss_flv : res.data.data.ws_flv;
  971 + const streamData = res.data.data || {};
  972 + const url = (location.protocol === "https:") ? streamData.wss_flv : streamData.ws_flv;
816 973 const idx = this.windowClickIndex - 1;
817 974 this.$set(this.videoUrl, idx, url);
818   - this.$set(this.videoDataList, idx, {...data, videoUrl: url});
  975 + this.$set(this.videoDataList, idx, {
  976 + ...data,
  977 + videoUrl: url,
  978 + audio_flv: streamData.audio_flv,
  979 + audio_https_flv: streamData.audio_https_flv
  980 + });
819 981 const maxWindow = parseInt(this.windowNum) || 4;
820 982 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1;
821 983 } else {
... ... @@ -826,10 +988,11 @@ export default {
826 988 });
827 989 },
828 990  
829   - batchPlayback(nodeData) {
  991 + async batchPlayback(nodeData) {
830 992 if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作");
831 993 const channels = nodeData.children;
832 994 if (channels && channels.length > 0) {
  995 + await this.resetAudioToDefaultMute();
833 996 this.videoUrl = [];
834 997 this.videoDataList = [];
835 998 if (channels.length > 16) this.windowNum = '25';
... ... @@ -848,7 +1011,12 @@ export default {
848 1011 list.forEach((item, i) => {
849 1012 if (channels[i]) {
850 1013 this.$set(this.videoUrl, i, item.ws_flv);
851   - this.$set(this.videoDataList, i, { ...channels[i], videoUrl: item.ws_flv });
  1014 + this.$set(this.videoDataList, i, {
  1015 + ...channels[i],
  1016 + videoUrl: item.ws_flv,
  1017 + audio_flv: item.audio_flv,
  1018 + audio_https_flv: item.audio_https_flv
  1019 + });
852 1020 }
853 1021 });
854 1022 } else {
... ... @@ -885,6 +1053,12 @@ export default {
885 1053 if (this.videoUrl && this.videoUrl[idx]) {
886 1054 this.$confirm(`确认关闭窗口 [${this.windowClickIndex}] ?`, '提示', {type: 'warning'})
887 1055 .then(() => {
  1056 + if (this.activeAudioPlayerIndex === idx) {
  1057 + if (this.activeAudioSim && this.activeAudioChannel) {
  1058 + this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  1059 + }
  1060 + this.stopActiveAudio(true);
  1061 + }
888 1062 this.$set(this.videoUrl, idx, null);
889 1063 this.$set(this.videoDataList, idx, null);
890 1064 }).catch(()=>{});
... ... @@ -892,6 +1066,10 @@ export default {
892 1066 },
893 1067  
894 1068 closeAllVideoNoConfirm() {
  1069 + if (this.activeAudioSim && this.activeAudioChannel) {
  1070 + this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  1071 + }
  1072 + this.stopActiveAudio(true);
895 1073 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
896 1074 this.videoDataList = new Array(parseInt(this.windowNum)).fill(null);
897 1075 },
... ... @@ -997,6 +1175,10 @@ export default {
997 1175 },
998 1176  
999 1177 applyVideoBatch({urls, infos}) {
  1178 + if (this.activeAudioSim && this.activeAudioChannel) {
  1179 + this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  1180 + }
  1181 + this.stopActiveAudio(true);
1000 1182 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
1001 1183 setTimeout(() => {
1002 1184 urls.forEach((url, index) => {
... ... @@ -1045,7 +1227,9 @@ export default {
1045 1227 infos[i] = {
1046 1228 code: currentCodes[i],
1047 1229 name: `通道 ${i + 1}`,
1048   - videoUrl: url
  1230 + videoUrl: url,
  1231 + audio_flv: item.audio_flv,
  1232 + audio_https_flv: item.audio_https_flv
1049 1233 };
1050 1234 }
1051 1235 });
... ... @@ -1077,6 +1261,8 @@ export default {
1077 1261 e.returnValue = '';
1078 1262 }
1079 1263 this.stopIntercom();
  1264 + this.stopActiveAudio(true);
  1265 + this.clearAudioSubscribe();
1080 1266 },
1081 1267 }
1082 1268 };
... ...
web_src/src/components/common/EasyPlayer.vue
... ... @@ -5,7 +5,6 @@
5 5 @click="onPlayerClick"
6 6 @mousemove="onMouseMove"
7 7 @mouseleave="onMouseLeave"
8   - @contextmenu="onContextMenuWrapper"
9 8 >
10 9 <div class="custom-top-bar" :class="{ 'hide-bar': !showControls }">
11 10 <div class="top-bar-left">
... ... @@ -19,21 +18,6 @@
19 18 </div>
20 19  
21 20 <div :id="uniqueId" ref="container" class="player-box"></div>
22   -
23   - <!-- 自定义右键菜单 - 全屏时附加到 body -->
24   - <div
25   - v-if="showContextMenu"
26   - ref="contextMenu"
27   - class="custom-contextmenu"
28   - :class="{ 'menu-fixed': isFullscreen }"
29   - :style="{ top: contextMenuY + 'px', left: contextMenuX + 'px' }"
30   - @click.stop
31   - >
32   - <div class="contextmenu-item" @click.stop="onShowVideoInfo">视频信息</div>
33   - <div class="contextmenu-divider"></div>
34   - <div class="contextmenu-item" @click.stop="onSwitchMainStream">主码流</div>
35   - <div class="contextmenu-item" @click.stop="onSwitchSubStream">子码流</div>
36   - </div>
37 21 </div>
38 22 </template>
39 23  
... ... @@ -45,9 +29,8 @@ export default {
45 29 isResize: { type: Boolean, default: true },
46 30 videoTitle: { type: String, default: '' },
47 31 hasAudio: { type: Boolean, default: false },
48   - // 码流切换所需参数
49   - sim: { type: String, default: '' },
50   - channel: { type: [String, Number], default: '' }
  32 + channelInfo: { type: Object, default: null },
  33 + playerIndex: { type: Number, default: -1 }
51 34 },
52 35 data() {
53 36 return {
... ... @@ -57,15 +40,8 @@ export default {
57 40 hasStarted: false,
58 41 showControls: false,
59 42 controlTimer: null,
60   - isFullscreen: false, // 全屏状态
61   -
62   - // 自定义右键菜单
63   - showContextMenu: false,
64   - contextMenuX: 0,
65   - contextMenuY: 0,
66   - contextMenuHandler: null, // 保存右键菜单监听器函数
67   - docContextMenuHandler: null, // 保存文档级右键菜单监听器函数
68   - containerContextMenuHandler: null, // 保存容器级右键菜单监听器函数
  43 + audioButtonClickHandler: null,
  44 + muteEventHandler: null,
69 45  
70 46 // 【配置控制表】
71 47 controlsConfig: {
... ... @@ -122,77 +98,11 @@ export default {
122 98 }
123 99 }, 500);
124 100 });
125   - // 添加全局点击事件监听,用于隐藏右键菜单
126   - document.addEventListener('click', this.hideContextMenu);
127   - // 添加全屏变化监听
128   - document.addEventListener('fullscreenchange', this.onFullscreenChange);
129   - document.addEventListener('webkitfullscreenchange', this.onFullscreenChange);
130   - // 在 document 级别使用 capture 模式拦截 contextmenu 事件
131   - this.docContextMenuHandler = (e) => {
132   - // 检查播放器是否在播放
133   - if (!this.hasStarted) {
134   - return;
135   - }
136   -
137   - // 优先使用 this.isFullscreen(通过 onFullscreenChange 更新的),因为 EasyPlayerPro 可能不会更新 document.fullscreenElement
138   - const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement || this.isFullscreen);
139   - console.log('Contextmenu event - isFullscreen:', isFullscreen, 'docFullscreen:', !!(document.fullscreenElement || document.webkitFullscreenElement), 'this.isFullscreen:', this.isFullscreen, 'target:', e.target);
140   -
141   - // 全屏模式下,使用 DOM 遍历方式检测
142   - if (isFullscreen) {
143   - console.log('Entering fullscreen branch');
144   - let target = e.target;
145   -
146   - // 简化检测:直接检查点击目标是否在当前播放器元素内
147   - const isInPlayer = this.$el.contains(target);
148   - console.log('isInPlayer (this.$el.contains):', isInPlayer, 'target:', target, 'this.$el:', this.$el);
149   -
150   - if (isInPlayer) {
151   - e.preventDefault();
152   - setTimeout(() => {
153   - this.hideNativeContextMenu();
154   - this.onContextMenu(e);
155   - }, 10);
156   - }
157   - return;
158   - }
159   -
160   - // 非全屏模式下,使用坐标检测
161   - const rect = this.$el.getBoundingClientRect();
162   - const isClickInPlayer = (
163   - e.clientX >= rect.left &&
164   - e.clientX <= rect.right &&
165   - e.clientY >= rect.top &&
166   - e.clientY <= rect.bottom
167   - );
168   -
169   - // 如果点击不在当前播放器内,不处理
170   - if (!isClickInPlayer) {
171   - return;
172   - }
173   -
174   - // 点击在当前播放器内,阻止原生菜单并显示自定义菜单
175   - e.preventDefault();
176   - // 延迟一点执行,确保我们的菜单能显示
177   - setTimeout(() => {
178   - this.hideNativeContextMenu();
179   - this.onContextMenu(e);
180   - }, 10);
181   - };
182   - document.addEventListener('contextmenu', this.docContextMenuHandler, true);
183 101 },
184 102 beforeDestroy() {
  103 + this.unbindAudioButtonEvent();
185 104 this.destroy();
186 105 if (this.controlTimer) clearTimeout(this.controlTimer);
187   - // 移除全局点击事件监听
188   - document.removeEventListener('click', this.hideContextMenu);
189   - // 移除全屏变化监听
190   - document.removeEventListener('fullscreenchange', this.onFullscreenChange);
191   - document.removeEventListener('webkitfullscreenchange', this.onFullscreenChange);
192   - // 移除 document 级别的 contextmenu 监听
193   - if (this.docContextMenuHandler) {
194   - document.removeEventListener('contextmenu', this.docContextMenuHandler, true);
195   - }
196 106 },
197 107 methods: {
198 108 onMouseMove() {
... ... @@ -221,6 +131,7 @@ export default {
221 131 MSE: true,
222 132 WCS: true,
223 133 hasAudio: this.hasAudio,
  134 + isMute: true,
224 135 isLive: true,
225 136 loading: true,
226 137 isBand: true, // 必须为 true 才能获取网速回调
... ... @@ -235,13 +146,14 @@ export default {
235 146 zoom: true,
236 147 }
237 148 });
238   -
239 149 this.playerInstance.on('kBps', (speed) => {
240 150 this.netSpeed = speed + '/S';
241 151 });
242 152  
243 153 this.playerInstance.on('play', () => {
244 154 this.hasStarted = true;
  155 + // 强制同步一次初始静音状态,确保默认关闭音频
  156 + this.setMuted(true, false);
245 157 this.showControls = true;
246 158 if (this.controlTimer) clearTimeout(this.controlTimer);
247 159 this.controlTimer = setTimeout(() => {
... ... @@ -253,31 +165,18 @@ export default {
253 165 console.warn('Player Error:', err);
254 166 });
255 167  
256   - // 监听播放器的 contextmenu 事件,防止显示原生菜单
257   - this.playerInstance.on('contextmenu', (e) => {
258   - e.preventDefault();
259   - });
260   -
261   - // 添加原生 contextmenu 监听器到容器元素和 wrapper 元素,作为 document 级监听器的备份
262   - // 在 fullscreen 模式下,事件可能不会冒泡到 document
263   - this.containerContextMenuHandler = (e) => {
264   - console.log('Container contextmenu fired (native listener), hasStarted:', this.hasStarted);
265   - if (!this.hasStarted) {
266   - console.log('hasStarted check failed');
267   - return;
268   - }
269   - e.preventDefault();
270   - console.log('About to call onContextMenu');
271   - this.hideNativeContextMenu();
272   - this.onContextMenu(e);
273   - console.log('onContextMenu called, showContextMenu should be:', this.showContextMenu);
  168 + // 官方事件:音量按钮切换时触发
  169 + this.muteEventHandler = () => {
  170 + // 统一读取最终状态,避免不同版本回调参数语义不一致
  171 + setTimeout(() => {
  172 + this.emitAudioToggle();
  173 + }, 0);
274 174 };
275   - container.addEventListener('contextmenu', this.containerContextMenuHandler, true);
276   - // 也在 wrapper (this.$el) 上添加监听,fullscreen 时 this.$el 可能是全屏元素
277   - this.$el.addEventListener('contextmenu', this.containerContextMenuHandler, true);
278   -
279   - // 注意:document 级别的 contextmenu 监听器已在 mounted 中添加,这里不需要重复添加
  175 + this.playerInstance.on('mute', this.muteEventHandler);
280 176  
  177 + this.$nextTick(() => {
  178 + this.bindAudioButtonEvent();
  179 + });
281 180  
282 181 } catch (e) {
283 182 console.error("Create Error:", e);
... ... @@ -300,21 +199,16 @@ export default {
300 199 destroy() {
301 200 this.hasStarted = false;
302 201 this.showControls = false;
303   - // 移除 document 级别的 contextmenu 监听
304   - if (this.docContextMenuHandler) {
305   - document.removeEventListener('contextmenu', this.docContextMenuHandler, true);
306   - this.docContextMenuHandler = null;
307   - }
308   - // 移除容器级别的 contextmenu 监听
309   - if (this.containerContextMenuHandler && this.$refs.container) {
310   - this.$refs.container.removeEventListener('contextmenu', this.containerContextMenuHandler, true);
311   - this.$el.removeEventListener('contextmenu', this.containerContextMenuHandler, true);
312   - this.containerContextMenuHandler = null;
313   - }
314 202 if (this.playerInstance) {
  203 + if (this.muteEventHandler && typeof this.playerInstance.off === 'function') {
  204 + try {
  205 + this.playerInstance.off('mute', this.muteEventHandler);
  206 + } catch (e) {}
  207 + }
315 208 this.playerInstance.destroy();
316 209 this.playerInstance = null;
317 210 }
  211 + this.muteEventHandler = null;
318 212 },
319 213  
320 214 destroyAndReplay(url) {
... ... @@ -329,328 +223,90 @@ export default {
329 223 this.$emit('click');
330 224 },
331 225  
332   - // 右键菜单处理 - 直接在 wrapper 上拦截
333   - onContextMenuWrapper(e) {
334   - // 阻止事件冒泡到 EasyPlayerPro
335   - e.stopPropagation();
336   - // document 级别的监听器会处理阻止默认行为
  226 + setControls(config) {
  227 + this.controlsConfig = { ...this.controlsConfig, ...config };
337 228 },
338   -
339   - // 右键菜单 - 用于 capture 模式拦截
340   - onContextMenuCapture(e) {
341   - // 阻止原生右键菜单
342   - e.preventDefault();
343   - e.stopPropagation();
344   -
345   - // 调用正常的右键菜单处理
346   - this.onContextMenu(e);
  229 + getMediaElement() {
  230 + const container = this.$refs.container;
  231 + if (!container) return null;
  232 + return container.querySelector('video');
347 233 },
348   -
349   - // 右键菜单
350   - onContextMenu(e) {
351   - console.log('onContextMenu called, hasStarted:', this.hasStarted);
352   - // 只有在播放器已启动后才显示菜单
353   - if (!this.hasStarted) return;
354   -
355   - // 计算菜单位置,确保不超出播放器边界
356   - const menuWidth = 150;
357   - const menuHeight = 140;
358   -
359   - // 检查是否是全屏模式
360   - const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement || this.isFullscreen);
361   -
362   - // 全屏模式:使用 viewport 坐标
363   - // 非全屏模式:使用播放器容器坐标
364   - let x, y;
365   - if (isFs) {
366   - // 全屏时使用绝对坐标
367   - x = e.clientX;
368   - y = e.clientY;
369   - console.log('Fullscreen mode - using viewport coords:', x, y);
370   - } else {
371   - // 非全屏时使用相对于 player-wrapper 的坐标
372   - const containerRect = this.$el.getBoundingClientRect();
373   - x = e.clientX - containerRect.left;
374   - y = e.clientY - containerRect.top;
375   - console.log('Normal mode - using relative coords:', x, y, 'containerRect:', containerRect);
376   - }
377   -
378   - // 边界检测
379   - const maxX = isFs ? window.innerWidth : (isFs ? window.innerWidth : this.$el.getBoundingClientRect().width);
380   - const maxY = isFs ? window.innerHeight : (isFs ? window.innerHeight : this.$el.getBoundingClientRect().height);
381   -
382   - if (x + menuWidth > maxX) {
383   - x = maxX - menuWidth;
384   - }
385   - if (y + menuHeight > maxY) {
386   - y = maxY - menuHeight;
387   - }
388   -
389   - // 确保最小值
390   - x = Math.max(0, x);
391   - y = Math.max(0, y);
392   -
393   - this.contextMenuX = x;
394   - this.contextMenuY = y;
395   - console.log('Setting showContextMenu=true, x:', x, 'y:', y);
396   -
397   - // 阻止事件冒泡
398   - e.stopPropagation();
399   -
400   - // 先隐藏原生菜单(多次调用确保隐藏)
401   - this.hideNativeContextMenu();
402   -
403   - this.showContextMenu = true;
404   - this.$forceUpdate();
405   - console.log('showContextMenu is now:', this.showContextMenu);
406   -
407   - // 全屏时将菜单附加到 body
408   - if (isFs && this.$refs.contextMenu) {
409   - console.log('Appending menu to body for fullscreen');
410   - document.body.appendChild(this.$refs.contextMenu);
411   - // 强制设置 position: fixed 和 top/left
412   - this.$refs.contextMenu.style.position = 'fixed';
413   - this.$refs.contextMenu.style.top = y + 'px';
414   - this.$refs.contextMenu.style.left = x + 'px';
  234 + setMuted(muted, emitEvent = false) {
  235 + if (this.playerInstance && typeof this.playerInstance.setMute === 'function') {
  236 + try {
  237 + this.playerInstance.setMute(!!muted);
  238 + } catch (e) {}
415 239 }
416   -
417   - // 检查 DOM
418   - this.$nextTick(() => {
419   - const menu = this.$refs.contextMenu || this.$el.querySelector('.custom-contextmenu');
420   - console.log('DOM check - menu exists:', !!menu, 'parent:', menu ? menu.parentElement : 'N/A');
421   - console.log('isFullscreen data property:', this.isFullscreen);
422   - console.log('menu-fixed class applied:', menu ? menu.classList.contains('menu-fixed') : 'N/A');
423   - if (menu) {
424   - console.log('Menu computed position:', getComputedStyle(menu).position);
425   - console.log('Menu rect:', menu.getBoundingClientRect(), 'viewport:', window.innerWidth, window.innerHeight);
426   - // 检查原生菜单状态
427   - const nativeMenus = document.querySelectorAll('[class*="easyplayer-contextmenu"]');
428   - console.log('Native menus count:', nativeMenus.length);
429   -
430   - // 检查是否有其他元素覆盖在菜单上面
431   - const elemBelow = document.elementFromPoint(x, y);
432   - console.log('Element at menu position (x,y):', x, y, 'is:', elemBelow);
433   -
434   - // 使用 requestAnimationFrame 确保菜单在原生菜单之后渲染
435   - requestAnimationFrame(() => {
436   - requestAnimationFrame(() => {
437   - this.hideNativeContextMenu();
438   - // 强制确保菜单可见
439   - menu.style.setProperty('display', 'block', 'important');
440   - menu.style.setProperty('visibility', 'visible', 'important');
441   - menu.style.setProperty('opacity', '1', 'important');
442   - console.log('After rAF forcing styles - display:', getComputedStyle(menu).display, 'visibility:', getComputedStyle(menu).visibility, 'opacity:', getComputedStyle(menu).opacity, 'position:', getComputedStyle(menu).position);
443   - // 再次检查覆盖元素
444   - const elemBelowAfter = document.elementFromPoint(x, y);
445   - console.log('Element at menu position AFTER forcing:', x, y, 'is:', elemBelowAfter);
446   - });
447   - });
  240 + const media = this.getMediaElement();
  241 + if (media) {
  242 + media.muted = !!muted;
  243 + if (muted) {
  244 + media.volume = 0;
  245 + } else if (media.volume === 0) {
  246 + media.volume = 1;
448 247 }
449   - });
450   - },
451   -
452   - // 隐藏右键菜单
453   - hideContextMenu() {
454   - // 如果菜单被附加到 body,将其移回 player-wrapper
455   - if (this.$refs.contextMenu && this.$refs.contextMenu.parentElement !== this.$el) {
456   - this.$el.appendChild(this.$refs.contextMenu);
457 248 }
458   - this.showContextMenu = false;
459   - // 同时隐藏原生菜单
460   - this.hideNativeContextMenu();
461   - },
462   -
463   - // 隐藏原生右键菜单
464   - hideNativeContextMenu() {
465   - const menus = document.querySelectorAll('[class*="easyplayer-contextmenu"]');
466   - menus.forEach(menu => {
467   - if (!menu.classList.contains('custom-contextmenu')) {
468   - menu.style.display = 'none';
469   - }
470   - });
471   - },
472   -
473   - // 全屏状态变化处理
474   - onFullscreenChange() {
475   - this.isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
476   - // 全屏时隐藏右键菜单
477   - if (!this.isFullscreen) {
478   - this.hideContextMenu();
  249 + if (emitEvent) {
  250 + this.emitAudioToggle(!!muted);
479 251 }
480 252 },
481   -
482   - // 视频信息
483   - onShowVideoInfo() {
484   - const instance = this.playerInstance;
485   -
486   - // 方法1: 直接调用
487   - if (instance && typeof instance.showVideoInfo === 'function') {
488   - instance.showVideoInfo();
489   - this.hideContextMenu();
490   - return;
  253 + resolveSimChannel() {
  254 + if (!this.channelInfo || !this.channelInfo.code) {
  255 + return { sim: '', channel: '' };
491 256 }
492   -
493   - // 方法2: 通过 player 调用
494   - if (instance && instance.player && typeof instance.player.showVideoInfo === 'function') {
495   - instance.player.showVideoInfo();
496   - this.hideContextMenu();
497   - return;
498   - }
499   -
500   - // 方法3: 调用 _videoinfo 方法
501   - if (instance && typeof instance._videoinfo === 'function') {
502   - instance._videoinfo();
503   - this.hideContextMenu();
504   - return;
  257 + const code = String(this.channelInfo.code).replace(/_/g, '-');
  258 + const arr = code.split('-');
  259 + if (arr.length < 3) {
  260 + return { sim: '', channel: '' };
505 261 }
506   -
507   - // 方法4: 尝试通过 events 触发
508   - if (instance && instance.events && instance.events.emit) {
509   - instance.events.emit('showVideoInfo');
510   - this.hideContextMenu();
511   - return;
  262 + return { sim: arr[1], channel: arr[2] };
  263 + },
  264 + resolveMutedState() {
  265 + if (this.playerInstance && typeof this.playerInstance.isMute === 'function') {
  266 + try {
  267 + return !!this.playerInstance.isMute();
  268 + } catch (e) {}
512 269 }
513   -
514   - // 方法5: 触发原生菜单中的视频信息按钮
515   - // 先隐藏我们的自定义菜单,然后让原生菜单显示,再点击其视频信息按钮
516   - const customMenu = this.$el.querySelector('.custom-contextmenu');
517   - if (customMenu) {
518   - customMenu.style.display = 'none';
  270 + const media = this.getMediaElement();
  271 + if (media) {
  272 + return media.muted || media.volume === 0;
519 273 }
520   -
521   - // 强制显示原生菜单(移除我们的隐藏样式)
522   - const allMenus = document.querySelectorAll('.easyplayer-contextmenu-btn');
523   - allMenus.forEach(menu => {
524   - menu.style.display = '';
525   - menu.style.opacity = '';
526   - menu.style.visibility = '';
527   - });
528   -
529   - // 延迟一点让原生菜单显示
530   - setTimeout(() => {
531   - // 在当前播放器容器内查找原生菜单
532   - const container = this.$refs.container;
533   - let infoBtn = null;
534   -
535   - if (container) {
536   - const nativeMenu = container.querySelector('.easyplayer-contextmenu-btn');
537   - if (nativeMenu) {
538   - infoBtn = nativeMenu.querySelector('.easyplayer-contextmenu-info');
539   - }
540   - }
541   -
542   - // 如果没找到,尝试基于位置匹配
543   - if (!infoBtn) {
544   - const playerRect = this.$el.getBoundingClientRect();
545   - for (let i = 0; i < allMenus.length; i++) {
546   - const menu = allMenus[i];
547   - const menuRect = menu.getBoundingClientRect();
548   - const isOverlapping =
549   - Math.abs(menuRect.left - playerRect.left) < 100 &&
550   - Math.abs(menuRect.top - playerRect.top) < 100;
551   - if (isOverlapping) {
552   - infoBtn = menu.querySelector('.easyplayer-contextmenu-info');
553   - break;
554   - }
555   - }
556   - }
557   -
558   - if (infoBtn) {
559   - infoBtn.click();
560   - }
561   -
562   - // 隐藏原生菜单
563   - allMenus.forEach(menu => {
564   - menu.style.display = 'none';
565   - });
566   - }, 50);
567   - },
568   -
569   - // 切换主码流
570   - onSwitchMainStream() {
571   - this.hideContextMenu();
572   - this.switchStream(0);
573   - },
574   -
575   - // 切换子码流
576   - onSwitchSubStream() {
577   - this.hideContextMenu();
578   - this.switchStream(1);
  274 + return true;
579 275 },
580   -
581   - setControls(config) {
582   - this.controlsConfig = { ...this.controlsConfig, ...config };
  276 + emitAudioToggle(forcedMuted) {
  277 + const muted = typeof forcedMuted === 'boolean' ? forcedMuted : this.resolveMutedState();
  278 + const simChannel = this.resolveSimChannel();
  279 + this.$emit('audio-toggle', {
  280 + muted,
  281 + playerIndex: this.playerIndex,
  282 + stream: this.channelInfo && this.channelInfo.videoUrl ? this.channelInfo.videoUrl : this.initialPlayUrl,
  283 + channelInfo: this.channelInfo,
  284 + sim: simChannel.sim,
  285 + channel: simChannel.channel
  286 + });
583 287 },
584   -
585   - // 切换码流
586   - switchStream(type) {
587   - // 如果没有sim和channel参数,则不发送请求
588   - if (!this.sim || !this.channel) {
589   - console.log('码流切换跳过: 缺少sim或channel参数');
590   - return;
591   - }
592   -
593   - const params = {
594   - sim: this.sim,
595   - channel: parseInt(this.channel),
596   - type: type
597   - };
598   -
599   - console.log(`码流切换: sim=${params.sim}, channel=${params.channel}, type=${type} (0=主码流, 1=子码流)`);
600   -
601   - // 使用箭头函数保存this引用
602   - const doSwitch = (axiosInstance) => {
603   - axiosInstance.post('/api/jt1078/query/switch/stream', params)
604   - .then(res => {
605   - if (res.data.code === 200 || res.data.code === 0) {
606   - console.log('码流切换成功,等待设备切换...');
607   - } else {
608   - console.warn('码流切换失败:', res.data.msg || res.data);
609   - }
610   - })
611   - .catch(err => {
612   - console.error('码流切换请求失败:', err);
613   - });
  288 + bindAudioButtonEvent() {
  289 + const container = this.$refs.container;
  290 + if (!container) return;
  291 + this.unbindAudioButtonEvent();
  292 + const audioBtn = container.querySelector('.easyplayer-audio-box');
  293 + if (!audioBtn) return;
  294 + this.audioButtonClickHandler = () => {
  295 + // DOM 兜底:避免个别版本不抛 mute 事件
  296 + setTimeout(() => {
  297 + this.emitAudioToggle();
  298 + }, 0);
614 299 };
615   -
616   - if (this.$axios) {
617   - doSwitch(this.$axios);
618   - } else if (window.axios) {
619   - doSwitch(window.axios);
620   - }
  300 + audioBtn.addEventListener('click', this.audioButtonClickHandler);
621 301 },
622   -
623   - // 刷新播放地址并重新播放
624   - refreshPlayUrl() {
625   - if (!this.sim || !this.channel) return;
626   -
627   - const axiosInstance = this.$axios || window.axios;
628   - if (!axiosInstance) return;
629   -
630   - axiosInstance.get(`/api/jt1078/query/send/request/io/${this.sim}/${this.channel}`)
631   - .then(res => {
632   - if (res.data.code === 200 || res.data.code === 0) {
633   - const newUrl = res.data.data.ws_flv || res.data.data.wss_flv;
634   - console.log('获取新播放地址:', newUrl);
635   -
636   - if (newUrl) {
637   - // 重新创建播放器并播放新地址
638   - // 分步执行:先销毁,等待,再重建,确保播放器完全重置
639   - this.destroy();
640   -
641   - // 等待一段时间后重新创建播放器
642   - setTimeout(() => {
643   - this.create();
644   - this.play(newUrl);
645   - }, 500); // 等待500ms确保完全销毁后再重建
646   - }
647   - } else {
648   - console.warn('获取播放地址失败:', res.data.msg || res.data);
649   - }
650   - })
651   - .catch(err => {
652   - console.error('获取播放地址失败:', err);
653   - });
  302 + unbindAudioButtonEvent() {
  303 + const container = this.$refs.container;
  304 + if (!container || !this.audioButtonClickHandler) return;
  305 + const audioBtn = container.querySelector('.easyplayer-audio-box');
  306 + if (audioBtn) {
  307 + audioBtn.removeEventListener('click', this.audioButtonClickHandler);
  308 + }
  309 + this.audioButtonClickHandler = null;
654 310 }
655 311 },
656 312 };
... ... @@ -724,7 +380,7 @@ export default {
724 380 }
725 381  
726 382 /* 音量按钮 */
727   -.player-wrapper.hide-btn-audio .easyplayer-volume {
  383 +.player-wrapper.hide-btn-audio .easyplayer-audio-box {
728 384 display: none !important;
729 385 }
730 386  
... ... @@ -903,52 +559,4 @@ export default {
903 559 .player-wrapper .easyplayer-loading-text {
904 560 display: none !important;
905 561 }
906   -
907   -/* =========================================
908   - 3. 自定义右键菜单
909   - ========================================= */
910   -
911   -/* 隐藏原生右键菜单 - 使用更高优先级 */
912   -[class*="easyplayer-contextmenu"] {
913   - display: none !important;
914   - visibility: hidden !important;
915   -}
916   -
917   -/* 自定义右键菜单样式 */
918   -.custom-contextmenu {
919   - position: absolute;
920   - z-index: 2147483647 !important;
921   - background: rgba(30, 30, 30, 0.95);
922   - border: 1px solid rgba(80, 80, 80, 0.8);
923   - border-radius: 6px;
924   - padding: 6px 0;
925   - min-width: 140px;
926   - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
927   -}
928   -
929   -/* 全屏模式下使用 fixed 定位,并强制使用视口作为定位上下文 */
930   -.custom-contextmenu.menu-fixed {
931   - position: fixed !important;
932   - top: var(--menu-top, 0) !important;
933   - left: var(--menu-left, 0) !important;
934   - transform: none !important;
935   -}
936   -
937   -.custom-contextmenu .contextmenu-divider {
938   - height: 1px;
939   - background: rgba(80, 80, 80, 0.6);
940   - margin: 4px 0;
941   -}
942   -
943   -.custom-contextmenu .contextmenu-item {
944   - padding: 8px 16px;
945   - color: #fff;
946   - font-size: 13px;
947   - cursor: pointer;
948   - transition: background 0.2s;
949   -}
950   -
951   -.custom-contextmenu .contextmenu-item:hover {
952   - background: rgba(30, 144, 255, 0.6);
953   -}
954 562 </style>
... ...
web_src/src/components/common/PlayerListComponent.vue
... ... @@ -12,13 +12,14 @@
12 12 :class="`video${i}`"
13 13 :ref="`player${i}`"
14 14 :initial-play-url="videoUrl[i]"
  15 + :channel-info="videoDataList[i]"
  16 + :player-index="i"
15 17 :initial-buffer-time="0.1"
16 18 :show-custom-mask="false"
17 19 :has-audio="true"
18   - :sim="playerDataList[i] ? playerDataList[i].sim : ''"
19   - :channel="playerDataList[i] ? playerDataList[i].channel : ''"
20 20 style="width: 100%;height: 100%;"
21 21 @click="playerClick(item, i, items.length)"
  22 + @audio-toggle="handleAudioToggle"
22 23 ></easyPlayer>
23 24  
24 25 <!-- 选中/悬停的高亮框 (使用绝对定位覆盖,不影响布局) -->
... ... @@ -40,10 +41,6 @@ export default {
40 41 type: String,
41 42 default: '9'
42 43 },
43   - hasAudio: {
44   - type: Boolean,
45   - default: true
46   - },
47 44 videoUrl: {
48 45 type: Array,
49 46 default: []
... ... @@ -63,16 +60,6 @@ export default {
63 60 },
64 61 //计算属性 类似于data概念",
65 62 computed: {
66   - // 生成播放器数据列表,包含 sim 和 channel
67   - playerDataList() {
68   - return this.items.map((item, i) => {
69   - const data = this.videoDataList[i] || {};
70   - return {
71   - sim: this.getSim(data),
72   - channel: this.getChannel(data)
73   - };
74   - });
75   - },
76 63 },
77 64 //监控data中的数据变化",
78 65 watch: {
... ... @@ -212,6 +199,26 @@ export default {
212 199 // 通知父组件,注意:如果布局变了(比如切到1+5),len可能变了
213 200 this.$emit('playerClick', data, index, this.items.length);
214 201 },
  202 + handleAudioToggle(payload) {
  203 + if (!payload || payload.playerIndex === undefined || payload.playerIndex === null) {
  204 + return;
  205 + }
  206 + this.$emit('audio-toggle', payload);
  207 + },
  208 + setPlayerMuted(index, muted) {
  209 + const playerRef = this.$refs[`player${index}`];
  210 + const player = Array.isArray(playerRef) ? playerRef[0] : playerRef;
  211 + if (player && typeof player.setMuted === 'function') {
  212 + player.setMuted(muted);
  213 + }
  214 + },
  215 + muteAllPlayersExcept(activeIndex) {
  216 + for (let i = 0; i < this.items.length; i++) {
  217 + if (i !== activeIndex) {
  218 + this.setPlayerMuted(i, true);
  219 + }
  220 + }
  221 + },
215 222  
216 223 setDivStyle(idx, len) {
217 224 // 保持兼容,如果 len 变化了才更新布局
... ... @@ -288,22 +295,6 @@ export default {
288 295 }
289 296 });
290 297 },
291   -
292   - // 获取SIM卡号 (从parent对象中获取)
293   - getSim(data) {
294   - if (!data) return '';
295   - // 优先从自身获取,否则从parent对象获取
296   - return data.sim || (data.parent && data.parent.sim) || '';
297   - },
298   -
299   - // 获取通道号 (从code中提取,格式: id_sim_channelNumber)
300   - getChannel(data) {
301   - console.log(data)
302   - if (!data || !data.code) return '';
303   - const parts = data.code.split('_');
304   - // code格式: id_sim_channelNumber, channel是最后一部分
305   - return parts.length >= 3 ? parts[parts.length - 1] : '';
306   - }
307 298 },
308 299 //生命周期 - 创建完成(可以访问当前this实例)",
309 300 created() {
... ...
打包/config/config.ini
... ... @@ -86,7 +86,7 @@ udpTTL=64
86 86  
87 87 [protocol]
88 88 add_mute_audio=1
89   -continue_push_ms=3000
  89 +continue_push_ms=8000
90 90 enable_audio=1
91 91 enable_fmp4=1
92 92 enable_hls=1
... ...