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