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,7 +59,7 @@ public class SipLayer implements CommandLineRunner {
59 addListeningPoint(monitorIp, sipConfig.getPort()); 59 addListeningPoint(monitorIp, sipConfig.getPort());
60 } 60 }
61 if (udpSipProviderMap.size() + tcpSipProviderMap.size() == 0) { 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,6 +140,14 @@ public class SipLayer implements CommandLineRunner {
140 if (!ObjectUtils.isEmpty(deviceLocalIp)) { 140 if (!ObjectUtils.isEmpty(deviceLocalIp)) {
141 return deviceLocalIp; 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,6 +47,7 @@ public class VideoServerApp
47 configProperties = "/app.properties"; 47 configProperties = "/app.properties";
48 break; 48 break;
49 case "jt1078-dev100": 49 case "jt1078-dev100":
  50 + case "local-100":
50 configProperties = "/app-dev100.properties"; 51 configProperties = "/app-dev100.properties";
51 break; 52 break;
52 case "jt1078-dev103": 53 case "jt1078-dev103":
src/main/java/com/genersoft/iot/vmp/jtt1078/flv/FlvEncoder.java
@@ -33,7 +33,7 @@ public final class FlvEncoder @@ -33,7 +33,7 @@ public final class FlvEncoder
33 public FlvEncoder(boolean haveVideo, boolean haveAudio) 33 public FlvEncoder(boolean haveVideo, boolean haveAudio)
34 { 34 {
35 this.haveVideo = haveVideo; 35 this.haveVideo = haveVideo;
36 - // this.haveAudio = haveAudio; 36 + this.haveAudio = haveAudio;
37 flvHeader = Packet.create(16); 37 flvHeader = Packet.create(16);
38 videoFrame = new ByteArrayOutputStream(2048 * 100); 38 videoFrame = new ByteArrayOutputStream(2048 * 100);
39 makeFlvHeader(); 39 makeFlvHeader();
src/main/java/com/genersoft/iot/vmp/jtt1078/http/NettyHttpServerHandler.java
@@ -30,18 +30,28 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter @@ -30,18 +30,28 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
30 { 30 {
31 FullHttpRequest fhr = (FullHttpRequest) msg; 31 FullHttpRequest fhr = (FullHttpRequest) msg;
32 String uri = fhr.uri(); 32 String uri = fhr.uri();
  33 + QueryStringDecoder decoder = new QueryStringDecoder(uri);
  34 + String path = decoder.path();
  35 + String origin = fhr.headers().get("Origin");
33 Packet resp = Packet.create(1024); 36 Packet resp = Packet.create(1024);
34 // uri的第二段,就是通道标签 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 resp.addBytes("HTTP/1.1 200 OK\r\n".getBytes(HEADER_ENCODING)); 41 resp.addBytes("HTTP/1.1 200 OK\r\n".getBytes(HEADER_ENCODING));
39 resp.addBytes("Connection: keep-alive\r\n".getBytes(HEADER_ENCODING)); 42 resp.addBytes("Connection: keep-alive\r\n".getBytes(HEADER_ENCODING));
40 resp.addBytes("Content-Type: video/x-flv\r\n".getBytes(HEADER_ENCODING)); 43 resp.addBytes("Content-Type: video/x-flv\r\n".getBytes(HEADER_ENCODING));
41 resp.addBytes("Transfer-Encoding: chunked\r\n".getBytes(HEADER_ENCODING)); 44 resp.addBytes("Transfer-Encoding: chunked\r\n".getBytes(HEADER_ENCODING));
42 resp.addBytes("Cache-Control: no-cache\r\n".getBytes(HEADER_ENCODING)); 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 resp.addBytes("\r\n".getBytes(HEADER_ENCODING)); 55 resp.addBytes("\r\n".getBytes(HEADER_ENCODING));
46 56
47 ctx.writeAndFlush(resp.getBytes()).await(); 57 ctx.writeAndFlush(resp.getBytes()).await();
@@ -49,8 +59,34 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter @@ -49,8 +59,34 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
49 // 订阅视频数据 59 // 订阅视频数据
50 long wid = PublishManager.getInstance().subscribe(tag, Media.Type.Video, ctx).getId(); 60 long wid = PublishManager.getInstance().subscribe(tag, Media.Type.Video, ctx).getId();
51 setSession(ctx, new Session().set("subscriber-id", wid).set("tag", tag)); 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 else if (uri.equals("/test/multimedia")) 90 else if (uri.equals("/test/multimedia"))
55 { 91 {
56 responseHTMLFile("/multimedia.html", ctx); 92 responseHTMLFile("/multimedia.html", ctx);
@@ -76,6 +112,7 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter @@ -76,6 +112,7 @@ public class NettyHttpServerHandler extends ChannelInboundHandlerAdapter
76 String tag = session.get("tag"); 112 String tag = session.get("tag");
77 Long wid = session.get("subscriber-id"); 113 Long wid = session.get("subscriber-id");
78 PublishManager.getInstance().unsubscribe(tag, wid); 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,8 +4,10 @@ import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec;
4 import com.genersoft.iot.vmp.jtt1078.entity.Media; 4 import com.genersoft.iot.vmp.jtt1078.entity.Media;
5 import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding; 5 import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding;
6 import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder; 6 import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder;
  7 +// [恢复] 引用 FFmpeg 进程管理类
7 import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher; 8 import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher;
8 import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber; 9 import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber;
  10 +import com.genersoft.iot.vmp.jtt1078.subscriber.AudioSubscriber;
9 import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber; 11 import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber;
10 import com.genersoft.iot.vmp.jtt1078.util.ByteHolder; 12 import com.genersoft.iot.vmp.jtt1078.util.ByteHolder;
11 import com.genersoft.iot.vmp.jtt1078.util.Configs; 13 import com.genersoft.iot.vmp.jtt1078.util.Configs;
@@ -14,11 +16,8 @@ import org.apache.commons.lang3.StringUtils; @@ -14,11 +16,8 @@ import org.apache.commons.lang3.StringUtils;
14 import org.slf4j.Logger; 16 import org.slf4j.Logger;
15 import org.slf4j.LoggerFactory; 17 import org.slf4j.LoggerFactory;
16 18
17 -import java.util.Arrays;  
18 import java.util.Iterator; 19 import java.util.Iterator;
19 import java.util.concurrent.ConcurrentLinkedQueue; 20 import java.util.concurrent.ConcurrentLinkedQueue;
20 -import java.util.concurrent.atomic.AtomicBoolean;  
21 -import java.util.concurrent.atomic.AtomicLong;  
22 21
23 public class Channel 22 public class Channel
24 { 23 {
@@ -28,6 +27,8 @@ public class Channel @@ -28,6 +27,8 @@ public class Channel
28 27
29 // [恢复] FFmpeg 推流进程管理器 28 // [恢复] FFmpeg 推流进程管理器
30 RTMPPublisher rtmpPublisher; 29 RTMPPublisher rtmpPublisher;
  30 + RTMPPublisher audioRtmpPublisher;
  31 + // [删除] ZlmRtpPublisher rtpPublisher;
31 32
32 String tag; 33 String tag;
33 boolean publishing; 34 boolean publishing;
@@ -36,39 +37,6 @@ public class Channel @@ -36,39 +37,6 @@ public class Channel
36 FlvEncoder flvEncoder; 37 FlvEncoder flvEncoder;
37 private long firstTimestamp = -1; 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 public Channel(String tag) 40 public Channel(String tag)
73 { 41 {
74 this.tag = tag; 42 this.tag = tag;
@@ -84,6 +52,8 @@ public class Channel @@ -84,6 +52,8 @@ public class Channel
84 logger.info("[{}] 启动 FFmpeg 进程推流至: {}", tag, rtmpUrl); 52 logger.info("[{}] 启动 FFmpeg 进程推流至: {}", tag, rtmpUrl);
85 rtmpPublisher = new RTMPPublisher(tag); 53 rtmpPublisher = new RTMPPublisher(tag);
86 rtmpPublisher.start(); 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,9 +62,17 @@ public class Channel
92 return publishing; 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 this.subscribers.add(subscriber); 76 this.subscribers.add(subscriber);
99 return subscriber; 77 return subscriber;
100 } 78 }
@@ -105,10 +83,12 @@ public class Channel @@ -105,10 +83,12 @@ public class Channel
105 if (audioCodec == null) 83 if (audioCodec == null)
106 { 84 {
107 audioCodec = AudioCodec.getCodec(pt); 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 // 写入到内部广播,FFmpeg 通过 HTTP 拉取这个数据 88 // 写入到内部广播,FFmpeg 通过 HTTP 拉取这个数据
111 broadcastAudio(timestamp, audioCodec.toPCM(data)); 89 broadcastAudio(timestamp, audioCodec.toPCM(data));
  90 +
  91 + // [删除] rtpPublisher.sendAudio(...)
112 } 92 }
113 93
114 public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264) 94 public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264)
@@ -123,28 +103,6 @@ public class Channel @@ -123,28 +103,6 @@ public class Channel
123 if (nalu == null) break; 103 if (nalu == null) break;
124 if (nalu.length < 4) continue; 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 // 1. 封装为 FLV Tag (必须) 106 // 1. 封装为 FLV Tag (必须)
149 // FFmpeg 通过 HTTP 读取这些 FLV Tag 107 // FFmpeg 通过 HTTP 读取这些 FLV Tag
150 byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp)); 108 byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp));
@@ -156,281 +114,6 @@ public class Channel @@ -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 public void broadcastVideo(long timeoffset, byte[] flvTag) 117 public void broadcastVideo(long timeoffset, byte[] flvTag)
435 { 118 {
436 for (Subscriber subscriber : subscribers) 119 for (Subscriber subscriber : subscribers)
@@ -463,8 +146,6 @@ public class Channel @@ -463,8 +146,6 @@ public class Channel
463 146
464 public void close() 147 public void close()
465 { 148 {
466 - logger.info("[{}] 关闭Channel,开始清理资源...", tag);  
467 -  
468 for (Iterator<Subscriber> itr = subscribers.iterator(); itr.hasNext(); ) 149 for (Iterator<Subscriber> itr = subscribers.iterator(); itr.hasNext(); )
469 { 150 {
470 Subscriber subscriber = itr.next(); 151 Subscriber subscriber = itr.next();
@@ -472,17 +153,41 @@ public class Channel @@ -472,17 +153,41 @@ public class Channel
472 itr.remove(); 153 itr.remove();
473 } 154 }
474 155
475 - // 关闭 FFmpeg 进程 156 + // [恢复] 关闭 FFmpeg 进程
476 if (rtmpPublisher != null) { 157 if (rtmpPublisher != null) {
477 - logger.info("[{}] 关闭FFmpeg推流进程...", tag);  
478 rtmpPublisher.close(); 158 rtmpPublisher.close();
479 rtmpPublisher = null; 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 // [恢复] 原版 readNalu (FFmpeg 偏好带 StartCode 的数据,或者 FlvEncoder 需要) 189 // [恢复] 原版 readNalu (FFmpeg 偏好带 StartCode 的数据,或者 FlvEncoder 需要)
  190 + // 之前为了 RTP 特意修改了切片逻辑,现在改回原版简单逻辑即可
486 private byte[] readNalu() 191 private byte[] readNalu()
487 { 192 {
488 // 寻找 00 00 00 01 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,9 +29,15 @@ public final class PublishManager
29 chl = new Channel(tag); 29 chl = new Channel(tag);
30 channels.put(tag, chl); 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 subscriber.setName("subscriber-" + tag + "-" + subscriber.getId()); 42 subscriber.setName("subscriber-" + tag + "-" + subscriber.getId());
37 subscriber.start(); 43 subscriber.start();
@@ -68,6 +74,17 @@ public final class PublishManager @@ -68,6 +74,17 @@ public final class PublishManager
68 if (chl != null) chl.close(); 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 public void unsubscribe(String tag, long watcherId) 88 public void unsubscribe(String tag, long watcherId)
72 { 89 {
73 Channel chl = channels.get(tag); 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,6 +36,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
36 private long currentSecondFlow = 0; 36 private long currentSecondFlow = 0;
37 private int currentSecondCount = 0; 37 private int currentSecondCount = 0;
38 private long lastRedisKeepAliveTime = 0; 38 private long lastRedisKeepAliveTime = 0;
  39 + private long lastAudioTraceTime = 0;
  40 + private long audioPacketCount = 0;
  41 + private long audioPublishCount = 0;
39 42
40 public Jtt1078Handler(Integer port) { 43 public Jtt1078Handler(Integer port) {
41 this.port = port; 44 this.port = port;
@@ -142,6 +145,19 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; { @@ -142,6 +145,19 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
142 return 28; // 视频 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 private void processMediaPayload(io.netty.channel.Channel nettyChannel, Packet packet, String tag, int lengthOffset) { 161 private void processMediaPayload(io.netty.channel.Channel nettyChannel, Packet packet, String tag, int lengthOffset) {
146 Integer sequence = SessionManager.get(nettyChannel, "video-sequence"); 162 Integer sequence = SessionManager.get(nettyChannel, "video-sequence");
147 if (sequence == null) sequence = 0; 163 if (sequence == null) sequence = 0;
@@ -154,9 +170,13 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; { @@ -154,9 +170,13 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
154 packet.seek(5); 170 packet.seek(5);
155 int pt = packet.nextByte() & 0x7f; 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 if (pkType == 0 || pkType == 2) { 180 if (pkType == 0 || pkType == 2) {
161 sequence += 1; 181 sequence += 1;
162 SessionManager.set(nettyChannel, "video-sequence", sequence); 182 SessionManager.set(nettyChannel, "video-sequence", sequence);
@@ -168,24 +188,26 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; { @@ -168,24 +188,26 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
168 PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, videoData); 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,37 +10,26 @@ import java.util.concurrent.TimeUnit;
10 10
11 /** 11 /**
12 * FFmpeg 推流器 (仅处理视频直播流) 12 * FFmpeg 推流器 (仅处理视频直播流)
13 - *  
14 - * 修改记录:  
15 - * 1. 添加详细的启动和关闭日志,便于排查问题  
16 - * 2. 优化关闭逻辑,确保FFmpeg进程被正确终止  
17 - * 3. 添加FFmpeg输出日志监控  
18 - * 4. 兼容Java 8  
19 */ 13 */
20 public class RTMPPublisher extends Thread 14 public class RTMPPublisher extends Thread
21 { 15 {
22 static Logger logger = LoggerFactory.getLogger(RTMPPublisher.class); 16 static Logger logger = LoggerFactory.getLogger(RTMPPublisher.class);
23 17
24 String tag = null; 18 String tag = null;
  19 + boolean audioOnly = false;
25 Process process = null; 20 Process process = null;
26 private volatile boolean running = true; 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 public RTMPPublisher(String tag) 23 public RTMPPublisher(String tag)
41 { 24 {
  25 + this(tag, false);
  26 + }
  27 +
  28 + public RTMPPublisher(String tag, boolean audioOnly)
  29 + {
42 this.tag = tag; 30 this.tag = tag;
43 - this.setName("RTMPPublisher-" + tag); 31 + this.audioOnly = audioOnly;
  32 + this.setName((audioOnly ? "RTMPPublisher-Audio-" : "RTMPPublisher-") + tag);
44 this.setDaemon(true); 33 this.setDaemon(true);
45 } 34 }
46 35
@@ -55,45 +44,52 @@ public class RTMPPublisher extends Thread @@ -55,45 +44,52 @@ public class RTMPPublisher extends Thread
55 44
56 try 45 try
57 { 46 {
58 - // 获取FFmpeg配置  
59 String sign = "41db35390ddad33f83944f44b8b75ded"; 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 if (rtmpUrl.startsWith("rtsp://")) { 53 if (rtmpUrl.startsWith("rtsp://")) {
66 formatFlag = "-f rtsp"; 54 formatFlag = "-f rtsp";
67 } else if (rtmpUrl.startsWith("rtmp://")) { 55 } else if (rtmpUrl.startsWith("rtmp://")) {
68 formatFlag = "-f flv"; 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 process = Runtime.getRuntime().exec(cmd); 92 process = Runtime.getRuntime().exec(cmd);
92 - processStartTime = System.currentTimeMillis();  
93 -  
94 - // 记录进程启动信息(Java 8兼容方式)  
95 - logger.info("[{}] FFmpeg进程已启动", tag);  
96 -  
97 stderr = process.getErrorStream(); 93 stderr = process.getErrorStream();
98 stdout = process.getInputStream(); 94 stdout = process.getInputStream();
99 95
@@ -102,15 +98,9 @@ public class RTMPPublisher extends Thread @@ -102,15 +98,9 @@ public class RTMPPublisher extends Thread
102 Thread stdoutConsumer = new Thread(() -> { 98 Thread stdoutConsumer = new Thread(() -> {
103 try { 99 try {
104 byte[] buffer = new byte[512]; 100 byte[] buffer = new byte[512];
105 - int count = 0;  
106 while (running && finalStdout.read(buffer) > -1) { 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 } catch (Exception e) { 104 } catch (Exception e) {
115 // 忽略异常 105 // 忽略异常
116 } 106 }
@@ -118,60 +108,26 @@ public class RTMPPublisher extends Thread @@ -118,60 +108,26 @@ public class RTMPPublisher extends Thread
118 stdoutConsumer.setDaemon(true); 108 stdoutConsumer.setDaemon(true);
119 stdoutConsumer.start(); 109 stdoutConsumer.start();
120 110
121 - // 消费 stderr 日志流  
122 - StringBuilder errorLog = new StringBuilder();  
123 - int errorCount = 0; 111 + // 消费 stderr 日志流,防止阻塞
124 while (running && (len = stderr.read(buff)) > -1) 112 while (running && (len = stderr.read(buff)) > -1)
125 { 113 {
126 if (debugMode) { 114 if (debugMode) {
127 System.out.print(new String(buff, 0, len)); 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 int exitCode = process.waitFor(); 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 catch(InterruptedException ex) 123 catch(InterruptedException ex)
168 { 124 {
169 - logger.info("[{}] RTMPPublisher被中断: {}", tag, ex.getMessage()); 125 + logger.info("RTMPPublisher interrupted: {}", tag);
170 Thread.currentThread().interrupt(); 126 Thread.currentThread().interrupt();
171 } 127 }
172 catch(Exception ex) 128 catch(Exception ex)
173 { 129 {
174 - logger.error("[{}] RTMPPublisher异常: {}", tag, ex); 130 + logger.error("RTMPPublisher Error: " + tag, ex);
175 } 131 }
176 finally 132 finally
177 { 133 {
@@ -183,184 +139,40 @@ public class RTMPPublisher extends Thread @@ -183,184 +139,40 @@ public class RTMPPublisher extends Thread
183 closeQuietly(process.getOutputStream()); 139 closeQuietly(process.getOutputStream());
184 closeQuietly(process.getErrorStream()); 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 public void close() 145 public void close()
195 { 146 {
196 - logger.info("[{}] ====== 开始关闭FFmpeg推流 ======", tag);  
197 - logger.info("[{}] 关闭请求时间: {}", tag, new java.util.Date());  
198 -  
199 try { 147 try {
200 // 设置停止标志 148 // 设置停止标志
201 running = false; 149 running = false;
202 150
203 if (process != null) { 151 if (process != null) {
204 - long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0;  
205 - logger.info("[{}] 正在终止FFmpeg进程... (已运行{}ms)", tag, runDuration);  
206 -  
207 // 先尝试正常终止 152 // 先尝试正常终止
208 process.destroy(); 153 process.destroy();
209 154
210 - // 等待最多3 155 + // 等待最多 3
211 boolean exited = process.waitFor(3, TimeUnit.SECONDS); 156 boolean exited = process.waitFor(3, TimeUnit.SECONDS);
212 157
213 if (!exited) { 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 process.destroyForcibly(); 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 this.interrupt(); 169 this.interrupt();
246 170
247 // 等待线程结束 171 // 等待线程结束
248 this.join(2000); 172 this.join(2000);
249 - if (this.isAlive()) {  
250 - logger.warn("[{}] RTMPPublisher线程未能正常结束", tag);  
251 - }  
252 173
253 } catch(Exception e) { 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,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,7 +211,7 @@ public class ZLMHttpHookListener {
211 return new HookResultForOnPublish(200, "success"); 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 StreamProxyItem stream = streamProxyService.getStreamProxyByAppAndStream(param.getApp(), param.getStream()); 215 StreamProxyItem stream = streamProxyService.getStreamProxyByAppAndStream(param.getApp(), param.getStream());
216 if (stream != null) { 216 if (stream != null) {
217 HookResultForOnPublish result = HookResultForOnPublish.SUCCESS(); 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,7 +10,6 @@ import com.genersoft.iot.vmp.conf.UserSetting;
10 import com.genersoft.iot.vmp.gb28181.bean.*; 10 import com.genersoft.iot.vmp.gb28181.bean.*;
11 import com.genersoft.iot.vmp.gb28181.event.EventPublisher; 11 import com.genersoft.iot.vmp.gb28181.event.EventPublisher;
12 import com.genersoft.iot.vmp.gb28181.event.subscribe.catalog.CatalogEvent; 12 import com.genersoft.iot.vmp.gb28181.event.subscribe.catalog.CatalogEvent;
13 -import com.genersoft.iot.vmp.jtt1078.util.Configs;  
14 import com.genersoft.iot.vmp.media.zlm.ZLMRESTfulUtils; 13 import com.genersoft.iot.vmp.media.zlm.ZLMRESTfulUtils;
15 import com.genersoft.iot.vmp.media.zlm.dto.*; 14 import com.genersoft.iot.vmp.media.zlm.dto.*;
16 import com.genersoft.iot.vmp.media.zlm.dto.hook.OnStreamChangedHookParam; 15 import com.genersoft.iot.vmp.media.zlm.dto.hook.OnStreamChangedHookParam;
@@ -24,10 +23,8 @@ import com.genersoft.iot.vmp.storager.mapper.*; @@ -24,10 +23,8 @@ import com.genersoft.iot.vmp.storager.mapper.*;
24 import com.genersoft.iot.vmp.utils.DateUtil; 23 import com.genersoft.iot.vmp.utils.DateUtil;
25 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo; 24 import com.genersoft.iot.vmp.vmanager.bean.ResourceBaseInfo;
26 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean; 25 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.Jt1078ConfigBean;
27 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.RtspConfigBean;  
28 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.ThirdPartyHttpService; 26 import com.genersoft.iot.vmp.vmanager.jt1078.platform.config.ThirdPartyHttpService;
29 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch; 27 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch;
30 -import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9101;  
31 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9102; 28 import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.T9102;
32 import com.genersoft.iot.vmp.vmanager.util.RedisCache; 29 import com.genersoft.iot.vmp.vmanager.util.RedisCache;
33 import com.github.pagehelper.PageHelper; 30 import com.github.pagehelper.PageHelper;
@@ -58,9 +55,6 @@ public class StreamPushServiceImpl implements IStreamPushService { @@ -58,9 +55,6 @@ public class StreamPushServiceImpl implements IStreamPushService {
58 private GbStreamMapper gbStreamMapper; 55 private GbStreamMapper gbStreamMapper;
59 56
60 @Autowired 57 @Autowired
61 - private RtspConfigBean rtspConfigBean;  
62 -  
63 - @Autowired  
64 private StreamPushMapper streamPushMapper; 58 private StreamPushMapper streamPushMapper;
65 59
66 @Autowired 60 @Autowired
src/main/java/com/genersoft/iot/vmp/vmanager/bean/StreamContent.java
@@ -65,6 +65,18 @@ public class StreamContent { @@ -65,6 +65,18 @@ public class StreamContent {
65 @Schema(description = "Websockets-FLV流地址") 65 @Schema(description = "Websockets-FLV流地址")
66 private String wss_flv; 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 * HTTP-FMP4流地址 81 * HTTP-FMP4流地址
70 */ 82 */
@@ -368,6 +380,38 @@ public class StreamContent { @@ -368,6 +380,38 @@ public class StreamContent {
368 this.wss_flv = wss_flv; 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 public String getFmp4() { 415 public String getFmp4() {
372 return fmp4; 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,6 +17,7 @@ import com.genersoft.iot.vmp.conf.security.JwtUtils;
17 import com.genersoft.iot.vmp.conf.security.dto.JwtUser; 17 import com.genersoft.iot.vmp.conf.security.dto.JwtUser;
18 import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp; 18 import com.genersoft.iot.vmp.jtt1078.app.VideoServerApp;
19 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager; 19 import com.genersoft.iot.vmp.jtt1078.publisher.PublishManager;
  20 +import com.genersoft.iot.vmp.jtt1078.util.Configs;
20 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem; 21 import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem;
21 import com.genersoft.iot.vmp.service.IStreamPushService; 22 import com.genersoft.iot.vmp.service.IStreamPushService;
22 import com.genersoft.iot.vmp.service.StremProxyService1078; 23 import com.genersoft.iot.vmp.service.StremProxyService1078;
@@ -66,6 +67,7 @@ import javax.validation.constraints.NotBlank; @@ -66,6 +67,7 @@ import javax.validation.constraints.NotBlank;
66 import java.io.ByteArrayOutputStream; 67 import java.io.ByteArrayOutputStream;
67 import java.io.IOException; 68 import java.io.IOException;
68 import java.io.InputStream; 69 import java.io.InputStream;
  70 +import java.net.URI;
69 import java.net.URISyntaxException; 71 import java.net.URISyntaxException;
70 import java.security.Key; 72 import java.security.Key;
71 import java.security.KeyFactory; 73 import java.security.KeyFactory;
@@ -132,6 +134,10 @@ public class Jt1078OfCarController { @@ -132,6 +134,10 @@ public class Jt1078OfCarController {
132 private static final ConcurrentHashMap<String, String> CHANNEL1_MAP = new ConcurrentHashMap<>(); 134 private static final ConcurrentHashMap<String, String> CHANNEL1_MAP = new ConcurrentHashMap<>();
133 private static final ConcurrentHashMap<String, String> CHANNEL2_MAP = new ConcurrentHashMap<>(); 135 private static final ConcurrentHashMap<String, String> CHANNEL2_MAP = new ConcurrentHashMap<>();
134 private static final ConcurrentHashMap<String, String> CHANNEL3_MAP = new ConcurrentHashMap<>(); 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 static { 142 static {
137 //'ADAS', 'DSM', '路况', '司机', '整车前', '中门', '倒车', '前门客流', '后面客流' 143 //'ADAS', 'DSM', '路况', '司机', '整车前', '中门', '倒车', '前门客流', '后面客流'
@@ -428,7 +434,7 @@ public class Jt1078OfCarController { @@ -428,7 +434,7 @@ public class Jt1078OfCarController {
428 * @return 434 * @return
429 */ 435 */
430 @GetMapping({"/send/request/io/{sim}/{channel}"}) 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 if (StringUtils.isBlank(sim)) { 438 if (StringUtils.isBlank(sim)) {
433 throw new ControllerException(-100, "sim 不能为空"); 439 throw new ControllerException(-100, "sim 不能为空");
434 } 440 }
@@ -440,12 +446,61 @@ public class Jt1078OfCarController { @@ -440,12 +446,61 @@ public class Jt1078OfCarController {
440 if (Objects.isNull(streamContent) || StringUtils.isBlank(streamContent.getWs_flv())) { 446 if (Objects.isNull(streamContent) || StringUtils.isBlank(streamContent.getWs_flv())) {
441 sendIORequest(stream); 447 sendIORequest(stream);
442 } 448 }
443 - streamContent = getStreamContent(stream); 449 + streamContent = getStreamContent(stream, request);
444 streamContent.setPort(jt1078ConfigBean.getPort()); 450 streamContent.setPort(jt1078ConfigBean.getPort());
445 - streamContent.setHttpPort(jt1078ConfigBean.getHttpPort()); 451 + streamContent.setHttpPort(getJttHttpPort());
446 return streamContent; 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 @PostMapping("/switch/stream") 505 @PostMapping("/switch/stream")
451 public int switchStream(@RequestBody StreamSwitch streamSwitch){ 506 public int switchStream(@RequestBody StreamSwitch streamSwitch){
@@ -462,14 +517,14 @@ public class Jt1078OfCarController { @@ -462,14 +517,14 @@ public class Jt1078OfCarController {
462 * @param streamList 流唯一值集合 {sim-channel} 517 * @param streamList 流唯一值集合 {sim-channel}
463 */ 518 */
464 @PostMapping({"/beachSend/request/io"}) 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 if (CollectionUtils.isEmpty(streamList)) { 521 if (CollectionUtils.isEmpty(streamList)) {
467 throw new ControllerException(ErrorCode.ERROR400); 522 throw new ControllerException(ErrorCode.ERROR400);
468 } 523 }
469 List<StreamContent> list = new ArrayList<>(); 524 List<StreamContent> list = new ArrayList<>();
470 for (String stream : streamList) { 525 for (String stream : streamList) {
471 sendIORequest(stream); 526 sendIORequest(stream);
472 - list.add(getStreamContent(stream)); 527 + list.add(getStreamContent(stream, request));
473 } 528 }
474 return list; 529 return list;
475 } 530 }
@@ -492,7 +547,7 @@ public class Jt1078OfCarController { @@ -492,7 +547,7 @@ public class Jt1078OfCarController {
492 redisTemplate.opsForValue().set("patrol:stream:" + stream, patrolDataReq); 547 redisTemplate.opsForValue().set("patrol:stream:" + stream, patrolDataReq);
493 return stream; 548 return stream;
494 }).distinct().collect(Collectors.toList()); 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,6 +559,26 @@ public class Jt1078OfCarController {
504 redisTemplate.delete(keys); 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,7 +1094,7 @@ public class Jt1078OfCarController {
1019 HttpClientPostEntity entity1 = this.httpClientUtil.doPost(url, msg, (String) null); 1094 HttpClientPostEntity entity1 = this.httpClientUtil.doPost(url, msg, (String) null);
1020 //chooseEntity(entity1, url, true); 1095 //chooseEntity(entity1, url, true);
1021 resultMap.put("code", "1"); 1096 resultMap.put("code", "1");
1022 - streamContent = this.getStreamContent(channelMapping); 1097 + streamContent = this.getStreamContent(channelMapping, request);
1023 log.info("StreamContent:[{}]", streamContent); 1098 log.info("StreamContent:[{}]", streamContent);
1024 resultMap.put("data", streamContent); 1099 resultMap.put("data", streamContent);
1025 return resultMap; 1100 return resultMap;
@@ -1155,7 +1230,7 @@ public class Jt1078OfCarController { @@ -1155,7 +1230,7 @@ public class Jt1078OfCarController {
1155 1230
1156 1231
1157 @Nullable 1232 @Nullable
1158 - private StreamContent getStreamContent(String stream) { 1233 + private StreamContent getStreamContent(String stream, HttpServletRequest request) {
1159 StreamContent streamContent = getStreamContentPlayURL(stream); 1234 StreamContent streamContent = getStreamContentPlayURL(stream);
1160 if (Objects.isNull(streamContent) || StringUtils.isEmpty(streamContent.getWs_flv())) { 1235 if (Objects.isNull(streamContent) || StringUtils.isEmpty(streamContent.getWs_flv())) {
1161 streamContent = new StreamContent(); 1236 streamContent = new StreamContent();
@@ -1164,6 +1239,7 @@ public class Jt1078OfCarController { @@ -1164,6 +1239,7 @@ public class Jt1078OfCarController {
1164 streamContent.setWss_flv(StringUtils.replace(jt1078ConfigBean.getWss() + authKey, "{stream}", stream)); 1239 streamContent.setWss_flv(StringUtils.replace(jt1078ConfigBean.getWss() + authKey, "{stream}", stream));
1165 streamContent.setFlv(StringUtils.replace(jt1078ConfigBean.getDownloadFlv() + authKey, "{stream}", stream)); 1240 streamContent.setFlv(StringUtils.replace(jt1078ConfigBean.getDownloadFlv() + authKey, "{stream}", stream));
1166 } 1241 }
  1242 + appendAudioPlayUrl(streamContent, stream, request);
1167 return streamContent; 1243 return streamContent;
1168 } 1244 }
1169 1245
@@ -1180,6 +1256,106 @@ public class Jt1078OfCarController { @@ -1180,6 +1256,106 @@ public class Jt1078OfCarController {
1180 return streamContent; 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,7 +32,7 @@ public class GB28181MessageListener {
32 @Resource 32 @Resource
33 private HisToryRecordService hisToryRecordService; 33 private HisToryRecordService hisToryRecordService;
34 34
35 - @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME) 35 + @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME, autoStartup = "false")
36 public void receiveMessage(String convert, Channel channel, Message message) { 36 public void receiveMessage(String convert, Channel channel, Message message) {
37 // 处理消息逻辑(示例:打印消息内容) 37 // 处理消息逻辑(示例:打印消息内容)
38 log.info("rabbitmq 接收 [{}] 队列消息 : {}",RabbitMQConfig.QUEUE_NAME, message); 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 \ No newline at end of file 1 \ No newline at end of file
  2 +package com.genersoft.iot.vmp.vmanager.jt1078.platform.config; import com.genersoft.iot.vmp.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 \ No newline at end of file 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,4 +31,14 @@ public class IntercomController {
31 return AjaxResult.error("建立连接失败: " + e.getMessage()); 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,4 +12,10 @@ public interface IntercomService {
12 String startIntercom(String sim) throws ServiceException; 12 String startIntercom(String sim) throws ServiceException;
13 13
14 void stopIntercom(String sim); 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,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 * 构造 WebSocket 地址 136 * 构造 WebSocket 地址
124 * 目标网关: 118.113.164.50:10202 137 * 目标网关: 118.113.164.50:10202
125 * 协议格式参考提供的 HTML: ws://IP:PORT?sim={sim} 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,7 +14,7 @@ rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign}
14 14
15 #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} 15 #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign}
16 # 设置为on时,控制台将输出ffmpeg的输出 16 # 设置为on时,控制台将输出ffmpeg的输出
17 -debug.mode = on 17 +debug.mode = off
18 18
19 zlm.host = 127.0.0.1 19 zlm.host = 127.0.0.1
20 zlm.http.port = 80 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,8 +174,8 @@ tuohua:
174 ip : 61.169.120.202 174 ip : 61.169.120.202
175 jt1078: 175 jt1078:
176 ws-prefix: ws://192.168.1.117:18090 176 ws-prefix: ws://192.168.1.117:18090
177 - ports: 40001,40001  
178 - port: 40000 177 + ports: 40011,40011
  178 + port: 40010
179 httpPort: 3333 179 httpPort: 3333
180 addPortVal: 0 180 addPortVal: 0
181 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort} 181 pushURL: http://${my.ip}:3333/new/server/{pushKey}/{port}/{httpPort}
web_src/config/index.js
@@ -27,9 +27,9 @@ module.exports = { @@ -27,9 +27,9 @@ module.exports = {
27 }, 27 },
28 28
29 // Various Dev Server settings 29 // Various Dev Server settings
30 - host: "127.0.0.1", 30 + host: "192.168.168.21",
31 useLocalIp: false, // can be overwritten by process.env.HOST 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 autoOpenBrowser: false, 33 autoOpenBrowser: false,
34 errorOverlay: true, 34 errorOverlay: true,
35 notifyOnErrors: true, 35 notifyOnErrors: true,
web_src/src/components/DeviceList1078.vue
@@ -84,12 +84,14 @@ @@ -84,12 +84,14 @@
84 <player-list-component 84 <player-list-component
85 ref="playListComponent" 85 ref="playListComponent"
86 @playerClick="handleClick" 86 @playerClick="handleClick"
  87 + @audio-toggle="handlePlayerAudioToggle"
87 :video-url="videoUrl" 88 :video-url="videoUrl"
88 :videoDataList="videoDataList" 89 :videoDataList="videoDataList"
89 :has-audio="true" 90 :has-audio="true"
90 v-model="windowNum" 91 v-model="windowNum"
91 style="width: 100%; height: 100%;" 92 style="width: 100%; height: 100%;"
92 ></player-list-component> 93 ></player-list-component>
  94 + <audio-player1078 ref="audioPlayer1078"></audio-player1078>
93 </el-main> 95 </el-main>
94 </el-container> 96 </el-container>
95 <el-dialog 97 <el-dialog
@@ -126,6 +128,7 @@ import VehicleList from &quot;./JT1078Components/deviceList/VehicleList.vue&quot;; @@ -126,6 +128,7 @@ import VehicleList from &quot;./JT1078Components/deviceList/VehicleList.vue&quot;;
126 import CarouselConfig from "./CarouselConfig.vue"; 128 import CarouselConfig from "./CarouselConfig.vue";
127 import WindowNumSelect from "./WindowNumSelect.vue"; 129 import WindowNumSelect from "./WindowNumSelect.vue";
128 import PlayerListComponent from './common/PlayerListComponent.vue'; 130 import PlayerListComponent from './common/PlayerListComponent.vue';
  131 +import AudioPlayer1078 from './common/AudioPlayer1078.vue';
129 import VideoPlayer from './common/EasyPlayer.vue'; 132 import VideoPlayer from './common/EasyPlayer.vue';
130 const PCMA_TO_PCM = new Int16Array(256); 133 const PCMA_TO_PCM = new Int16Array(256);
131 for (let i = 0; i < 256; i++) { 134 for (let i = 0; i < 256; i++) {
@@ -143,6 +146,7 @@ export default { @@ -143,6 +146,7 @@ export default {
143 CarouselConfig, 146 CarouselConfig,
144 WindowNumSelect, 147 WindowNumSelect,
145 PlayerListComponent, 148 PlayerListComponent,
  149 + AudioPlayer1078,
146 VideoPlayer 150 VideoPlayer
147 }, 151 },
148 data() { 152 data() {
@@ -192,7 +196,13 @@ export default { @@ -192,7 +196,13 @@ export default {
192 retryTimer: null, 196 retryTimer: null,
193 // 对讲音频播放器 197 // 对讲音频播放器
194 talkFlvPlayer: null, 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 mounted() { 208 mounted() {
@@ -206,6 +216,8 @@ export default { @@ -206,6 +216,8 @@ export default {
206 document.removeEventListener('click', this.hideContextMenu); 216 document.removeEventListener('click', this.hideContextMenu);
207 this.stopCarousel(); 217 this.stopCarousel();
208 this.stopIntercom(); // 确保组件销毁时停止对讲 218 this.stopIntercom(); // 确保组件销毁时停止对讲
  219 + this.stopActiveAudio(true);
  220 + this.clearAudioSubscribe();
209 }, 221 },
210 // 路由离开守卫 222 // 路由离开守卫
211 beforeRouteLeave(to, from, next) { 223 beforeRouteLeave(to, from, next) {
@@ -214,9 +226,13 @@ export default { @@ -214,9 +226,13 @@ export default {
214 type: 'warning' 226 type: 'warning'
215 }).then(() => { 227 }).then(() => {
216 this.stopCarousel(); 228 this.stopCarousel();
  229 + this.stopActiveAudio(true);
  230 + this.clearAudioSubscribe();
217 next(); 231 next();
218 }).catch(() => next(false)); 232 }).catch(() => next(false));
219 } else { 233 } else {
  234 + this.stopActiveAudio(true);
  235 + this.clearAudioSubscribe();
220 next(); 236 next();
221 } 237 }
222 }, 238 },
@@ -238,6 +254,123 @@ export default { @@ -238,6 +254,123 @@ export default {
238 this.windowClickIndex = index + 1; 254 this.windowClickIndex = index + 1;
239 this.windowClickData = data; 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 toggleFullscreen() { 374 toggleFullscreen() {
242 const element = this.$refs.videoMain.$el; 375 const element = this.$refs.videoMain.$el;
243 if (!this.isFullscreen) { 376 if (!this.isFullscreen) {
@@ -375,6 +508,7 @@ export default { @@ -375,6 +508,7 @@ export default {
375 508
376 if (!wssUrl || !wssUrl.startsWith('ws')) { 509 if (!wssUrl || !wssUrl.startsWith('ws')) {
377 loading.close(); 510 loading.close();
  511 + this.notifyIntercomStartFailed(data.sim, 'invalid_ws_url');
378 this.$message.error("后端返回的 WebSocket 地址无效"); 512 this.$message.error("后端返回的 WebSocket 地址无效");
379 return; 513 return;
380 } 514 }
@@ -392,6 +526,15 @@ export default { @@ -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 // 3. 初始化 WebSocket (信令交互) 538 // 3. 初始化 WebSocket (信令交互)
396 initIntercomSession(data, url, loadingInstance) { 539 initIntercomSession(data, url, loadingInstance) {
397 try { 540 try {
@@ -427,12 +570,15 @@ export default { @@ -427,12 +570,15 @@ export default {
427 socket.onerror = (e) => { 570 socket.onerror = (e) => {
428 loadingInstance.close(); 571 loadingInstance.close();
429 console.error("WS Error", e); 572 console.error("WS Error", e);
  573 + this.notifyIntercomStartFailed(this.currentIntercomSim || data.sim, 'ws_error');
430 this.stopIntercom(); 574 this.stopIntercom();
431 this.$message.error("对讲连接中断"); 575 this.$message.error("对讲连接中断");
432 }; 576 };
433 577
434 socket.onclose = () => { 578 socket.onclose = () => {
435 - if (this.isTalking) { 579 + if (!this.isTalking) {
  580 + this.notifyIntercomStartFailed(data.sim, 'ws_close_before_talking');
  581 + } else {
436 console.warn("WebSocket 意外断开"); 582 console.warn("WebSocket 意外断开");
437 this.stopIntercom(); 583 this.stopIntercom();
438 } 584 }
@@ -440,6 +586,7 @@ export default { @@ -440,6 +586,7 @@ export default {
440 586
441 } catch (e) { 587 } catch (e) {
442 loadingInstance.close(); 588 loadingInstance.close();
  589 + this.notifyIntercomStartFailed(data.sim, 'ws_init_exception');
443 this.$message.error("初始化失败: " + e.message); 590 this.$message.error("初始化失败: " + e.message);
444 } 591 }
445 }, 592 },
@@ -803,19 +950,34 @@ export default { @@ -803,19 +950,34 @@ export default {
803 this.batchPlayback(data); 950 this.batchPlayback(data);
804 }, 951 },
805 952
806 - playSingleChannel(data) { 953 + async playSingleChannel(data) {
807 let stream = data.code.replace('-', '_'); 954 let stream = data.code.replace('-', '_');
808 let arr = stream.split("_"); 955 let arr = stream.split("_");
809 if (arr.length < 3) { 956 if (arr.length < 3) {
810 console.warn("Invalid channel code:", data.code); 957 console.warn("Invalid channel code:", data.code);
811 return; 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 this.$axios.get(`/api/jt1078/query/send/request/io/${arr[1]}/${arr[2]}`).then(res => { 969 this.$axios.get(`/api/jt1078/query/send/request/io/${arr[1]}/${arr[2]}`).then(res => {
814 if (res.data.code === 0 || res.data.code === 200) { 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 const idx = this.windowClickIndex - 1; 973 const idx = this.windowClickIndex - 1;
817 this.$set(this.videoUrl, idx, url); 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 const maxWindow = parseInt(this.windowNum) || 4; 981 const maxWindow = parseInt(this.windowNum) || 4;
820 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1; 982 this.windowClickIndex = (this.windowClickIndex % maxWindow) + 1;
821 } else { 983 } else {
@@ -826,10 +988,11 @@ export default { @@ -826,10 +988,11 @@ export default {
826 }); 988 });
827 }, 989 },
828 990
829 - batchPlayback(nodeData) { 991 + async batchPlayback(nodeData) {
830 if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作"); 992 if (this.isCarouselRunning) return this.$message.warning("轮播中无法操作");
831 const channels = nodeData.children; 993 const channels = nodeData.children;
832 if (channels && channels.length > 0) { 994 if (channels && channels.length > 0) {
  995 + await this.resetAudioToDefaultMute();
833 this.videoUrl = []; 996 this.videoUrl = [];
834 this.videoDataList = []; 997 this.videoDataList = [];
835 if (channels.length > 16) this.windowNum = '25'; 998 if (channels.length > 16) this.windowNum = '25';
@@ -848,7 +1011,12 @@ export default { @@ -848,7 +1011,12 @@ export default {
848 list.forEach((item, i) => { 1011 list.forEach((item, i) => {
849 if (channels[i]) { 1012 if (channels[i]) {
850 this.$set(this.videoUrl, i, item.ws_flv); 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 } else { 1022 } else {
@@ -885,6 +1053,12 @@ export default { @@ -885,6 +1053,12 @@ export default {
885 if (this.videoUrl && this.videoUrl[idx]) { 1053 if (this.videoUrl && this.videoUrl[idx]) {
886 this.$confirm(`确认关闭窗口 [${this.windowClickIndex}] ?`, '提示', {type: 'warning'}) 1054 this.$confirm(`确认关闭窗口 [${this.windowClickIndex}] ?`, '提示', {type: 'warning'})
887 .then(() => { 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 this.$set(this.videoUrl, idx, null); 1062 this.$set(this.videoUrl, idx, null);
889 this.$set(this.videoDataList, idx, null); 1063 this.$set(this.videoDataList, idx, null);
890 }).catch(()=>{}); 1064 }).catch(()=>{});
@@ -892,6 +1066,10 @@ export default { @@ -892,6 +1066,10 @@ export default {
892 }, 1066 },
893 1067
894 closeAllVideoNoConfirm() { 1068 closeAllVideoNoConfirm() {
  1069 + if (this.activeAudioSim && this.activeAudioChannel) {
  1070 + this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  1071 + }
  1072 + this.stopActiveAudio(true);
895 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null); 1073 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
896 this.videoDataList = new Array(parseInt(this.windowNum)).fill(null); 1074 this.videoDataList = new Array(parseInt(this.windowNum)).fill(null);
897 }, 1075 },
@@ -997,6 +1175,10 @@ export default { @@ -997,6 +1175,10 @@ export default {
997 }, 1175 },
998 1176
999 applyVideoBatch({urls, infos}) { 1177 applyVideoBatch({urls, infos}) {
  1178 + if (this.activeAudioSim && this.activeAudioChannel) {
  1179 + this.unsubscribeAudio(this.activeAudioSim, this.activeAudioChannel).catch(() => {});
  1180 + }
  1181 + this.stopActiveAudio(true);
1000 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null); 1182 this.videoUrl = new Array(parseInt(this.windowNum)).fill(null);
1001 setTimeout(() => { 1183 setTimeout(() => {
1002 urls.forEach((url, index) => { 1184 urls.forEach((url, index) => {
@@ -1045,7 +1227,9 @@ export default { @@ -1045,7 +1227,9 @@ export default {
1045 infos[i] = { 1227 infos[i] = {
1046 code: currentCodes[i], 1228 code: currentCodes[i],
1047 name: `通道 ${i + 1}`, 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,6 +1261,8 @@ export default {
1077 e.returnValue = ''; 1261 e.returnValue = '';
1078 } 1262 }
1079 this.stopIntercom(); 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,7 +5,6 @@
5 @click="onPlayerClick" 5 @click="onPlayerClick"
6 @mousemove="onMouseMove" 6 @mousemove="onMouseMove"
7 @mouseleave="onMouseLeave" 7 @mouseleave="onMouseLeave"
8 - @contextmenu="onContextMenuWrapper"  
9 > 8 >
10 <div class="custom-top-bar" :class="{ 'hide-bar': !showControls }"> 9 <div class="custom-top-bar" :class="{ 'hide-bar': !showControls }">
11 <div class="top-bar-left"> 10 <div class="top-bar-left">
@@ -19,21 +18,6 @@ @@ -19,21 +18,6 @@
19 </div> 18 </div>
20 19
21 <div :id="uniqueId" ref="container" class="player-box"></div> 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 </div> 21 </div>
38 </template> 22 </template>
39 23
@@ -45,9 +29,8 @@ export default { @@ -45,9 +29,8 @@ export default {
45 isResize: { type: Boolean, default: true }, 29 isResize: { type: Boolean, default: true },
46 videoTitle: { type: String, default: '' }, 30 videoTitle: { type: String, default: '' },
47 hasAudio: { type: Boolean, default: false }, 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 data() { 35 data() {
53 return { 36 return {
@@ -57,15 +40,8 @@ export default { @@ -57,15 +40,8 @@ export default {
57 hasStarted: false, 40 hasStarted: false,
58 showControls: false, 41 showControls: false,
59 controlTimer: null, 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 controlsConfig: { 47 controlsConfig: {
@@ -122,77 +98,11 @@ export default { @@ -122,77 +98,11 @@ export default {
122 } 98 }
123 }, 500); 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 beforeDestroy() { 102 beforeDestroy() {
  103 + this.unbindAudioButtonEvent();
185 this.destroy(); 104 this.destroy();
186 if (this.controlTimer) clearTimeout(this.controlTimer); 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 methods: { 107 methods: {
198 onMouseMove() { 108 onMouseMove() {
@@ -221,6 +131,7 @@ export default { @@ -221,6 +131,7 @@ export default {
221 MSE: true, 131 MSE: true,
222 WCS: true, 132 WCS: true,
223 hasAudio: this.hasAudio, 133 hasAudio: this.hasAudio,
  134 + isMute: true,
224 isLive: true, 135 isLive: true,
225 loading: true, 136 loading: true,
226 isBand: true, // 必须为 true 才能获取网速回调 137 isBand: true, // 必须为 true 才能获取网速回调
@@ -235,13 +146,14 @@ export default { @@ -235,13 +146,14 @@ export default {
235 zoom: true, 146 zoom: true,
236 } 147 }
237 }); 148 });
238 -  
239 this.playerInstance.on('kBps', (speed) => { 149 this.playerInstance.on('kBps', (speed) => {
240 this.netSpeed = speed + '/S'; 150 this.netSpeed = speed + '/S';
241 }); 151 });
242 152
243 this.playerInstance.on('play', () => { 153 this.playerInstance.on('play', () => {
244 this.hasStarted = true; 154 this.hasStarted = true;
  155 + // 强制同步一次初始静音状态,确保默认关闭音频
  156 + this.setMuted(true, false);
245 this.showControls = true; 157 this.showControls = true;
246 if (this.controlTimer) clearTimeout(this.controlTimer); 158 if (this.controlTimer) clearTimeout(this.controlTimer);
247 this.controlTimer = setTimeout(() => { 159 this.controlTimer = setTimeout(() => {
@@ -253,31 +165,18 @@ export default { @@ -253,31 +165,18 @@ export default {
253 console.warn('Player Error:', err); 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 } catch (e) { 181 } catch (e) {
283 console.error("Create Error:", e); 182 console.error("Create Error:", e);
@@ -300,21 +199,16 @@ export default { @@ -300,21 +199,16 @@ export default {
300 destroy() { 199 destroy() {
301 this.hasStarted = false; 200 this.hasStarted = false;
302 this.showControls = false; 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 if (this.playerInstance) { 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 this.playerInstance.destroy(); 208 this.playerInstance.destroy();
316 this.playerInstance = null; 209 this.playerInstance = null;
317 } 210 }
  211 + this.muteEventHandler = null;
318 }, 212 },
319 213
320 destroyAndReplay(url) { 214 destroyAndReplay(url) {
@@ -329,328 +223,90 @@ export default { @@ -329,328 +223,90 @@ export default {
329 this.$emit('click'); 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,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 display: none !important; 384 display: none !important;
729 } 385 }
730 386
@@ -903,52 +559,4 @@ export default { @@ -903,52 +559,4 @@ export default {
903 .player-wrapper .easyplayer-loading-text { 559 .player-wrapper .easyplayer-loading-text {
904 display: none !important; 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 </style> 562 </style>
web_src/src/components/common/PlayerListComponent.vue
@@ -12,13 +12,14 @@ @@ -12,13 +12,14 @@
12 :class="`video${i}`" 12 :class="`video${i}`"
13 :ref="`player${i}`" 13 :ref="`player${i}`"
14 :initial-play-url="videoUrl[i]" 14 :initial-play-url="videoUrl[i]"
  15 + :channel-info="videoDataList[i]"
  16 + :player-index="i"
15 :initial-buffer-time="0.1" 17 :initial-buffer-time="0.1"
16 :show-custom-mask="false" 18 :show-custom-mask="false"
17 :has-audio="true" 19 :has-audio="true"
18 - :sim="playerDataList[i] ? playerDataList[i].sim : ''"  
19 - :channel="playerDataList[i] ? playerDataList[i].channel : ''"  
20 style="width: 100%;height: 100%;" 20 style="width: 100%;height: 100%;"
21 @click="playerClick(item, i, items.length)" 21 @click="playerClick(item, i, items.length)"
  22 + @audio-toggle="handleAudioToggle"
22 ></easyPlayer> 23 ></easyPlayer>
23 24
24 <!-- 选中/悬停的高亮框 (使用绝对定位覆盖,不影响布局) --> 25 <!-- 选中/悬停的高亮框 (使用绝对定位覆盖,不影响布局) -->
@@ -40,10 +41,6 @@ export default { @@ -40,10 +41,6 @@ export default {
40 type: String, 41 type: String,
41 default: '9' 42 default: '9'
42 }, 43 },
43 - hasAudio: {  
44 - type: Boolean,  
45 - default: true  
46 - },  
47 videoUrl: { 44 videoUrl: {
48 type: Array, 45 type: Array,
49 default: [] 46 default: []
@@ -63,16 +60,6 @@ export default { @@ -63,16 +60,6 @@ export default {
63 }, 60 },
64 //计算属性 类似于data概念", 61 //计算属性 类似于data概念",
65 computed: { 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 //监控data中的数据变化", 64 //监控data中的数据变化",
78 watch: { 65 watch: {
@@ -212,6 +199,26 @@ export default { @@ -212,6 +199,26 @@ export default {
212 // 通知父组件,注意:如果布局变了(比如切到1+5),len可能变了 199 // 通知父组件,注意:如果布局变了(比如切到1+5),len可能变了
213 this.$emit('playerClick', data, index, this.items.length); 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 setDivStyle(idx, len) { 223 setDivStyle(idx, len) {
217 // 保持兼容,如果 len 变化了才更新布局 224 // 保持兼容,如果 len 变化了才更新布局
@@ -288,22 +295,6 @@ export default { @@ -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 //生命周期 - 创建完成(可以访问当前this实例)", 299 //生命周期 - 创建完成(可以访问当前this实例)",
309 created() { 300 created() {
打包/config/config.ini
@@ -86,7 +86,7 @@ udpTTL=64 @@ -86,7 +86,7 @@ udpTTL=64
86 86
87 [protocol] 87 [protocol]
88 add_mute_audio=1 88 add_mute_audio=1
89 -continue_push_ms=3000 89 +continue_push_ms=8000
90 enable_audio=1 90 enable_audio=1
91 enable_fmp4=1 91 enable_fmp4=1
92 enable_hls=1 92 enable_hls=1