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