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