Browse Source

!66 多人语音通话功能上线

Merge pull request !66 from blue/v_2.0.0
master
blue 2 years ago
committed by Gitee
parent
commit
3c4fedafe6
No known key found for this signature in database GPG Key ID: 173E9B9CA92EEF8F
  1. 3
      .gitignore
  2. 1
      README.md
  3. 2
      im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java
  4. 6
      im-platform/pom.xml
  5. 16
      im-platform/src/main/java/com/bx/implatform/annotation/OnlineCheck.java
  6. 37
      im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java
  7. 43
      im-platform/src/main/java/com/bx/implatform/aspect/OnlineCheckAspect.java
  8. 82
      im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java
  9. 42
      im-platform/src/main/java/com/bx/implatform/config/RedissonConfig.java
  10. 4
      im-platform/src/main/java/com/bx/implatform/config/WebrtcConfig.java
  11. 25
      im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java
  12. 35
      im-platform/src/main/java/com/bx/implatform/controller/SystemController.java
  13. 126
      im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java
  14. 32
      im-platform/src/main/java/com/bx/implatform/controller/WebrtcPrivateController.java
  15. 31
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupAnswerDTO.java
  16. 32
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupCandidateDTO.java
  17. 29
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupDeviceDTO.java
  18. 25
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupFailedDTO.java
  19. 29
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupInviteDTO.java
  20. 23
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupJoinDTO.java
  21. 31
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupOfferDTO.java
  22. 29
      im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupSetupDTO.java
  23. 57
      im-platform/src/main/java/com/bx/implatform/enums/MessageType.java
  24. 27
      im-platform/src/main/java/com/bx/implatform/enums/WebrtcMode.java
  25. 8
      im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java
  26. 79
      im-platform/src/main/java/com/bx/implatform/service/IWebrtcGroupService.java
  27. 6
      im-platform/src/main/java/com/bx/implatform/service/IWebrtcPrivateService.java
  28. 44
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java
  29. 3
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java
  30. 1
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java
  31. 4
      im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
  32. 585
      im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java
  33. 145
      im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java
  34. 33
      im-platform/src/main/java/com/bx/implatform/session/WebrtcGroupSession.java
  35. 11
      im-platform/src/main/java/com/bx/implatform/session/WebrtcPrivateSession.java
  36. 29
      im-platform/src/main/java/com/bx/implatform/session/WebrtcUserInfo.java
  37. 44
      im-platform/src/main/java/com/bx/implatform/util/UserStateUtils.java
  38. 2
      im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java
  39. 22
      im-platform/src/main/java/com/bx/implatform/vo/SystemConfigVO.java
  40. 23
      im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupFailedVO.java
  41. 28
      im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupInfoVO.java
  42. 1
      im-platform/src/main/resources/application.yml
  43. 27
      im-ui/src/App.vue
  44. 76
      im-ui/src/api/camera.js
  45. 47
      im-ui/src/api/enums.js
  46. 3
      im-ui/src/api/eventBus.js
  47. 40
      im-ui/src/api/messageType.js
  48. 151
      im-ui/src/api/rtcGroupApi.js
  49. 66
      im-ui/src/api/rtcPrivateApi.js
  50. 120
      im-ui/src/api/webrtc.js
  51. 34
      im-ui/src/assets/iconfont/iconfont.css
  52. BIN
      im-ui/src/assets/iconfont/iconfont.ttf
  53. 1627
      im-ui/src/components/chat/ChatBox.vue
  54. 36
      im-ui/src/components/chat/ChatItem.vue
  55. 34
      im-ui/src/components/chat/ChatMessageItem.vue
  56. 477
      im-ui/src/components/chat/ChatPrivateVideo.vue
  57. 14
      im-ui/src/components/chat/ChatRecord.vue
  58. 277
      im-ui/src/components/chat/ChatVideoAcceptor.vue
  59. 28
      im-ui/src/components/common/HeadImage.vue
  60. 28
      im-ui/src/components/group/AddGroupMember.vue
  61. 70
      im-ui/src/components/group/GroupMemberItem.vue
  62. 161
      im-ui/src/components/group/GroupMemberSelector.vue
  63. 116
      im-ui/src/components/rtc/RtcGroupJoin.vue
  64. 42
      im-ui/src/components/rtc/RtcGroupVideo.vue
  65. 126
      im-ui/src/components/rtc/RtcPrivateAcceptor.vue
  66. 496
      im-ui/src/components/rtc/RtcPrivateVideo.vue
  67. 3
      im-ui/src/main.js
  68. 16
      im-ui/src/store/chatStore.js
  69. 32
      im-ui/src/store/configStore.js
  70. 4
      im-ui/src/store/index.js
  71. 4
      im-ui/src/view/Chat.vue
  72. 72
      im-ui/src/view/Home.vue
  73. 27
      im-ui/src/view/Login.vue
  74. 71
      im-uniapp/App.vue
  75. 20
      im-uniapp/common/enums.js
  76. 40
      im-uniapp/common/messageType.js
  77. 19
      im-uniapp/components/chat-message-item/chat-message-item.vue
  78. 174
      im-uniapp/components/group-member-selector/group-member-selector.vue
  79. 89
      im-uniapp/components/group-rtc-join/group-rtc-join.vue
  80. 118
      im-uniapp/components/user-search/user-search.vue
  81. 13
      im-uniapp/hybrid/html/rtc-group/index.html
  82. 0
      im-uniapp/hybrid/html/rtc-private/index.html
  83. 2
      im-uniapp/main.js
  84. 2
      im-uniapp/manifest.json
  85. 6
      im-uniapp/pages.json
  86. 130
      im-uniapp/pages/chat/chat-box.vue
  87. 144
      im-uniapp/pages/chat/chat-group-video.vue
  88. 24
      im-uniapp/pages/chat/chat-private-video.vue
  89. 6
      im-uniapp/pages/group/group.vue
  90. 19
      im-uniapp/ssl/cert.crt
  91. 27
      im-uniapp/ssl/cert.key
  92. 14
      im-uniapp/store/chatStore.js
  93. 32
      im-uniapp/store/configStore.js
  94. 10
      im-uniapp/store/index.js
  95. 3
      im-uniapp/store/userStore.js
  96. 10
      im-uniapp/vite.config.js

3
.gitignore

@ -13,3 +13,6 @@
/im-uniapp/unpackage/ /im-uniapp/unpackage/
/im-uniapp/hybrid/ /im-uniapp/hybrid/
/im-uniapp/package-lock.json /im-uniapp/package-lock.json
/im-ui/src/components/rtc/LocalVideo.vue
/im-ui/src/components/rtc/RemoteVideo.vue
/im-ui/src/components/rtc/RtcGroupAcceptor.vue

1
README.md

@ -255,6 +255,5 @@ wsApi.onClose((e) => {
1. 本系统允许用于商业用途,且不收费(自愿投币)。**但切记不要用于任何非法用途** ,本软件作者不会为此承担任何责任 1. 本系统允许用于商业用途,且不收费(自愿投币)。**但切记不要用于任何非法用途** ,本软件作者不会为此承担任何责任
1. 基于本系统二次开发后再次开源的项目,请注明引用出处,以避免引发不必要的误会 1. 基于本系统二次开发后再次开源的项目,请注明引用出处,以避免引发不必要的误会
1. 如果您也想体验开源(bei bai piao)的快感,成为本项目的贡献者,欢迎提交PR。开发前最好提前联系作者,避免功能重复开发
1. 作者目前不打算接项目,如果能接受1k/天以上的报价,也可以聊聊 1. 作者目前不打算接项目,如果能接受1k/天以上的报价,也可以聊聊

2
im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java

@ -5,7 +5,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* @author: 谢绍许 * @author: Blue
* @date: 2023-09-24 09:23:11 * @date: 2023-09-24 09:23:11
* @version: 1.0 * @version: 1.0
*/ */

6
im-platform/pom.xml

@ -110,7 +110,11 @@
<artifactId>mybatis-plus-generator</artifactId> <artifactId>mybatis-plus-generator</artifactId>
<version>3.3.2</version> <version>3.3.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.3</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

16
im-platform/src/main/java/com/bx/implatform/annotation/OnlineCheck.java

@ -0,0 +1,16 @@
package com.bx.implatform.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 在线校验标注此注解的接口用户必须保持长连接否则将抛异常
*/
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface OnlineCheck {
}

37
im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java

@ -0,0 +1,37 @@
package com.bx.implatform.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 分布式锁注解
*/
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface RedisLock {
/**
* key的前缀,prefixKey+key就是redis的key
*/
String prefixKey() ;
/**
* spel 表达式
*/
String key();
/**
* 等待锁的时间默认-1不等待直接失败,redisson默认也是-1
*/
int waitTime() default -1;
/**
* 等待锁的时间单位默认毫秒
*
*/
TimeUnit unit() default TimeUnit.MILLISECONDS;
}

43
im-platform/src/main/java/com/bx/implatform/aspect/OnlineCheckAspect.java

@ -0,0 +1,43 @@
package com.bx.implatform.aspect;
import cn.hutool.core.util.StrUtil;
import com.bx.imclient.IMClient;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* @author: blue
* @date: 2024-06-16
* @version: 1.0
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OnlineCheckAspect {
private final IMClient imClient;
@Around("@annotation(com.bx.implatform.annotation.OnlineCheck)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
UserSession session = SessionContext.getSession();
if(!imClient.isOnline(session.getUserId())){
throw new GlobalException("您当前的网络连接已断开,请稍后重试");
}
return joinPoint.proceed();
}
}

82
im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java

@ -0,0 +1,82 @@
package com.bx.implatform.aspect;
import cn.hutool.core.util.StrUtil;
import com.bx.implatform.annotation.RedisLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.Redisson;
import org.redisson.RedissonLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.annotation.Order;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;
/**
* @author: blue
* @date: 2024-06-09
* @version: 1.0
*/
@Slf4j
@Aspect
@Order(0)
@Component
@RequiredArgsConstructor
public class RedisLockAspect {
private ExpressionParser parser = new SpelExpressionParser();
private DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private final RedissonClient redissonClient;
@Around("@annotation(com.bx.implatform.annotation.RedisLock)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RedisLock annotation = method.getAnnotation(RedisLock.class);
// 解析表达式中的key
String key = parseKey(joinPoint);
String lockKey = StrUtil.join(":",annotation.prefixKey(),key);
// 上锁
RLock lock = redissonClient.getLock(lockKey);
lock.lock(annotation.waitTime(),annotation.unit());
try {
// 执行方法
return joinPoint.proceed();
}finally {
lock.unlock();
}
}
private String parseKey(ProceedingJoinPoint joinPoint){
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RedisLock annotation = method.getAnnotation(RedisLock.class);
// el解析需要的上下文对象
EvaluationContext context = new StandardEvaluationContext();
// 参数名
String[] params = parameterNameDiscoverer.getParameterNames(method);
if(Objects.isNull(params)){
return annotation.key();
}
Object[] args = joinPoint.getArgs();
for (int i = 0; i < params.length; i++) {
context.setVariable(params[i], args[i]);//所有参数都作为原材料扔进去
}
Expression expression = parser.parseExpression(annotation.key());
return expression.getValue(context, String.class);
}
}

42
im-platform/src/main/java/com/bx/implatform/config/RedissonConfig.java

@ -0,0 +1,42 @@
package com.bx.implatform.config;
import cn.hutool.core.util.StrUtil;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: Blue
* @date: 2024-06-09
* @version: 1.0
*/
@Configuration
@ConditionalOnClass(Config.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfig {
@Bean
RedissonClient redissonClient(RedisProperties redisProperties) {
Config config = new Config();
config.setCodec(new StringCodec());
String address = "redis://" + redisProperties.getHost()+":"+redisProperties.getPort();
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(address)
.setDatabase(redisProperties.getDatabase());
if(StrUtil.isNotEmpty(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
}

4
im-platform/src/main/java/com/bx/implatform/config/ICEServerConfig.java → im-platform/src/main/java/com/bx/implatform/config/WebrtcConfig.java

@ -10,7 +10,9 @@ import java.util.List;
@Data @Data
@Component @Component
@ConfigurationProperties(prefix = "webrtc") @ConfigurationProperties(prefix = "webrtc")
public class ICEServerConfig { public class WebrtcConfig {
private Integer maxChannel = 9;
private List<ICEServer> iceServers = new ArrayList<>(); private List<ICEServer> iceServers = new ArrayList<>();

25
im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java

@ -2,17 +2,22 @@ package com.bx.implatform.contant;
public final class RedisKey { public final class RedisKey {
private RedisKey() { /**
} * 用户状态 无值:空闲 1:正在忙
*/
public static final String IM_USER_STATE = "im:user:state";
/** /**
* 已读群聊消息位置(已读最大id) * 已读群聊消息位置(已读最大id)
*/ */
public static final String IM_GROUP_READED_POSITION = "im:readed:group:position"; public static final String IM_GROUP_READED_POSITION = "im:readed:group:position";
/** /**
* webrtc 会话信息 * webrtc 单人通话
*/ */
public static final String IM_WEBRTC_SESSION = "im:webrtc:session"; public static final String IM_WEBRTC_PRIVATE_SESSION = "im:webrtc:private:session";
/**
* webrtc 群通话
*/
public static final String IM_WEBRTC_GROUP_SESSION = "im:webrtc:group:session";
/** /**
* 缓存前缀 * 缓存前缀
*/ */
@ -30,4 +35,14 @@ public final class RedisKey {
*/ */
public static final String IM_CACHE_GROUP_MEMBER_ID = IM_CACHE + "group_member_ids"; public static final String IM_CACHE_GROUP_MEMBER_ID = IM_CACHE + "group_member_ids";
/**
* 分布式锁前缀
*/
public static final String IM_LOCK = "im:lock:";
/**
* 分布式锁前缀
*/
public static final String IM_LOCK_RTC_GROUP = IM_LOCK + "rtc:group";
} }

35
im-platform/src/main/java/com/bx/implatform/controller/SystemController.java

@ -0,0 +1,35 @@
package com.bx.implatform.controller;
import com.bx.implatform.config.WebrtcConfig;
import com.bx.implatform.dto.PrivateMessageDTO;
import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.vo.SystemConfigVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* @author: blue
* @date: 2024-06-10
* @version: 1.0
*/
@Api(tags = "系统相关")
@RestController
@RequestMapping("/system")
@RequiredArgsConstructor
public class SystemController {
private final WebrtcConfig webrtcConfig;
@GetMapping("/config")
@ApiOperation(value = "加载系统配置", notes = "加载系统配置")
public Result<SystemConfigVO> loadConfig() {
return ResultUtils.success(new SystemConfigVO(webrtcConfig));
}
}

126
im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java

@ -0,0 +1,126 @@
package com.bx.implatform.controller;
import com.bx.implatform.config.WebrtcConfig;
import com.bx.implatform.dto.*;
import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.service.IWebrtcGroupService;
import com.bx.implatform.vo.WebrtcGroupInfoVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Api(tags = "webrtc视频多人通话")
@RestController
@RequestMapping("/webrtc/group")
@RequiredArgsConstructor
public class WebrtcGroupController {
private final IWebrtcGroupService webrtcGroupService;
@ApiOperation(httpMethod = "POST", value = "发起群视频通话")
@PostMapping("/setup")
public Result setup(@Valid @RequestBody WebrtcGroupSetupDTO dto) {
webrtcGroupService.setup(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "接受通话")
@PostMapping("/accept")
public Result accept(@RequestParam Long groupId) {
webrtcGroupService.accept(groupId);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "拒绝通话")
@PostMapping("/reject")
public Result reject(@RequestParam Long groupId) {
webrtcGroupService.reject(groupId);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "通话失败")
@PostMapping("/failed")
public Result failed(@Valid @RequestBody WebrtcGroupFailedDTO dto) {
webrtcGroupService.failed(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "进入视频通话")
@PostMapping("/join")
public Result join(@RequestParam Long groupId) {
webrtcGroupService.join(groupId);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "取消通话")
@PostMapping("/cancel")
public Result cancel(@RequestParam Long groupId) {
webrtcGroupService.cancel(groupId);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "离开视频通话")
@PostMapping("/quit")
public Result quit(@RequestParam Long groupId) {
webrtcGroupService.quit(groupId);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "推送offer信息")
@PostMapping("/offer")
public Result offer(@Valid @RequestBody WebrtcGroupOfferDTO dto) {
webrtcGroupService.offer(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "推送answer信息")
@PostMapping("/answer")
public Result answer(@Valid @RequestBody WebrtcGroupAnswerDTO dto) {
webrtcGroupService.answer(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "邀请用户进入视频通话")
@PostMapping("/invite")
public Result invite(@Valid @RequestBody WebrtcGroupInviteDTO dto) {
webrtcGroupService.invite(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "同步candidate")
@PostMapping("/candidate")
public Result candidate(@Valid @RequestBody WebrtcGroupCandidateDTO dto) {
webrtcGroupService.candidate(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "POST", value = "设备操作")
@PostMapping("/device")
public Result device(@Valid @RequestBody WebrtcGroupDeviceDTO dto) {
webrtcGroupService.device(dto);
return ResultUtils.success();
}
@ApiOperation(httpMethod = "GET", value = "获取通话信息")
@GetMapping("/info")
public Result<WebrtcGroupInfoVO> info(@RequestParam Long groupId) {
return ResultUtils.success(webrtcGroupService.info(groupId));
}
@ApiOperation(httpMethod = "POST", value = "获取通话信息")
@PostMapping("/heartbeat")
public Result heartbeat(@RequestParam Long groupId) {
webrtcGroupService.heartbeat(groupId);
return ResultUtils.success();
}
}

32
im-platform/src/main/java/com/bx/implatform/controller/WebrtcController.java → im-platform/src/main/java/com/bx/implatform/controller/WebrtcPrivateController.java

@ -1,9 +1,10 @@
package com.bx.implatform.controller; package com.bx.implatform.controller;
import com.bx.implatform.annotation.OnlineCheck;
import com.bx.implatform.config.ICEServer; import com.bx.implatform.config.ICEServer;
import com.bx.implatform.result.Result; import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils; import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.service.IWebrtcService; import com.bx.implatform.service.IWebrtcPrivateService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -15,21 +16,22 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/webrtc/private") @RequestMapping("/webrtc/private")
@RequiredArgsConstructor @RequiredArgsConstructor
public class WebrtcController { public class WebrtcPrivateController {
private final IWebrtcService webrtcService; private final IWebrtcPrivateService webrtcPrivateService;
@OnlineCheck
@ApiOperation(httpMethod = "POST", value = "呼叫视频通话") @ApiOperation(httpMethod = "POST", value = "呼叫视频通话")
@PostMapping("/call") @PostMapping("/call")
public Result call(@RequestParam Long uid, @RequestParam(defaultValue = "video") String mode, @RequestBody String offer) { public Result call(@RequestParam Long uid, @RequestParam(defaultValue = "video") String mode, @RequestBody String offer) {
webrtcService.call(uid, mode, offer); webrtcPrivateService.call(uid, mode, offer);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(httpMethod = "POST", value = "接受视频通话") @ApiOperation(httpMethod = "POST", value = "接受视频通话")
@PostMapping("/accept") @PostMapping("/accept")
public Result accept(@RequestParam Long uid, @RequestBody String answer) { public Result accept(@RequestParam Long uid, @RequestBody String answer) {
webrtcService.accept(uid, answer); webrtcPrivateService.accept(uid, answer);
return ResultUtils.success(); return ResultUtils.success();
} }
@ -37,28 +39,28 @@ public class WebrtcController {
@ApiOperation(httpMethod = "POST", value = "拒绝视频通话") @ApiOperation(httpMethod = "POST", value = "拒绝视频通话")
@PostMapping("/reject") @PostMapping("/reject")
public Result reject(@RequestParam Long uid) { public Result reject(@RequestParam Long uid) {
webrtcService.reject(uid); webrtcPrivateService.reject(uid);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(httpMethod = "POST", value = "取消呼叫") @ApiOperation(httpMethod = "POST", value = "取消呼叫")
@PostMapping("/cancel") @PostMapping("/cancel")
public Result cancel(@RequestParam Long uid) { public Result cancel(@RequestParam Long uid) {
webrtcService.cancel(uid); webrtcPrivateService.cancel(uid);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(httpMethod = "POST", value = "呼叫失败") @ApiOperation(httpMethod = "POST", value = "呼叫失败")
@PostMapping("/failed") @PostMapping("/failed")
public Result failed(@RequestParam Long uid, @RequestParam String reason) { public Result failed(@RequestParam Long uid, @RequestParam String reason) {
webrtcService.failed(uid, reason); webrtcPrivateService.failed(uid, reason);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(httpMethod = "POST", value = "挂断") @ApiOperation(httpMethod = "POST", value = "挂断")
@PostMapping("/handup") @PostMapping("/handup")
public Result handup(@RequestParam Long uid) { public Result handup(@RequestParam Long uid) {
webrtcService.handup(uid); webrtcPrivateService.handup(uid);
return ResultUtils.success(); return ResultUtils.success();
} }
@ -66,14 +68,14 @@ public class WebrtcController {
@PostMapping("/candidate") @PostMapping("/candidate")
@ApiOperation(httpMethod = "POST", value = "同步candidate") @ApiOperation(httpMethod = "POST", value = "同步candidate")
public Result candidate(@RequestParam Long uid, @RequestBody String candidate) { public Result candidate(@RequestParam Long uid, @RequestBody String candidate) {
webrtcService.candidate(uid, candidate); webrtcPrivateService.candidate(uid, candidate);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(httpMethod = "POST", value = "获取通话信息")
@GetMapping("/iceservers") @PostMapping("/heartbeat")
@ApiOperation(httpMethod = "GET", value = "获取iceservers") public Result heartbeat(@RequestParam Long uid) {
public Result<List<ICEServer>> iceservers() { webrtcPrivateService.heartbeat(uid);
return ResultUtils.success(webrtcService.getIceServers()); return ResultUtils.success();
} }
} }

31
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupAnswerDTO.java

@ -0,0 +1,31 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("回复用户连接请求DTO")
public class WebrtcGroupAnswerDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@NotNull(message = "用户id不可为空")
@ApiModelProperty(value = "用户id,代表回复谁的连接请求")
private Long userId;
@NotEmpty(message = "anwer不可为空")
@ApiModelProperty(value = "用户本地anwer信息")
private String answer;
}

32
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupCandidateDTO.java

@ -0,0 +1,32 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("发起群视频通话DTO")
public class WebrtcGroupCandidateDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@NotNull(message = "用户id不可为空")
@ApiModelProperty(value = "用户id")
private Long userId;
@NotEmpty(message = "candidate信息不可为空")
@ApiModelProperty(value = "candidate信息")
private String candidate;
}

29
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupDeviceDTO.java

@ -0,0 +1,29 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("用户设备操作DTO")
public class WebrtcGroupDeviceDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@ApiModelProperty(value = "是否开启摄像头")
private Boolean isCamera;
@ApiModelProperty(value = "是否开启麦克风")
private Boolean isMicroPhone;
}

25
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupFailedDTO.java

@ -0,0 +1,25 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("用户通话失败DTO")
public class WebrtcGroupFailedDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@ApiModelProperty(value = "失败原因")
private String reason;
}

29
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupInviteDTO.java

@ -0,0 +1,29 @@
package com.bx.implatform.dto;
import com.bx.implatform.session.WebrtcUserInfo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("邀请用户进入群视频通话DTO")
public class WebrtcGroupInviteDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@NotEmpty(message = "参与用户信息不可为空")
@ApiModelProperty(value = "参与用户信息")
private List<WebrtcUserInfo> userInfos;
}

23
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupJoinDTO.java

@ -0,0 +1,23 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("进入群视频通话DTO")
public class WebrtcGroupJoinDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
}

31
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupOfferDTO.java

@ -0,0 +1,31 @@
package com.bx.implatform.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("回复用户连接请求DTO")
public class WebrtcGroupOfferDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@NotNull(message = "用户id不可为空")
@ApiModelProperty(value = "用户id,代表回复谁的连接请求")
private Long userId;
@NotEmpty(message = "offer不可为空")
@ApiModelProperty(value = "用户offer信息")
private String offer;
}

29
im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupSetupDTO.java

@ -0,0 +1,29 @@
package com.bx.implatform.dto;
import com.bx.implatform.session.WebrtcUserInfo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
@ApiModel("发起群视频通话DTO")
public class WebrtcGroupSetupDTO {
@NotNull(message = "群聊id不可为空")
@ApiModelProperty(value = "群聊id")
private Long groupId;
@NotEmpty(message = "参与用户信息不可为空")
@ApiModelProperty(value = "参与用户信息")
private List<WebrtcUserInfo> userInfos;
}

57
im-platform/src/main/java/com/bx/implatform/enums/MessageType.java

@ -1,9 +1,18 @@
package com.bx.implatform.enums; package com.bx.implatform.enums;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 0-9: 真正的消息需要存储到数据库
* 10-19: 状态类消息: 撤回已读回执
* 20-29: 提示类消息: 在会话中间显示的提示
* 30-39: UI交互类消息: 显示加载状态等
* 40-49: 操作交互类消息: 语音通话视频通话消息等
* 100-199: 单人语音通话rtc信令
* 200-299: 多人语音通话rtc信令
*
*/
@AllArgsConstructor @AllArgsConstructor
public enum MessageType { public enum MessageType {
@ -27,6 +36,7 @@ public enum MessageType {
* 视频 * 视频
*/ */
VIDEO(4, "视频"), VIDEO(4, "视频"),
/** /**
* 撤回 * 撤回
*/ */
@ -48,44 +58,41 @@ public enum MessageType {
* 文字提示 * 文字提示
*/ */
TIP_TEXT(21,"文字提示"), TIP_TEXT(21,"文字提示"),
/** /**
* 消息加载标记 * 消息加载标记
*/ */
LOADDING(30,"加载中"), LOADING(30,"加载中"),
/** /**
* 语音呼叫 * 语音通话提示
*/ */
RTC_CALL_VOICE(100, "语音呼叫"), ACT_RT_VOICE(40,"语音通话"),
/** /**
* 视频呼叫 * 视频通话提示
*/ */
ACT_RT_VIDEO(41,"视频通话"),
RTC_CALL_VOICE(100, "语音呼叫"),
RTC_CALL_VIDEO(101, "视频呼叫"), RTC_CALL_VIDEO(101, "视频呼叫"),
/**
* 接受
*/
RTC_ACCEPT(102, "接受"), RTC_ACCEPT(102, "接受"),
/**
* 拒绝
*/
RTC_REJECT(103, "拒绝"), RTC_REJECT(103, "拒绝"),
/**
* 取消呼叫
*/
RTC_CANCEL(104, "取消呼叫"), RTC_CANCEL(104, "取消呼叫"),
/**
* 呼叫失败
*/
RTC_FAILED(105, "呼叫失败"), RTC_FAILED(105, "呼叫失败"),
/**
* 挂断
*/
RTC_HANDUP(106, "挂断"), RTC_HANDUP(106, "挂断"),
/** RTC_CANDIDATE(107, "同步candidate"),
* 同步candidate RTC_GROUP_SETUP(200,"发起群视频通话"),
*/ RTC_GROUP_ACCEPT(201,"接受通话呼叫"),
RTC_CANDIDATE(107, "同步candidate"); RTC_GROUP_REJECT(202,"拒绝通话呼叫"),
RTC_GROUP_FAILED(203,"拒绝通话呼叫"),
RTC_GROUP_CANCEL(204,"取消通话呼叫"),
RTC_GROUP_QUIT(205,"退出通话"),
RTC_GROUP_INVITE(206,"邀请进入通话"),
RTC_GROUP_JOIN(207,"主动进入通话"),
RTC_GROUP_OFFER(208,"推送offer信息"),
RTC_GROUP_ANSWER(209,"推送answer信息"),
RTC_GROUP_CANDIDATE(210,"同步candidate"),
RTC_GROUP_DEVICE(211,"设备操作"),
;
private final Integer code; private final Integer code;

27
im-platform/src/main/java/com/bx/implatform/enums/WebrtcMode.java

@ -0,0 +1,27 @@
package com.bx.implatform.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Getter
@AllArgsConstructor
public enum WebrtcMode {
/**
* 视频通话
*/
VIDEO( "video"),
/**
* 语音通话
*/
VOICE( "voice");
private final String value;
}

8
im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java

@ -73,4 +73,12 @@ public interface IGroupMemberService extends IService<GroupMember> {
* @param userId 用户id * @param userId 用户id
*/ */
void removeByGroupAndUserId(Long groupId, Long userId); void removeByGroupAndUserId(Long groupId, Long userId);
/**
* 用户用户是否在群中
*
* @param groupId 群聊id
* @param userIds 用户id
*/
Boolean isInGroup(Long groupId,List<Long> userIds);
} }

79
im-platform/src/main/java/com/bx/implatform/service/IWebrtcGroupService.java

@ -0,0 +1,79 @@
package com.bx.implatform.service;
import com.bx.implatform.config.WebrtcConfig;
import com.bx.implatform.dto.*;
import com.bx.implatform.vo.WebrtcGroupInfoVO;
public interface IWebrtcGroupService {
/**
* 发起通话
*/
void setup(WebrtcGroupSetupDTO dto);
/**
* 接受通话
*/
void accept(Long groupId);
/**
* 拒绝通话
*/
void reject(Long groupId);
/**
* 通话失败,如设备不支持用户忙等(此接口为系统自动调用,无需用户操作所以不抛异常)
*/
void failed(WebrtcGroupFailedDTO dto);
/**
* 主动加入通话
*/
void join(Long groupId);
/**
* 通话过程中继续邀请用户加入通话
*/
void invite(WebrtcGroupInviteDTO dto);
/**
* 取消通话,仅通话发起人可以取消通话
*/
void cancel(Long groupId);
/**
* 退出通话如果当前没有人在通话中将取消整个通话
*/
void quit(Long groupId);
/**
* 推送offer信息给对方
*/
void offer(WebrtcGroupOfferDTO dto);
/**
* 推送answer信息给对方
*/
void answer(WebrtcGroupAnswerDTO dto);
/**
* 推送candidate信息给对方
*/
void candidate(WebrtcGroupCandidateDTO dto);
/**
* 用户进行了设备操作如果关闭摄像头
*/
void device(WebrtcGroupDeviceDTO dto);
/**
* 查询通话信息
*/
WebrtcGroupInfoVO info(Long groupId);
/**
* 心跳保持, 用户每15s上传一次心跳
*/
void heartbeat(Long groupId);
}

6
im-platform/src/main/java/com/bx/implatform/service/IWebrtcService.java → im-platform/src/main/java/com/bx/implatform/service/IWebrtcPrivateService.java

@ -1,7 +1,6 @@
package com.bx.implatform.service; package com.bx.implatform.service;
import com.bx.implatform.config.ICEServer; import com.bx.implatform.config.ICEServer;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List; import java.util.List;
@ -10,7 +9,7 @@ import java.util.List;
* *
* @author * @author
*/ */
public interface IWebrtcService { public interface IWebrtcPrivateService {
void call(Long uid, String mode,String offer); void call(Long uid, String mode,String offer);
@ -26,7 +25,6 @@ public interface IWebrtcService {
void candidate(Long uid, String candidate); void candidate(Long uid, String candidate);
List<ICEServer> getIceServers(); void heartbeat(Long uid);
} }

44
im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java

@ -3,6 +3,7 @@ package com.bx.implatform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.implatform.contant.RedisKey; import com.bx.implatform.contant.RedisKey;
@ -34,30 +35,26 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
return super.saveOrUpdateBatch(members); return super.saveOrUpdateBatch(members);
} }
@Override @Override
public GroupMember findByGroupAndUserId(Long groupId, Long userId) { public GroupMember findByGroupAndUserId(Long groupId, Long userId) {
QueryWrapper<GroupMember> wrapper = new QueryWrapper<>(); QueryWrapper<GroupMember> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(GroupMember::getGroupId, groupId) wrapper.lambda().eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId);
.eq(GroupMember::getUserId, userId);
return this.getOne(wrapper); return this.getOne(wrapper);
} }
@Override @Override
public List<GroupMember> findByUserId(Long userId) { public List<GroupMember> findByUserId(Long userId) {
LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery(); LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery();
memberWrapper.eq(GroupMember::getUserId, userId) memberWrapper.eq(GroupMember::getUserId, userId).eq(GroupMember::getQuit, false);
.eq(GroupMember::getQuit, false);
return this.list(memberWrapper); return this.list(memberWrapper);
} }
@Override @Override
public List<GroupMember> findQuitInMonth(Long userId) { public List<GroupMember> findQuitInMonth(Long userId) {
Date monthTime = DateTimeUtils.addMonths(new Date(),-1); Date monthTime = DateTimeUtils.addMonths(new Date(), -1);
LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery(); LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery();
memberWrapper.eq(GroupMember::getUserId, userId) memberWrapper.eq(GroupMember::getUserId, userId).eq(GroupMember::getQuit, true)
.eq(GroupMember::getQuit, true) .ge(GroupMember::getQuitTime, monthTime);
.ge(GroupMember::getQuitTime,monthTime);
return this.list(memberWrapper); return this.list(memberWrapper);
} }
@ -72,9 +69,8 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
@Override @Override
public List<Long> findUserIdsByGroupId(Long groupId) { public List<Long> findUserIdsByGroupId(Long groupId) {
LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery(); LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery();
memberWrapper.eq(GroupMember::getGroupId, groupId) memberWrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getQuit, false)
.eq(GroupMember::getQuit, false) .select(GroupMember::getUserId);
.select(GroupMember::getUserId);
List<GroupMember> members = this.list(memberWrapper); List<GroupMember> members = this.list(memberWrapper);
return members.stream().map(GroupMember::getUserId).collect(Collectors.toList()); return members.stream().map(GroupMember::getUserId).collect(Collectors.toList());
} }
@ -83,9 +79,8 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
@Override @Override
public void removeByGroupId(Long groupId) { public void removeByGroupId(Long groupId) {
LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate(); LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate();
wrapper.eq(GroupMember::getGroupId, groupId) wrapper.eq(GroupMember::getGroupId, groupId).set(GroupMember::getQuit, true)
.set(GroupMember::getQuit, true) .set(GroupMember::getQuitTime, new Date());
.set(GroupMember::getQuitTime,new Date());
this.update(wrapper); this.update(wrapper);
} }
@ -93,10 +88,19 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
@Override @Override
public void removeByGroupAndUserId(Long groupId, Long userId) { public void removeByGroupAndUserId(Long groupId, Long userId) {
LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate(); LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate();
wrapper.eq(GroupMember::getGroupId, groupId) wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId).set(GroupMember::getQuit, true)
.eq(GroupMember::getUserId, userId) .set(GroupMember::getQuitTime, new Date());
.set(GroupMember::getQuit, true)
.set(GroupMember::getQuitTime,new Date());
this.update(wrapper); this.update(wrapper);
} }
@Override
public Boolean isInGroup(Long groupId, List<Long> userIds) {
if (CollectionUtils.isEmpty(userIds)) {
return true;
}
LambdaQueryWrapper<GroupMember> wrapper = Wrappers.lambdaQuery();
wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getQuit, false)
.in(GroupMember::getUserId, userIds);
return userIds.size() == this.count(wrapper);
}
} }

3
im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java

@ -345,7 +345,6 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
QueryWrapper<GroupMessage> wrapper = new QueryWrapper<>(); QueryWrapper<GroupMessage> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(GroupMessage::getGroupId, groupId).gt(GroupMessage::getSendTime, member.getCreatedTime()) wrapper.lambda().eq(GroupMessage::getGroupId, groupId).gt(GroupMessage::getSendTime, member.getCreatedTime())
.ne(GroupMessage::getStatus, MessageStatus.RECALL.code()).orderByDesc(GroupMessage::getId).last("limit " + stIdx + "," + size); .ne(GroupMessage::getStatus, MessageStatus.RECALL.code()).orderByDesc(GroupMessage::getId).last("limit " + stIdx + "," + size);
List<GroupMessage> messages = this.list(wrapper); List<GroupMessage> messages = this.list(wrapper);
List<GroupMessageVO> messageInfos = List<GroupMessageVO> messageInfos =
messages.stream().map(m -> BeanUtils.copyProperties(m, GroupMessageVO.class)).collect(Collectors.toList()); messages.stream().map(m -> BeanUtils.copyProperties(m, GroupMessageVO.class)).collect(Collectors.toList());
@ -369,7 +368,7 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
private void sendLoadingMessage(Boolean isLoadding){ private void sendLoadingMessage(Boolean isLoadding){
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
GroupMessageVO msgInfo = new GroupMessageVO(); GroupMessageVO msgInfo = new GroupMessageVO();
msgInfo.setType(MessageType.LOADDING.code()); msgInfo.setType(MessageType.LOADING.code());
msgInfo.setContent(isLoadding.toString()); msgInfo.setContent(isLoadding.toString());
IMGroupMessage sendMessage = new IMGroupMessage<>(); IMGroupMessage sendMessage = new IMGroupMessage<>();
sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal()));

1
im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java

@ -50,6 +50,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
private final IFriendService friendsService; private final IFriendService friendsService;
private final IMClient imClient; private final IMClient imClient;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
@Override @Override
public GroupVO createGroup(GroupVO vo) { public GroupVO createGroup(GroupVO vo) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();

4
im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java

@ -9,7 +9,6 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.imclient.IMClient; import com.bx.imclient.IMClient;
import com.bx.imcommon.contant.IMConstant; import com.bx.imcommon.contant.IMConstant;
import com.bx.imcommon.enums.IMTerminalType; import com.bx.imcommon.enums.IMTerminalType;
import com.bx.imcommon.model.IMGroupMessage;
import com.bx.imcommon.model.IMPrivateMessage; import com.bx.imcommon.model.IMPrivateMessage;
import com.bx.imcommon.model.IMUserInfo; import com.bx.imcommon.model.IMUserInfo;
import com.bx.implatform.dto.PrivateMessageDTO; import com.bx.implatform.dto.PrivateMessageDTO;
@ -26,7 +25,6 @@ import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession; import com.bx.implatform.session.UserSession;
import com.bx.implatform.util.BeanUtils; import com.bx.implatform.util.BeanUtils;
import com.bx.implatform.util.SensitiveFilterUtil; import com.bx.implatform.util.SensitiveFilterUtil;
import com.bx.implatform.vo.GroupMessageVO;
import com.bx.implatform.vo.PrivateMessageVO; import com.bx.implatform.vo.PrivateMessageVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -245,7 +243,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
private void sendLoadingMessage(Boolean isLoadding){ private void sendLoadingMessage(Boolean isLoadding){
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
PrivateMessageVO msgInfo = new PrivateMessageVO(); PrivateMessageVO msgInfo = new PrivateMessageVO();
msgInfo.setType(MessageType.LOADDING.code()); msgInfo.setType(MessageType.LOADING.code());
msgInfo.setContent(isLoadding.toString()); msgInfo.setContent(isLoadding.toString());
IMPrivateMessage sendMessage = new IMPrivateMessage<>(); IMPrivateMessage sendMessage = new IMPrivateMessage<>();
sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal()));

585
im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java

@ -0,0 +1,585 @@
package com.bx.implatform.service.impl;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.bx.imclient.IMClient;
import com.bx.imcommon.model.IMGroupMessage;
import com.bx.imcommon.model.IMUserInfo;
import com.bx.implatform.annotation.OnlineCheck;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.config.WebrtcConfig;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.dto.*;
import com.bx.implatform.entity.GroupMember;
import com.bx.implatform.entity.GroupMessage;
import com.bx.implatform.enums.MessageStatus;
import com.bx.implatform.enums.MessageType;
import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.service.IGroupMemberService;
import com.bx.implatform.service.IGroupMessageService;
import com.bx.implatform.service.IWebrtcGroupService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession;
import com.bx.implatform.session.WebrtcGroupSession;
import com.bx.implatform.session.WebrtcUserInfo;
import com.bx.implatform.util.BeanUtils;
import com.bx.implatform.util.UserStateUtils;
import com.bx.implatform.vo.GroupMessageVO;
import com.bx.implatform.vo.WebrtcGroupFailedVO;
import com.bx.implatform.vo.WebrtcGroupInfoVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 群语音通话服务类,所有涉及修改webtcSession的方法都要挂分布式锁
*
* @author: blue
* @date: 2024-06-01
* @version: 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WebrtcGroupServiceImpl implements IWebrtcGroupService {
private final IGroupMemberService groupMemberService;
private final IGroupMessageService groupMessageService;
private final RedisTemplate<String, Object> redisTemplate;
private final IMClient imClient;
private final UserStateUtils userStateUtils;
private final WebrtcConfig webrtcConfig;
@OnlineCheck
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId")
@Override
public void setup(WebrtcGroupSetupDTO dto) {
UserSession userSession = SessionContext.getSession();
if(!imClient.isOnline(userSession.getUserId())){
throw new GlobalException("您已断开连接,请重新登陆");
}
if (dto.getUserInfos().size() > webrtcConfig.getMaxChannel()) {
throw new GlobalException("最多支持" + webrtcConfig.getMaxChannel() + "人进行通话");
}
List<Long> userIds = getRecvIds(dto.getUserInfos());
if (!groupMemberService.isInGroup(dto.getGroupId(), userIds)) {
throw new GlobalException("部分用户不在群聊中");
}
String key = buildWebrtcSessionKey(dto.getGroupId());
if (redisTemplate.hasKey(key)) {
throw new GlobalException("该群聊已存在一个通话");
}
// 有效用户
List<WebrtcUserInfo> userInfos = new LinkedList<>();
// 离线用户
List<Long> offlineUserIds = new LinkedList<>();
// 忙线用户
List<Long> busyUserIds = new LinkedList<>();
for (WebrtcUserInfo userInfo : dto.getUserInfos()) {
if (!imClient.isOnline(userInfo.getId())) {
//userInfos.add(userInfo);
offlineUserIds.add(userInfo.getId());
} else if (userStateUtils.isBusy(userInfo.getId())) {
busyUserIds.add(userInfo.getId());
} else {
userInfos.add(userInfo);
// 设置用户忙线状态
userStateUtils.setBusy(userInfo.getId());
}
}
// 创建通话session
WebrtcGroupSession webrtcSession = new WebrtcGroupSession();
IMUserInfo userInfo = new IMUserInfo(userSession.getUserId(), userSession.getTerminal());
webrtcSession.setHost(userInfo);
webrtcSession.setUserInfos(userInfos);
webrtcSession.getInChatUsers().add(userInfo);
saveWebrtcSession(dto.getGroupId(), webrtcSession);
// 向发起邀请者推送邀请失败消息
if (!offlineUserIds.isEmpty()) {
WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO();
vo.setUserIds(offlineUserIds);
vo.setReason("用户当前不在线");
sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), userInfo, JSON.toJSONString(vo));
}
if (!busyUserIds.isEmpty()) {
WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO();
vo.setUserIds(busyUserIds);
vo.setReason("用户正忙");
IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal());
sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo));
}
// 向被邀请的用户广播消息,发起呼叫
List<Long> recvIds = getRecvIds(userInfos);
sendRtcMessage1(MessageType.RTC_GROUP_SETUP, dto.getGroupId(), recvIds, JSON.toJSONString(userInfos),false);
// 发送文字提示信息
WebrtcUserInfo mineInfo = findUserInfo(webrtcSession,userSession.getUserId());
String content = mineInfo.getNickName() + " 发起了语音通话";
sendTipMessage(dto.getGroupId(),content);
log.info("发起群通话,userId:{},groupId:{}", userSession.getUserId(), dto.getGroupId());
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId")
@Override
public void accept(Long groupId) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(groupId);
// 校验
if (!isExist(webrtcSession, userSession.getUserId())) {
throw new GlobalException("您未被邀请通话");
}
// 防止重复进入
if (isInchat(webrtcSession, userSession.getUserId())) {
throw new GlobalException("您已在通话中");
}
// 将当前用户加入通话用户列表中
webrtcSession.getInChatUsers().add(new IMUserInfo(userSession.getUserId(), userSession.getTerminal()));
saveWebrtcSession(groupId, webrtcSession);
// 广播信令
List<Long> recvIds = getRecvIds(webrtcSession.getUserInfos());
sendRtcMessage1(MessageType.RTC_GROUP_ACCEPT, groupId, recvIds, "",true);
log.info("加入群通话,userId:{},groupId:{}", userSession.getUserId(), groupId);
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId")
@Override
public void reject(Long groupId) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(groupId);
// 校验
if (!isExist(webrtcSession, userSession.getUserId())) {
throw new GlobalException("您未被邀请通话");
}
// 防止重复进入
if (isInchat(webrtcSession, userSession.getUserId())) {
throw new GlobalException("您已在通话中");
}
// 将用户从列表中移除
List<WebrtcUserInfo> userInfos =
webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId()))
.collect(Collectors.toList());
webrtcSession.setUserInfos(userInfos);
saveWebrtcSession(groupId, webrtcSession);
// 进入空闲状态
userStateUtils.setFree(userSession.getUserId());
// 广播消息给的所有用户
List<Long> recvIds = getRecvIds(userInfos);
sendRtcMessage1(MessageType.RTC_GROUP_REJECT, groupId, recvIds, "",true);
log.info("拒绝群通话,userId:{},groupId:{}", userSession.getUserId(), groupId);
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId")
@Override
public void failed(WebrtcGroupFailedDTO dto) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
// 校验
if (!isExist(webrtcSession, userSession.getUserId())) {
return;
}
if (isInchat(webrtcSession, userSession.getUserId())) {
return;
}
// 将用户从列表中移除
List<WebrtcUserInfo> userInfos =
webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId()))
.collect(Collectors.toList());
webrtcSession.setUserInfos(userInfos);
saveWebrtcSession(dto.getGroupId(), webrtcSession);
// 进入空闲状态
userStateUtils.setFree(userSession.getUserId());
// 广播信令
WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO();
vo.setUserIds(Arrays.asList(userSession.getUserId()));
vo.setReason(dto.getReason());
List<Long> recvIds = getRecvIds(userInfos);
sendRtcMessage1(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), recvIds, JSON.toJSONString(vo),false);
log.info("群通话失败,userId:{},groupId:{},原因:{}", userSession.getUserId(), dto.getReason());
}
@OnlineCheck
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId")
@Override
public void join(Long groupId) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(groupId);
if (webrtcSession.getUserInfos().size() >= webrtcConfig.getMaxChannel()) {
throw new GlobalException("人员已满,无法进入通话");
}
GroupMember member = groupMemberService.findByGroupAndUserId(groupId, userSession.getUserId());
if (Objects.isNull(member) || member.getQuit()) {
throw new GlobalException("您不在群里中");
}
IMUserInfo mine = findInChatUser(webrtcSession, userSession.getUserId());
if(!Objects.isNull(mine) && mine.getTerminal() != userSession.getTerminal()){
throw new GlobalException("已在其他设备加入通话");
}
WebrtcUserInfo userInfo = new WebrtcUserInfo();
userInfo.setId(userSession.getUserId());
userInfo.setNickName(member.getAliasName());
userInfo.setHeadImage(member.getHeadImage());
// 默认是开启麦克风,关闭摄像头
userInfo.setIsCamera(false);
userInfo.setIsMicroPhone(true);
// 将当前用户加入通话用户列表中
if (!isExist(webrtcSession, userSession.getUserId())) {
webrtcSession.getUserInfos().add(userInfo);
}
if (!isInchat(webrtcSession, userSession.getUserId())) {
webrtcSession.getInChatUsers().add(new IMUserInfo(userSession.getUserId(), userSession.getTerminal()));
}
saveWebrtcSession(groupId, webrtcSession);
// 进入忙线状态
userStateUtils.setBusy(userSession.getUserId());
// 广播信令
List<Long> recvIds = getRecvIds(webrtcSession.getUserInfos());
sendRtcMessage1(MessageType.RTC_GROUP_JOIN, groupId, recvIds, JSON.toJSONString(userInfo),false);
log.info("加入群通话,userId:{},groupId:{}", userSession.getUserId(), groupId);
}
@OnlineCheck
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId")
@Override
public void invite(WebrtcGroupInviteDTO dto) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
if (webrtcSession.getUserInfos().size() + dto.getUserInfos().size() > webrtcConfig.getMaxChannel()) {
throw new GlobalException("最多支持" + webrtcConfig.getMaxChannel() + "人进行通话");
}
if (!groupMemberService.isInGroup(dto.getGroupId(), getRecvIds(dto.getUserInfos()))) {
throw new GlobalException("部分用户不在群聊中");
}
// 过滤掉已经在通话中的用户
List<WebrtcUserInfo> userInfos = webrtcSession.getUserInfos();
// 原用户id
List<Long> userIds = getRecvIds(userInfos);
// 离线用户id
List<Long> offlineUserIds = new LinkedList<>();
// 忙线用户
List<Long> busyUserIds = new LinkedList<>();
// 新加入的用户
List<WebrtcUserInfo> newUserInfos = new LinkedList<>();
for (WebrtcUserInfo userInfo : dto.getUserInfos()) {
if (isExist(webrtcSession, userInfo.getId())) {
// 防止重复进入
continue;
}
if (!imClient.isOnline(userInfo.getId())) {
offlineUserIds.add(userInfo.getId());
// userStateUtils.setBusy(userInfo.getId());
// newUserInfos.add(userInfo);
} else if (userStateUtils.isBusy(userInfo.getId())) {
busyUserIds.add(userInfo.getId());
} else {
// 进入忙线状态
userStateUtils.setBusy(userInfo.getId());
newUserInfos.add(userInfo);
}
}
// 更新会话信息
userInfos.addAll(newUserInfos);
saveWebrtcSession(dto.getGroupId(), webrtcSession);
// 向发起邀请者推送邀请失败消息
if (!offlineUserIds.isEmpty()) {
WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO();
vo.setUserIds(offlineUserIds);
vo.setReason("用户当前不在线");
IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal());
sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo));
}
if (!busyUserIds.isEmpty()) {
WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO();
vo.setUserIds(busyUserIds);
vo.setReason("用户正在忙");
IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal());
sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo));
}
// 向被邀请的发起呼叫
List<Long> newUserIds = getRecvIds(newUserInfos);
sendRtcMessage1(MessageType.RTC_GROUP_SETUP, dto.getGroupId(), newUserIds, JSON.toJSONString(userInfos),false);
// 向已在通话中的用户同步新邀请的用户信息
sendRtcMessage1(MessageType.RTC_GROUP_INVITE, dto.getGroupId(), userIds, JSON.toJSONString(newUserInfos),false);
log.info("邀请加入群通话,userId:{},groupId:{},邀请用户:{}", userSession.getUserId(), dto.getGroupId(),
newUserIds);
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId")
@Override
public void cancel(Long groupId) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(groupId);
if (!userSession.getUserId().equals(webrtcSession.getHost().getId())) {
throw new GlobalException("只有发起人可以取消通话");
}
// 移除rtc session
String key = buildWebrtcSessionKey(groupId);
redisTemplate.delete(key);
// 进入空闲状态
webrtcSession.getUserInfos().forEach(user -> userStateUtils.setFree(user.getId()));
// 广播消息给的所有用户
List<Long> recvIds = getRecvIds(webrtcSession.getUserInfos());
sendRtcMessage1(MessageType.RTC_GROUP_CANCEL, groupId, recvIds, "",false);
// 发送文字提示信息
sendTipMessage(groupId,"通话结束");
log.info("发起人取消群通话,userId:{},groupId:{}", userSession.getUserId(), groupId);
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId")
@Override
public void quit(Long groupId) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(groupId);
// 将用户从列表中移除
List<IMUserInfo> inChatUsers =
webrtcSession.getInChatUsers().stream().filter(user -> !user.getId().equals(userSession.getUserId()))
.collect(Collectors.toList());
List<WebrtcUserInfo> userInfos =
webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId()))
.collect(Collectors.toList());
// 如果群聊中没有人已经接受了通话,则直接取消整个通话
if (inChatUsers.isEmpty() || userInfos.isEmpty()) {
// 移除rtc session
String key = buildWebrtcSessionKey(groupId);
redisTemplate.delete(key);
// 进入空闲状态
webrtcSession.getUserInfos().forEach(user -> userStateUtils.setFree(user.getId()));
// 广播给还在呼叫中的用户,取消通话
List<Long> recvIds = getRecvIds(webrtcSession.getUserInfos());
sendRtcMessage1(MessageType.RTC_GROUP_CANCEL, groupId, recvIds, "",false);
// 发送文字提示信息
sendTipMessage(groupId,"通话结束");
log.info("群通话结束,groupId:{}", groupId);
} else {
// 更新会话信息
webrtcSession.setInChatUsers(inChatUsers);
webrtcSession.setUserInfos(userInfos);
saveWebrtcSession(groupId, webrtcSession);
// 进入空闲状态
userStateUtils.setFree(userSession.getUserId());
// 广播信令
List<Long> recvIds = getRecvIds(userInfos);
sendRtcMessage1(MessageType.RTC_GROUP_QUIT, groupId, recvIds, "",false);
log.info("用户退出群通话,userId:{},groupId:{}", userSession.getUserId(), groupId);
}
}
@Override
public void offer(WebrtcGroupOfferDTO dto) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId());
if (Objects.isNull(userInfo)) {
log.info("对方未加入群通话,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(),
dto.getGroupId());
return;
}
// 推送offer给对方
sendRtcMessage2(MessageType.RTC_GROUP_OFFER, dto.getGroupId(), userInfo, dto.getOffer());
log.info("推送offer信息,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(),
dto.getGroupId());
}
@Override
public void answer(WebrtcGroupAnswerDTO dto) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId());
if (Objects.isNull(userInfo)) {
// 对方未加入群通话
log.info("对方未加入群通话,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(),
dto.getGroupId());
return;
}
// 推送answer信息给对方
sendRtcMessage2(MessageType.RTC_GROUP_ANSWER, dto.getGroupId(), userInfo, dto.getAnswer());
log.info("回复answer信息,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(),
dto.getGroupId());
}
@Override
public void candidate(WebrtcGroupCandidateDTO dto) {
UserSession userSession = SessionContext.getSession();
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId());
if (Objects.isNull(userInfo)) {
// 对方未加入群通话
log.info("对方未加入群通话,无法同步candidate,userId:{},remoteUserId:{},groupId:{}", userSession.getUserId(),
dto.getUserId(), dto.getGroupId());
return;
}
// 推送candidate信息给对方
sendRtcMessage2(MessageType.RTC_GROUP_CANDIDATE, dto.getGroupId(), userInfo, dto.getCandidate());
log.info("同步candidate信息,userId:{},groupId:{}", userSession.getUserId(), dto.getGroupId());
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId")
@Override
public void device(WebrtcGroupDeviceDTO dto) {
UserSession userSession = SessionContext.getSession();
// 查询会话信息
WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId());
WebrtcUserInfo userInfo = findUserInfo(webrtcSession, userSession.getUserId());
if (Objects.isNull(userInfo)) {
throw new GlobalException("您已不在通话中");
}
// 更新设备状态
userInfo.setIsCamera(dto.getIsCamera());
userInfo.setIsMicroPhone(dto.getIsMicroPhone());
saveWebrtcSession(dto.getGroupId(), webrtcSession);
// 广播信令
List<Long> recvIds = getRecvIds(webrtcSession.getUserInfos());
sendRtcMessage1(MessageType.RTC_GROUP_DEVICE, dto.getGroupId(), recvIds, JSON.toJSONString(dto),false);
log.info("设备操作,userId:{},groupId:{},摄像头:{}", userSession.getUserId(), dto.getGroupId(),
dto.getIsCamera());
}
@Override
public WebrtcGroupInfoVO info(Long groupId) {
WebrtcGroupInfoVO vo = new WebrtcGroupInfoVO();
String key = buildWebrtcSessionKey(groupId);
WebrtcGroupSession webrtcSession = (WebrtcGroupSession)redisTemplate.opsForValue().get(key);
if (Objects.isNull(webrtcSession)) {
// 群聊当前没有通话
vo.setIsChating(false);
} else {
// 群聊正在通话中
vo.setIsChating(true);
vo.setUserInfos(webrtcSession.getUserInfos());
Long hostId = webrtcSession.getHost().getId();
WebrtcUserInfo host = findUserInfo(webrtcSession, hostId);
if (Objects.isNull(host)) {
// 如果发起人已经退出了通话,则从数据库查询发起人数据
GroupMember member = groupMemberService.findByGroupAndUserId(groupId, hostId);
host = new WebrtcUserInfo();
host.setId(hostId);
host.setNickName(member.getAliasName());
host.setHeadImage(member.getHeadImage());
}
vo.setHost(host);
}
return vo;
}
@Override
public void heartbeat(Long groupId) {
UserSession userSession = SessionContext.getSession();
// 给通话session续命
String key = buildWebrtcSessionKey(groupId);
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
// 用户忙线状态续命
userStateUtils.expire(userSession.getUserId());
}
private WebrtcGroupSession getWebrtcSession(Long groupId) {
String key = buildWebrtcSessionKey(groupId);
WebrtcGroupSession webrtcSession = (WebrtcGroupSession)redisTemplate.opsForValue().get(key);
if (Objects.isNull(webrtcSession)) {
throw new GlobalException("通话已结束");
}
return webrtcSession;
}
private void saveWebrtcSession(Long groupId, WebrtcGroupSession webrtcSession) {
String key = buildWebrtcSessionKey(groupId);
redisTemplate.opsForValue().set(key, webrtcSession, 30, TimeUnit.SECONDS);
}
private String buildWebrtcSessionKey(Long groupId) {
return StrUtil.join(":", RedisKey.IM_WEBRTC_GROUP_SESSION, groupId);
}
private IMUserInfo findInChatUser(WebrtcGroupSession webrtcSession, Long userId) {
for (IMUserInfo userInfo : webrtcSession.getInChatUsers()) {
if (userInfo.getId().equals(userId)) {
return userInfo;
}
}
return null;
}
private WebrtcUserInfo findUserInfo(WebrtcGroupSession webrtcSession, Long userId) {
for (WebrtcUserInfo userInfo : webrtcSession.getUserInfos()) {
if (userInfo.getId().equals(userId)) {
return userInfo;
}
}
return null;
}
private List<Long> getRecvIds(List<WebrtcUserInfo> userInfos) {
UserSession userSession = SessionContext.getSession();
return userInfos.stream().map(WebrtcUserInfo::getId).filter(id -> !id.equals(userSession.getUserId()))
.collect(Collectors.toList());
}
private Boolean isInchat(WebrtcGroupSession webrtcSession, Long userId) {
return webrtcSession.getInChatUsers().stream().anyMatch(user -> user.getId().equals(userId));
}
private Boolean isExist(WebrtcGroupSession webrtcSession, Long userId) {
return webrtcSession.getUserInfos().stream().anyMatch(user -> user.getId().equals(userId));
}
private void sendRtcMessage1(MessageType messageType, Long groupId, List<Long> recvIds, String content,Boolean sendSelf) {
UserSession userSession = SessionContext.getSession();
GroupMessageVO messageInfo = new GroupMessageVO();
messageInfo.setType(messageType.code());
messageInfo.setGroupId(groupId);
messageInfo.setSendId(userSession.getUserId());
messageInfo.setContent(content);
IMGroupMessage<GroupMessageVO> sendMessage = new IMGroupMessage<>();
sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal()));
sendMessage.setRecvIds(recvIds);
sendMessage.setSendToSelf(sendSelf);
sendMessage.setSendResult(false);
sendMessage.setData(messageInfo);
imClient.sendGroupMessage(sendMessage);
}
private void sendRtcMessage2(MessageType messageType, Long groupId, IMUserInfo receiver, String content) {
UserSession userSession = SessionContext.getSession();
GroupMessageVO messageInfo = new GroupMessageVO();
messageInfo.setType(messageType.code());
messageInfo.setGroupId(groupId);
messageInfo.setSendId(userSession.getUserId());
messageInfo.setContent(content);
IMGroupMessage<GroupMessageVO> sendMessage = new IMGroupMessage<>();
sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal()));
sendMessage.setRecvIds(Arrays.asList(receiver.getId()));
sendMessage.setRecvTerminals(Arrays.asList(receiver.getTerminal()));
sendMessage.setSendToSelf(false);
sendMessage.setSendResult(false);
sendMessage.setData(messageInfo);
imClient.sendGroupMessage(sendMessage);
}
private void sendTipMessage(Long groupId,String content){
UserSession userSession = SessionContext.getSession();
// 群聊成员列表
List<Long> userIds = groupMemberService.findUserIdsByGroupId(groupId);
// 保存消息
GroupMessage msg = new GroupMessage();
msg.setGroupId(groupId);
msg.setContent(content);
msg.setSendId(userSession.getUserId());
msg.setSendTime(new Date());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setSendNickName(userSession.getNickName());
msg.setType(MessageType.TIP_TEXT.code());
groupMessageService.save(msg);
// 群发罅隙
GroupMessageVO msgInfo = BeanUtils.copyProperties(msg, GroupMessageVO.class);
IMGroupMessage<GroupMessageVO> sendMessage = new IMGroupMessage<>();
sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal()));
sendMessage.setRecvIds(userIds);
sendMessage.setSendResult(false);
sendMessage.setData(msgInfo);
imClient.sendGroupMessage(sendMessage);
};
}

145
im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcServiceImpl.java → im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java

@ -3,15 +3,19 @@ package com.bx.implatform.service.impl;
import com.bx.imclient.IMClient; import com.bx.imclient.IMClient;
import com.bx.imcommon.model.IMPrivateMessage; import com.bx.imcommon.model.IMPrivateMessage;
import com.bx.imcommon.model.IMUserInfo; import com.bx.imcommon.model.IMUserInfo;
import com.bx.implatform.config.ICEServer;
import com.bx.implatform.config.ICEServerConfig;
import com.bx.implatform.contant.RedisKey; import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.entity.PrivateMessage;
import com.bx.implatform.enums.MessageStatus;
import com.bx.implatform.enums.MessageType; import com.bx.implatform.enums.MessageType;
import com.bx.implatform.enums.WebrtcMode;
import com.bx.implatform.exception.GlobalException; import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.service.IWebrtcService; import com.bx.implatform.service.IPrivateMessageService;
import com.bx.implatform.service.IWebrtcPrivateService;
import com.bx.implatform.session.SessionContext; import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession; import com.bx.implatform.session.UserSession;
import com.bx.implatform.session.WebrtcSession; import com.bx.implatform.session.WebrtcPrivateSession;
import com.bx.implatform.util.BeanUtils;
import com.bx.implatform.util.UserStateUtils;
import com.bx.implatform.vo.PrivateMessageVO; import com.bx.implatform.vo.PrivateMessageVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -20,33 +24,47 @@ import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.Date;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class WebrtcServiceImpl implements IWebrtcService { public class WebrtcPrivateServiceImpl implements IWebrtcPrivateService {
private final IMClient imClient; private final IMClient imClient;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final ICEServerConfig iceServerConfig; private final IPrivateMessageService privateMessageService;
private final UserStateUtils userStateUtils;
@Override @Override
public void call(Long uid, String mode, String offer) { public void call(Long uid, String mode, String offer) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
if (!imClient.isOnline(uid)) {
throw new GlobalException("对方目前不在线");
}
// 创建webrtc会话 // 创建webrtc会话
WebrtcSession webrtcSession = new WebrtcSession(); WebrtcPrivateSession webrtcSession = new WebrtcPrivateSession();
webrtcSession.setCallerId(session.getUserId()); webrtcSession.setCallerId(session.getUserId());
webrtcSession.setCallerTerminal(session.getTerminal()); webrtcSession.setCallerTerminal(session.getTerminal());
String key = getSessionKey(session.getUserId(), uid); webrtcSession.setAcceptorId(uid);
redisTemplate.opsForValue().set(key, webrtcSession, 12, TimeUnit.HOURS); webrtcSession.setMode(mode);
// 校验
if (!imClient.isOnline(uid)) {
this.sendActMessage(webrtcSession,MessageStatus.UNSEND,"未接通");
throw new GlobalException("对方目前不在线");
}
if (userStateUtils.isBusy(uid)) {
this.sendActMessage(webrtcSession,MessageStatus.UNSEND,"未接通");
throw new GlobalException("对方正忙");
}
// 保存rtc session
String key = getWebRtcSessionKey(session.getUserId(), uid);
redisTemplate.opsForValue().set(key, webrtcSession, 60, TimeUnit.SECONDS);
// 设置用户忙线状态
userStateUtils.setBusy(uid);
userStateUtils.setBusy(session.getUserId());
// 向对方所有终端发起呼叫 // 向对方所有终端发起呼叫
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
MessageType messageType = mode.equals("video") ? MessageType.RTC_CALL_VIDEO : MessageType.RTC_CALL_VOICE; MessageType messageType =
mode.equals(WebrtcMode.VIDEO.getValue()) ? MessageType.RTC_CALL_VIDEO : MessageType.RTC_CALL_VOICE;
messageInfo.setType(messageType.code()); messageInfo.setType(messageType.code());
messageInfo.setRecvId(uid); messageInfo.setRecvId(uid);
messageInfo.setSendId(session.getUserId()); messageInfo.setSendId(session.getUserId());
@ -66,12 +84,13 @@ public class WebrtcServiceImpl implements IWebrtcService {
public void accept(Long uid, @RequestBody String answer) { public void accept(Long uid, @RequestBody String answer) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话 // 查询webrtc会话
WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 更新接受者信息 // 更新接受者信息
webrtcSession.setAcceptorId(session.getUserId()); webrtcSession.setAcceptorId(session.getUserId());
webrtcSession.setAcceptorTerminal(session.getTerminal()); webrtcSession.setAcceptorTerminal(session.getTerminal());
String key = getSessionKey(session.getUserId(), uid); webrtcSession.setChatTimeStamp(System.currentTimeMillis());
redisTemplate.opsForValue().set(key, webrtcSession, 12, TimeUnit.HOURS); String key = getWebRtcSessionKey(session.getUserId(), uid);
redisTemplate.opsForValue().set(key, webrtcSession, 60, TimeUnit.SECONDS);
// 向发起人推送接受通话信令 // 向发起人推送接受通话信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_ACCEPT.code()); messageInfo.setType(MessageType.RTC_ACCEPT.code());
@ -94,9 +113,12 @@ public class WebrtcServiceImpl implements IWebrtcService {
public void reject(Long uid) { public void reject(Long uid) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话 // 查询webrtc会话
WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 删除会话信息 // 删除会话信息
removeWebrtcSession(uid, session.getUserId()); removeWebrtcSession(uid, session.getUserId());
// 设置用户空闲状态
userStateUtils.setFree(uid);
userStateUtils.setFree(session.getUserId());
// 向发起人推送拒绝通话信令 // 向发起人推送拒绝通话信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_REJECT.code()); messageInfo.setType(MessageType.RTC_REJECT.code());
@ -112,13 +134,20 @@ public class WebrtcServiceImpl implements IWebrtcService {
sendMessage.setRecvTerminals(Collections.singletonList(webrtcSession.getCallerTerminal())); sendMessage.setRecvTerminals(Collections.singletonList(webrtcSession.getCallerTerminal()));
sendMessage.setData(messageInfo); sendMessage.setData(messageInfo);
imClient.sendPrivateMessage(sendMessage); imClient.sendPrivateMessage(sendMessage);
// 生成通话消息
sendActMessage(webrtcSession, MessageStatus.READED,"已拒绝");
} }
@Override @Override
public void cancel(Long uid) { public void cancel(Long uid) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话
WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 删除会话信息 // 删除会话信息
removeWebrtcSession(session.getUserId(), uid); removeWebrtcSession(session.getUserId(), uid);
// 设置用户空闲状态
userStateUtils.setFree(uid);
userStateUtils.setFree(session.getUserId());
// 向对方所有终端推送取消通话信令 // 向对方所有终端推送取消通话信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_CANCEL.code()); messageInfo.setType(MessageType.RTC_CANCEL.code());
@ -133,15 +162,20 @@ public class WebrtcServiceImpl implements IWebrtcService {
sendMessage.setData(messageInfo); sendMessage.setData(messageInfo);
// 通知对方取消会话 // 通知对方取消会话
imClient.sendPrivateMessage(sendMessage); imClient.sendPrivateMessage(sendMessage);
// 生成通话消息
sendActMessage(webrtcSession, MessageStatus.UNSEND,"已取消");
} }
@Override @Override
public void failed(Long uid, String reason) { public void failed(Long uid, String reason) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话 // 查询webrtc会话
WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 删除会话信息 // 删除会话信息
removeWebrtcSession(uid, session.getUserId()); removeWebrtcSession(uid, session.getUserId());
// 设置用户空闲状态
userStateUtils.setFree(uid);
userStateUtils.setFree(session.getUserId());
// 向发起方推送通话失败信令 // 向发起方推送通话失败信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_FAILED.code()); messageInfo.setType(MessageType.RTC_FAILED.code());
@ -158,16 +192,20 @@ public class WebrtcServiceImpl implements IWebrtcService {
sendMessage.setData(messageInfo); sendMessage.setData(messageInfo);
// 通知对方取消会话 // 通知对方取消会话
imClient.sendPrivateMessage(sendMessage); imClient.sendPrivateMessage(sendMessage);
// 生成消息
sendActMessage(webrtcSession, MessageStatus.READED,"未接通");
} }
@Override @Override
public void handup(Long uid) { public void handup(Long uid) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话 // 查询webrtc会话
WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 删除会话信息 // 删除会话信息
removeWebrtcSession(uid, session.getUserId()); removeWebrtcSession(uid, session.getUserId());
// 设置用户空闲状态
userStateUtils.setFree(uid);
userStateUtils.setFree(session.getUserId());
// 向对方推送挂断通话信令 // 向对方推送挂断通话信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_HANDUP.code()); messageInfo.setType(MessageType.RTC_HANDUP.code());
@ -184,13 +222,15 @@ public class WebrtcServiceImpl implements IWebrtcService {
sendMessage.setData(messageInfo); sendMessage.setData(messageInfo);
// 通知对方取消会话 // 通知对方取消会话
imClient.sendPrivateMessage(sendMessage); imClient.sendPrivateMessage(sendMessage);
// 生成通话消息
sendActMessage(webrtcSession, MessageStatus.READED,"通话时长 " + chatTimeText(webrtcSession));
} }
@Override @Override
public void candidate(Long uid, String candidate) { public void candidate(Long uid, String candidate) {
UserSession session = SessionContext.getSession(); UserSession session = SessionContext.getSession();
// 查询webrtc会话 // 查询webrtc会话
WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid);
// 向发起方推送同步candidate信令 // 向发起方推送同步candidate信令
PrivateMessageVO messageInfo = new PrivateMessageVO(); PrivateMessageVO messageInfo = new PrivateMessageVO();
messageInfo.setType(MessageType.RTC_CANDIDATE.code()); messageInfo.setType(MessageType.RTC_CANDIDATE.code());
@ -210,13 +250,18 @@ public class WebrtcServiceImpl implements IWebrtcService {
} }
@Override @Override
public List<ICEServer> getIceServers() { public void heartbeat(Long uid) {
return iceServerConfig.getIceServers(); UserSession session = SessionContext.getSession();
// 会话续命
String key = getWebRtcSessionKey(session.getUserId(), uid);
redisTemplate.expire(key, 60, TimeUnit.SECONDS);
// 用户状态续命
userStateUtils.expire(session.getUserId());
} }
private WebrtcSession getWebrtcSession(Long userId, Long uid) { private WebrtcPrivateSession getWebrtcSession(Long userId, Long uid) {
String key = getSessionKey(userId, uid); String key = getWebRtcSessionKey(userId, uid);
WebrtcSession webrtcSession = (WebrtcSession)redisTemplate.opsForValue().get(key); WebrtcPrivateSession webrtcSession = (WebrtcPrivateSession)redisTemplate.opsForValue().get(key);
if (webrtcSession == null) { if (webrtcSession == null) {
throw new GlobalException("通话已结束"); throw new GlobalException("通话已结束");
} }
@ -224,21 +269,59 @@ public class WebrtcServiceImpl implements IWebrtcService {
} }
private void removeWebrtcSession(Long userId, Long uid) { private void removeWebrtcSession(Long userId, Long uid) {
String key = getSessionKey(userId, uid); String key = getWebRtcSessionKey(userId, uid);
redisTemplate.delete(key); redisTemplate.delete(key);
} }
private String getSessionKey(Long id1, Long id2) { private String getWebRtcSessionKey(Long id1, Long id2) {
Long minId = id1 > id2 ? id2 : id1; Long minId = id1 > id2 ? id2 : id1;
Long maxId = id1 > id2 ? id1 : id2; Long maxId = id1 > id2 ? id1 : id2;
return String.join(":", RedisKey.IM_WEBRTC_SESSION, minId.toString(), maxId.toString()); return String.join(":", RedisKey.IM_WEBRTC_PRIVATE_SESSION, minId.toString(), maxId.toString());
} }
private Integer getTerminalType(Long uid, WebrtcSession webrtcSession) { private Integer getTerminalType(Long uid, WebrtcPrivateSession webrtcSession) {
if (uid.equals(webrtcSession.getCallerId())) { if (uid.equals(webrtcSession.getCallerId())) {
return webrtcSession.getCallerTerminal(); return webrtcSession.getCallerTerminal();
} }
return webrtcSession.getAcceptorTerminal(); return webrtcSession.getAcceptorTerminal();
} }
private void sendActMessage(WebrtcPrivateSession rtcSession, MessageStatus status,String content) {
// 保存消息
PrivateMessage msg = new PrivateMessage();
msg.setSendId(rtcSession.getCallerId());
msg.setRecvId(rtcSession.getAcceptorId());
msg.setContent(content);
msg.setSendTime(new Date());
msg.setStatus(status.code());
MessageType type = rtcSession.getMode().equals(WebrtcMode.VIDEO.getValue()) ? MessageType.ACT_RT_VIDEO
: MessageType.ACT_RT_VOICE;
msg.setType(type.code());
privateMessageService.save(msg);
// 推给发起人
PrivateMessageVO messageInfo = BeanUtils.copyProperties(msg, PrivateMessageVO.class);
IMPrivateMessage<PrivateMessageVO> sendMessage = new IMPrivateMessage<>();
sendMessage.setSender(new IMUserInfo(rtcSession.getCallerId(), rtcSession.getCallerTerminal()));
sendMessage.setRecvId(rtcSession.getCallerId());
sendMessage.setSendToSelf(false);
sendMessage.setSendResult(false);
sendMessage.setData(messageInfo);
imClient.sendPrivateMessage(sendMessage);
// 推给接听方
sendMessage.setRecvId(rtcSession.getAcceptorId());
imClient.sendPrivateMessage(sendMessage);
}
private String chatTimeText(WebrtcPrivateSession rtcSession) {
long chatTime = (System.currentTimeMillis() - rtcSession.getChatTimeStamp())/1000;
int min = Math.abs((int)chatTime / 60);
int sec = Math.abs((int)chatTime % 60);
String strTime = min < 10 ? "0" : "";
strTime += min;
strTime += ":";
strTime += sec < 10 ? "0" : "";
strTime += sec;
return strTime;
}
} }

33
im-platform/src/main/java/com/bx/implatform/session/WebrtcGroupSession.java

@ -0,0 +1,33 @@
package com.bx.implatform.session;
import com.bx.imcommon.model.IMUserInfo;
import lombok.Data;
import java.util.LinkedList;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-01
* @version: 1.0
*/
@Data
public class WebrtcGroupSession {
/**
* 通话发起者
*/
private IMUserInfo host;
/**
* 所有被邀请的用户列表
*/
private List<WebrtcUserInfo> userInfos;
/**
* 已经进入通话的用户列表
*/
private List<IMUserInfo> inChatUsers = new LinkedList<>();
}

11
im-platform/src/main/java/com/bx/implatform/session/WebrtcSession.java → im-platform/src/main/java/com/bx/implatform/session/WebrtcPrivateSession.java

@ -8,7 +8,7 @@ import lombok.Data;
* @Date 2022/10/21 * @Date 2022/10/21
*/ */
@Data @Data
public class WebrtcSession { public class WebrtcPrivateSession {
/** /**
* 发起者id * 发起者id
*/ */
@ -27,4 +27,13 @@ public class WebrtcSession {
* 接受者终端类型 * 接受者终端类型
*/ */
private Integer acceptorTerminal; private Integer acceptorTerminal;
/**
* 通话模式
*/
private String mode;
/**
* 开始聊天时间戳
*/
private Long chatTimeStamp;
} }

29
im-platform/src/main/java/com/bx/implatform/session/WebrtcUserInfo.java

@ -0,0 +1,29 @@
package com.bx.implatform.session;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* @author: Blue
* @date: 2024-06-02
* @version: 1.0
*/
@Data
@ApiModel("用户信息")
public class WebrtcUserInfo {
@ApiModelProperty(value = "用户id")
private Long id;
@ApiModelProperty(value = "用户昵称")
private String nickName;
@ApiModelProperty(value = "用户头像")
private String headImage;
@ApiModelProperty(value = "是否开启摄像头")
private Boolean isCamera;
@ApiModelProperty(value = "是否开启麦克风")
private Boolean isMicroPhone;
}

44
im-platform/src/main/java/com/bx/implatform/util/UserStateUtils.java

@ -0,0 +1,44 @@
package com.bx.implatform.util;
import cn.hutool.core.util.StrUtil;
import com.bx.implatform.contant.RedisKey;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* @author: Blue
* @date: 2024-06-10
* @version: 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserStateUtils {
private final RedisTemplate<String, Object> redisTemplate;
public void setBusy(Long userId){
String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId);
redisTemplate.opsForValue().set(key,1,30, TimeUnit.SECONDS);
}
public void expire(Long userId){
String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId);
redisTemplate.expire(key,30, TimeUnit.SECONDS);
}
public void setFree(Long userId){
String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId);
redisTemplate.delete(key);
}
public Boolean isBusy(Long userId){
String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId);
return redisTemplate.hasKey(key);
}
}

2
im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java

@ -7,7 +7,7 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* @author: 谢绍许 * @author: Blue
* @date: 2023-10-28 21:17:59 * @date: 2023-10-28 21:17:59
* @version: 1.0 * @version: 1.0
*/ */

22
im-platform/src/main/java/com/bx/implatform/vo/SystemConfigVO.java

@ -0,0 +1,22 @@
package com.bx.implatform.vo;
import com.bx.implatform.config.WebrtcConfig;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* @author: blue
* @date: 2024-06-10
* @version: 1.0
*/
@Data
@ApiModel("系统配置VO")
@AllArgsConstructor
public class SystemConfigVO {
@ApiModelProperty(value = "webrtc配置")
private WebrtcConfig webrtc;
}

23
im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupFailedVO.java

@ -0,0 +1,23 @@
package com.bx.implatform.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-09
* @version: 1.0
*/
@Data
@ApiModel("用户加入群通话失败VO")
public class WebrtcGroupFailedVO {
@ApiModelProperty(value = "失败用户列表")
private List<Long> userIds;
@ApiModelProperty(value = "失败原因")
private String reason;
}

28
im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupInfoVO.java

@ -0,0 +1,28 @@
package com.bx.implatform.vo;
import com.bx.implatform.session.WebrtcUserInfo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
* @author: Blue
* @date: 2024-06-09
* @version: 1.0
*/
@Data
@ApiModel("群通话信息VO")
public class WebrtcGroupInfoVO {
@ApiModelProperty(value = "是否在通话中")
private Boolean isChating;
@ApiModelProperty(value = "通话发起人")
WebrtcUserInfo host;
@ApiModelProperty(value = "通话用户列表")
private List<WebrtcUserInfo> userInfos;
}

1
im-platform/src/main/resources/application.yml

@ -39,6 +39,7 @@ minio:
videoPath: video videoPath: video
webrtc: webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16
iceServers: iceServers:
- urls: stun:stun.l.google.com:19302 - urls: stun:stun.l.google.com:19302

27
im-ui/src/App.vue

@ -85,4 +85,31 @@
.el-button { .el-button {
padding: 8px 15px !important; padding: 8px 15px !important;
} }
.el-checkbox {
display: flex;
align-items: center;
//
.el-checkbox__inner {
width: 20px;
height: 20px;
//
&::after {
height: 12px;
left: 7px;
}
}
//
.el-checkbox__input.is-checked+.el-checkbox__label {
color: #333333;
}
.el-checkbox__label {
line-height: 20px;
padding-left: 8px;
}
}
</style> </style>

76
im-ui/src/api/camera.js

@ -0,0 +1,76 @@
class ImCamera {
constructor() {
this.stream = null;
}
}
ImCamera.prototype.isEnable = function() {
return !!navigator && !!navigator.mediaDevices && !!navigator.mediaDevices.getUserMedia;
}
ImCamera.prototype.openVideo = function() {
return new Promise((resolve, reject) => {
if(this.stream){
this.close()
}
let constraints = {
video: {
with: window.screen.width,
height: window.screen.height
},
audio: {
echoCancellation: true, //音频开启回音消除
noiseSuppression: true // 开启降噪
}
}
console.log("getUserMedia")
navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
console.log("摄像头打开")
this.stream = stream;
resolve(stream);
}).catch((e) => {
console.log(e)
console.log("摄像头未能正常打开")
reject({
code: 0,
message: "摄像头未能正常打开"
})
})
})
}
ImCamera.prototype.openAudio = function() {
return new Promise((resolve, reject) => {
let constraints = {
video: false,
audio: {
echoCancellation: true, //音频开启回音消除
noiseSuppression: true // 开启降噪
}
}
navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
this.stream = stream;
resolve(stream);
}).catch(() => {
console.log("麦克风未能正常打开")
reject({
code: 0,
message: "麦克风未能正常打开"
})
})
})
}
ImCamera.prototype.close = function() {
// 停止流
if (this.stream) {
this.stream.getTracks().forEach((track) => {
track.stop();
});
}
}
export default ImCamera;

47
im-ui/src/api/enums.js

@ -1,18 +1,17 @@
const MESSAGE_TYPE = { const MESSAGE_TYPE = {
TEXT: 0, TEXT: 0,
IMAGE:1, IMAGE: 1,
FILE:2, FILE: 2,
AUDIO:3, AUDIO: 3,
VIDEO:4, VIDEO: 4,
RT_VOICE:5, RECALL: 10,
RT_VIDEO:6, READED: 11,
RECALL:10, RECEIPT: 12,
READED:11, TIP_TIME: 20,
RECEIPT:12, TIP_TEXT: 21,
TIP_TIME:20, LOADING: 30,
TIP_TEXT:21, ACT_RT_VOICE: 40,
LOADDING:30, ACT_RT_VIDEO: 41,
RTC_CALL_VOICE: 100, RTC_CALL_VOICE: 100,
RTC_CALL_VIDEO: 101, RTC_CALL_VIDEO: 101,
RTC_ACCEPT: 102, RTC_ACCEPT: 102,
@ -20,7 +19,19 @@ const MESSAGE_TYPE = {
RTC_CANCEL: 104, RTC_CANCEL: 104,
RTC_FAILED: 105, RTC_FAILED: 105,
RTC_HANDUP: 106, RTC_HANDUP: 106,
RTC_CANDIDATE: 107 RTC_CANDIDATE: 107,
RTC_GROUP_SETUP: 200,
RTC_GROUP_ACCEPT: 201,
RTC_GROUP_REJECT: 202,
RTC_GROUP_FAILED: 203,
RTC_GROUP_CANCEL: 204,
RTC_GROUP_QUIT: 205,
RTC_GROUP_INVITE: 206,
RTC_GROUP_JOIN: 207,
RTC_GROUP_OFFER: 208,
RTC_GROUP_ANSWER: 209,
RTC_GROUP_CANDIDATE: 210,
RTC_GROUP_DEVICE: 211
} }
const RTC_STATE = { const RTC_STATE = {
@ -28,7 +39,7 @@ const RTC_STATE = {
WAIT_CALL: 1, // 呼叫后等待 WAIT_CALL: 1, // 呼叫后等待
WAIT_ACCEPT: 2, // 被呼叫后等待 WAIT_ACCEPT: 2, // 被呼叫后等待
ACCEPTED: 3, // 已接受聊天,等待建立连接 ACCEPTED: 3, // 已接受聊天,等待建立连接
CHATING:4 // 聊天中 CHATING: 4 // 聊天中
} }
const TERMINAL_TYPE = { const TERMINAL_TYPE = {
@ -39,8 +50,8 @@ const TERMINAL_TYPE = {
const MESSAGE_STATUS = { const MESSAGE_STATUS = {
UNSEND: 0, UNSEND: 0,
SENDED: 1, SENDED: 1,
RECALL:2, RECALL: 2,
READED:3 READED: 3
} }
@ -49,4 +60,4 @@ export {
RTC_STATE, RTC_STATE,
TERMINAL_TYPE, TERMINAL_TYPE,
MESSAGE_STATUS MESSAGE_STATUS
} }

3
im-ui/src/api/eventBus.js

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

40
im-ui/src/api/messageType.js

@ -0,0 +1,40 @@
// 是否普通消息
let isNormal = function(type){
return type>=0 && type < 10;
}
// 是否状态消息
let isStatus = function(type){
return type>=10 && type < 20;
}
// 是否提示消息
let isTip = function(type){
return type>=20 && type < 30;
}
// 操作交互类消息
let isAction = function(type){
return type>=40 && type < 50;
}
// 单人通话信令
let isRtcPrivate = function(type){
return type>=100 && type < 300;
}
// 多人通话信令
let isRtcGroup = function(type){
return type>=200 && type < 400;
}
export {
isNormal,
isStatus,
isTip,
isAction,
isRtcPrivate,
isRtcGroup
}

151
im-ui/src/api/rtcGroupApi.js

@ -0,0 +1,151 @@
import http from './httpRequest.js'
class RtcGroupApi {}
RtcGroupApi.prototype.setup = function(groupId, userInfos) {
let formData = {
groupId,
userInfos
}
return http({
url: '/webrtc/group/setup',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.accept = function(groupId) {
return http({
url: '/webrtc/group/accept?groupId='+groupId,
method: 'post'
})
}
RtcGroupApi.prototype.reject = function(groupId) {
return http({
url: '/webrtc/group/reject?groupId='+groupId,
method: 'post'
})
}
RtcGroupApi.prototype.failed = function(groupId,reason) {
let formData = {
groupId,
reason
}
return http({
url: '/webrtc/group/failed',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.join = function(groupId) {
return http({
url: '/webrtc/group/join?groupId='+groupId,
method: 'post'
})
}
RtcGroupApi.prototype.invite = function(groupId, userInfos) {
let formData = {
groupId,
userInfos
}
return http({
url: '/webrtc/group/invite',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.offer = function(groupId, userId, offer) {
let formData = {
groupId,
userId,
offer
}
return http({
url: '/webrtc/group/offer',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.answer = function(groupId, userId, answer) {
let formData = {
groupId,
userId,
answer
}
return http({
url: '/webrtc/group/answer',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.quit = function(groupId) {
return http({
url: '/webrtc/group/quit?groupId=' + groupId,
method: 'post'
})
}
RtcGroupApi.prototype.cancel = function(groupId) {
return http({
url: '/webrtc/group/cancel?groupId=' + groupId,
method: 'post'
})
}
RtcGroupApi.prototype.candidate = function(groupId, userId, candidate) {
let formData = {
groupId,
userId,
candidate
}
return http({
url: '/webrtc/group/candidate',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.device = function(groupId, isCamera, isMicroPhone) {
let formData = {
groupId,
isCamera,
isMicroPhone
}
return http({
url: '/webrtc/group/device',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.candidate = function(groupId, userId, candidate) {
let formData = {
groupId,
userId,
candidate
}
return http({
url: '/webrtc/group/candidate',
method: 'post',
data: formData
})
}
RtcGroupApi.prototype.heartbeat = function(groupId) {
return http({
url: '/webrtc/group/heartbeat?groupId=' + groupId,
method: 'post'
})
}
export default RtcGroupApi;

66
im-ui/src/api/rtcPrivateApi.js

@ -0,0 +1,66 @@
import http from './httpRequest.js'
class RtcPrivateApi {
}
RtcPrivateApi.prototype.call = function(uid, mode, offer) {
return http({
url: `/webrtc/private/call?uid=${uid}&mode=${mode}`,
method: 'post',
data: JSON.stringify(offer)
})
}
RtcPrivateApi.prototype.accept = function(uid, answer) {
return http({
url: `/webrtc/private/accept?uid=${uid}`,
method: 'post',
data: JSON.stringify(answer)
})
}
RtcPrivateApi.prototype.handup = function(uid) {
return http({
url: `/webrtc/private/handup?uid=${uid}`,
method: 'post'
})
}
RtcPrivateApi.prototype.cancel = function(uid) {
return http({
url: `/webrtc/private/cancel?uid=${uid}`,
method: 'post'
})
}
RtcPrivateApi.prototype.reject = function(uid) {
return http({
url: `/webrtc/private/reject?uid=${uid}`,
method: 'post'
})
}
RtcPrivateApi.prototype.failed = function(uid, reason) {
return http({
url: `/webrtc/private/failed?uid=${uid}&reason=${reason}`,
method: 'post'
})
}
RtcPrivateApi.prototype.sendCandidate = function(uid, candidate) {
return http({
url: `/webrtc/private/candidate?uid=${uid}`,
method: 'post',
data: JSON.stringify(candidate)
});
}
RtcPrivateApi.prototype.heartbeat = function(uid) {
return http({
url: `/webrtc/private/heartbeat?uid=${uid}`,
method: 'post'
})
}
export default RtcPrivateApi;

120
im-ui/src/api/webrtc.js

@ -0,0 +1,120 @@
class ImWebRtc {
constructor() {
this.configuration = {}
this.stream = null;
}
}
ImWebRtc.prototype.isEnable = function() {
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window
.mozRTCPeerConnection;
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window
.mozRTCSessionDescription;
window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window
.mozRTCIceCandidate;
return !!window.RTCPeerConnection;
}
ImWebRtc.prototype.init = function(configuration) {
this.configuration = configuration;
}
ImWebRtc.prototype.setupPeerConnection = function(callback) {
this.peerConnection = new RTCPeerConnection(this.configuration);
this.peerConnection.ontrack = (e) => {
// 对方的视频流
callback(e.streams[0]);
};
}
ImWebRtc.prototype.setStream = function(stream) {
if(this.stream){
this.peerConnection.removeStream(this.stream)
}
if(stream){
stream.getTracks().forEach((track) => {
this.peerConnection.addTrack(track, stream);
});
}
this.stream = stream;
}
ImWebRtc.prototype.onIcecandidate = function(callback) {
this.peerConnection.onicecandidate = (event) => {
// 追踪到候选信息
if (event.candidate) {
callback(event.candidate)
}
}
}
ImWebRtc.prototype.onStateChange = function(callback) {
// 监听连接状态
this.peerConnection.oniceconnectionstatechange = (event) => {
let state = event.target.iceConnectionState;
console.log("ICE连接状态变化: : " + state)
callback(state)
};
}
ImWebRtc.prototype.createOffer = function() {
return new Promise((resolve, reject) => {
const offerParam = {};
offerParam.offerToRecieveAudio = 1;
offerParam.offerToRecieveVideo = 1;
// 创建本地sdp信息
this.peerConnection.createOffer(offerParam).then((offer) => {
// 设置本地sdp信息
this.peerConnection.setLocalDescription(offer);
// 发起呼叫请求
resolve(offer)
}).catch((e) => {
reject(e)
})
});
}
ImWebRtc.prototype.createAnswer = function(offer) {
return new Promise((resolve, reject) => {
// 设置远端的sdp
this.setRemoteDescription(offer);
// 创建本地dsp
const offerParam = {};
offerParam.offerToRecieveAudio = 1;
offerParam.offerToRecieveVideo = 1;
this.peerConnection.createAnswer(offerParam).then((answer) => {
// 设置本地sdp信息
this.peerConnection.setLocalDescription(answer);
// 接受呼叫请求
resolve(answer)
}).catch((e) => {
reject(e)
})
});
}
ImWebRtc.prototype.setRemoteDescription = function(offer) {
// 设置对方的sdp信息
this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
}
ImWebRtc.prototype.addIceCandidate = function(candidate) {
// 添加对方的候选人信息
this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
ImWebRtc.prototype.close = function(uid) {
// 关闭RTC连接
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection.onicecandidate = null;
this.peerConnection.onaddstream = null;
this.peerConnection = null;
}
}
export default ImWebRtc;

34
im-ui/src/assets/iconfont/iconfont.css

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 3791506 */ font-family: "iconfont"; /* Project id 3791506 */
src: url('iconfont.ttf?t=1714220334746') format('truetype'); src: url('iconfont.ttf?t=1718373714629') format('truetype');
} }
.iconfont { .iconfont {
@ -11,6 +11,38 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-invite-rtc:before {
content: "\e65f";
}
.icon-quit:before {
content: "\e606";
}
.icon-camera-off:before {
content: "\e6b5";
}
.icon-speaker-off:before {
content: "\ea3c";
}
.icon-microphone-on:before {
content: "\e63b";
}
.icon-speaker-on:before {
content: "\e6a4";
}
.icon-camera-on:before {
content: "\e627";
}
.icon-microphone-off:before {
content: "\efe5";
}
.icon-chat:before { .icon-chat:before {
content: "\e600"; content: "\e600";
} }

BIN
im-ui/src/assets/iconfont/iconfont.ttf

Binary file not shown.

1627
im-ui/src/components/chat/ChatBox.vue

File diff suppressed because it is too large

36
im-ui/src/components/chat/ChatItem.vue

@ -12,7 +12,7 @@
</div> </div>
<div class="chat-content"> <div class="chat-content">
<div class="chat-at-text">{{atText}}</div> <div class="chat-at-text">{{atText}}</div>
<div class="chat-send-name" v-show="chat.sendNickName">{{chat.sendNickName+':&nbsp;'}}</div> <div class="chat-send-name" v-show="isShowSendName">{{chat.sendNickName+':&nbsp;'}}</div>
<div class="chat-content-text" v-html="$emo.transform(chat.lastContent)"></div> <div class="chat-content-text" v-html="$emo.transform(chat.lastContent)"></div>
</div> </div>
</div> </div>
@ -76,6 +76,18 @@
} }
}, },
computed: { computed: {
isShowSendName() {
if (!this.chat.sendNickName) {
return false;
}
let size = this.chat.messages.length;
if (size == 0) {
return false;
}
//
let lastMsg = this.chat.messages[size - 1];
return this.$msgType.isNormal(lastMsg.type)
},
showTime() { showTime() {
return this.$date.toTimeText(this.chat.lastSendTime, true) return this.$date.toTimeText(this.chat.lastSendTime, true)
}, },
@ -108,11 +120,11 @@
&:hover { &:hover {
background-color: #F8FAFF; background-color: #F8FAFF;
} }
&.active { &.active {
background-color: #F4F9FF; background-color: #F4F9FF;
} }
.chat-left { .chat-left {
position: relative; position: relative;
display: flex; display: flex;
@ -147,6 +159,7 @@
display: flex; display: flex;
line-height: 25px; line-height: 25px;
height: 25px; height: 25px;
.chat-name-text { .chat-name-text {
flex: 1; flex: 1;
font-size: 15px; font-size: 15px;
@ -154,9 +167,9 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.chat-time-text{ .chat-time-text {
font-size: 13px; font-size: 13px;
text-align: right; text-align: right;
color: #888888; color: #888888;
@ -169,23 +182,24 @@
.chat-content { .chat-content {
display: flex; display: flex;
line-height: 22px; line-height: 22px;
.chat-at-text { .chat-at-text {
color: #c70b0b; color: #c70b0b;
font-size: 12px; font-size: 12px;
} }
.chat-send-name{ .chat-send-name {
font-size: 13px; font-size: 13px;
} }
.chat-content-text { .chat-content-text {
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 13px; font-size: 13px;
img { img {
width: 20px !important; width: 20px !important;
height: 20px !important; height: 20px !important;

34
im-ui/src/components/chat/ChatMessageItem.vue

@ -1,14 +1,13 @@
<template> <template>
<div class="chat-msg-item"> <div class="chat-msg-item">
<div class="chat-msg-tip" <div class="chat-msg-tip"
v-show="msgInfo.type == $enums.MESSAGE_TYPE.RECALL || msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT"> v-if="msgInfo.type == $enums.MESSAGE_TYPE.RECALL || msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">
{{ msgInfo.content }} {{ msgInfo.content }}
</div> </div>
<div class="chat-msg-tip" v-show="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TIME"> <div class="chat-msg-tip" v-if="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TIME">
{{ $date.toTimeText(msgInfo.sendTime) }} {{ $date.toTimeText(msgInfo.sendTime) }}
</div> </div>
<div class="chat-msg-normal" v-if="isNormal" :class="{ 'chat-msg-mine': mine }">
<div class="chat-msg-normal" v-show="msgInfo.type >= 0 && msgInfo.type < 10" :class="{ 'chat-msg-mine': mine }">
<div class="head-image"> <div class="head-image">
<head-image :name="showName" :size="40" :url="headImage" :id="msgInfo.sendId"></head-image> <head-image :name="showName" :size="40" :url="headImage" :id="msgInfo.sendId"></head-image>
</div> </div>
@ -28,7 +27,7 @@
<div class="img-load-box" v-loading="loading" element-loading-text="上传中.." <div class="img-load-box" v-loading="loading" element-loading-text="上传中.."
element-loading-background="rgba(0, 0, 0, 0.4)"> element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl" <img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl"
@click="showFullImageBox()" loading="lazy"/> @click="showFullImageBox()" loading="lazy" />
</div> </div>
<span title="发送失败" v-show="loadFail" @click="onSendFail" <span title="发送失败" v-show="loadFail" @click="onSendFail"
class="send-fail el-icon-warning"></span> class="send-fail el-icon-warning"></span>
@ -51,14 +50,14 @@
<div class="chat-msg-voice" v-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" @click="onPlayVoice()"> <div class="chat-msg-voice" v-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" @click="onPlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio> <audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</div> </div>
<div class="chat-realtime chat-msg-text" v-if="isRealtime"> <div class="chat-action chat-msg-text" v-if="isAction">
<span v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE" title="重新呼叫" <span v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VOICE" title="重新呼叫" @click="$emit('call')"
@click="$emit('call')" class="iconfont icon-chat-voice"></span> class="iconfont icon-chat-voice"></span>
<span v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO" title="重新呼叫" <span v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VIDEO" title="重新呼叫" @click="$emit('call')"
@click="$emit('call')" class="iconfont icon-chat-video"></span> class="iconfont icon-chat-video"></span>
<span>{{msgInfo.content}}</span> <span>{{msgInfo.content}}</span>
</div> </div>
<div class="chat-msg-status" v-if="!isRealtime"> <div class="chat-msg-status" v-if="!isAction">
<span class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId <span class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</span> && msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</span>
<span class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId <span class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId
@ -201,9 +200,12 @@
} }
return items; return items;
}, },
isRealtime() { isAction(){
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE || return this.$msgType.isAction(this.msgInfo.type);
this.msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO },
isNormal() {
const type = this.msgInfo.type;
return this.$msgType.isNormal(type) || this.$msgType.isAction(type)
} }
} }
} }
@ -369,7 +371,7 @@
} }
} }
.chat-realtime { .chat-action {
display: flex; display: flex;
align-items: center; align-items: center;
@ -458,7 +460,7 @@
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.chat-realtime { .chat-action {
flex-direction: row-reverse; flex-direction: row-reverse;
.iconfont { .iconfont {

477
im-ui/src/components/chat/ChatPrivateVideo.vue

@ -1,477 +0,0 @@
<template>
<el-dialog v-dialogDrag :title="title" top="5vh" :close-on-click-modal="false" :close-on-press-escape="false"
:visible="isShow" width="50%" height="70%" :before-close="handleClose">
<div class="chat-video">
<div v-show="rtcInfo.mode=='video'" class="chat-video-box">
<div class="chat-video-friend" v-loading="loading" element-loading-text="等待对方接听..."
element-loading-spinner="el-icon-loading" element-loading-background="rgba(0, 0, 0, 0.3)">
<head-image class="friend-head-image" :id="rtcInfo.friend.id" :size="80" :name="rtcInfo.friend.nickName"
:url="rtcInfo.friend.headImage">
</head-image>
<video ref="friendVideo" autoplay=""></video>
</div>
<div class="chat-video-mine">
<video ref="mineVideo" autoplay=""></video>
</div>
</div>
<div v-show="rtcInfo.mode=='voice'" class="chat-voice-box" v-loading="loading" element-loading-text="等待对方接听..."
element-loading-spinner="el-icon-loading" element-loading-background="rgba(0, 0, 0, 0.3)">
<head-image class="friend-head-image" :id="rtcInfo.friend.id" :size="200" :name="rtcInfo.friend.nickName"
:url="rtcInfo.friend.headImage">
<div class="chat-voice-name">{{rtcInfo.friend.nickName}}</div>
</head-image>
</div>
<div class="chat-video-controllbar">
<div v-show="isWaiting" title="取消呼叫" class="icon iconfont icon-phone-reject reject" style="color: red;"
@click="cancel()"></div>
<div v-show="isAccepted" title="挂断" class="icon iconfont icon-phone-reject reject" style="color: red;"
@click="handup()"></div>
</div>
</div>
</el-dialog>
</template>
<script>
import HeadImage from '../common/HeadImage.vue';
export default {
name: 'chatVideo',
components: {
HeadImage
},
data() {
return {
isShow: false,
stream: null,
audio: new Audio(),
loading: false,
peerConnection: null,
videoTime: 0,
videoTimer: null,
candidates: [],
configuration: {
iceServers: []
}
}
},
methods: {
init() {
this.isShow = true;
if (!this.hasUserMedia() || !this.hasRTCPeerConnection()) {
this.$message.error("初始化失败,原因可能是: 1.未部署ssl证书 2.您的浏览器不支持WebRTC");
this.insertMessage("设备不支持通话");
if (!this.rtcInfo.isHost) {
this.sendFailed("对方设备不支持通话")
}
return;
}
//
this.openCamera((stream) => {
// webrtc
this.setupPeerConnection(stream);
if (this.rtcInfo.isHost) {
//
this.call();
} else {
//
this.accept(this.rtcInfo.offer);
}
});
},
openCamera(callback) {
navigator.getUserMedia({
video: this.isVideo,
audio: true
}, (stream) => {
console.log(this.loading)
this.stream = stream;
this.$refs.mineVideo.srcObject = stream;
this.$refs.mineVideo.muted = true;
callback(stream)
}, (error) => {
let devText = this.isVideo ? "摄像头" : "麦克风"
this.$message.error(`打开${devText}失败:${error}`);
callback()
})
},
closeCamera() {
if (this.stream) {
this.stream.getTracks().forEach((track) => {
track.stop();
});
this.$refs.mineVideo.srcObject = null;
this.stream = null;
}
},
setupPeerConnection(stream) {
this.peerConnection = new RTCPeerConnection(this.configuration);
this.peerConnection.ontrack = (e) => {
this.$refs.friendVideo.srcObject = e.streams[0];
};
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
if (this.isAccepted) {
// ,
this.sendCandidate(event.candidate);
} else {
// ,
this.candidates.push(event.candidate)
}
}
}
if (stream) {
stream.getTracks().forEach((track) => {
this.peerConnection.addTrack(track, stream);
});
}
this.peerConnection.oniceconnectionstatechange = (event) => {
let state = event.target.iceConnectionState;
console.log("ICE connection status changed : " + state)
if (state == 'connected') {
this.resetTime();
}
};
},
insertMessage(messageTip) {
//
let chat = {
type: 'PRIVATE',
targetId: this.rtcInfo.friend.id,
showName: this.rtcInfo.friend.nickName,
headImage: this.rtcInfo.friend.headImage,
};
this.$store.commit("openChat", chat);
//
let MESSAGE_TYPE = this.$enums.MESSAGE_TYPE;
let msgInfo = {
type: this.rtcInfo.mode == "video" ? MESSAGE_TYPE.RT_VIDEO : MESSAGE_TYPE.RT_VOICE,
sendId: this.rtcInfo.sendId,
recvId: this.rtcInfo.recvId,
content: this.isChating ? "通话时长 " + this.currentTime : messageTip,
status: 1,
selfSend: this.rtcInfo.isHost,
sendTime: new Date().getTime()
}
this.$store.commit("insertMessage", msgInfo);
},
onRTCMessage(msg) {
if (!msg.selfSend && msg.type == this.$enums.MESSAGE_TYPE.RTC_ACCEPT) {
//
this.peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(msg.content)));
//
this.loading = false;
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.CHATING)
//
this.audio.pause();
// candidate
this.candidates.forEach((candidate) => {
this.sendCandidate(candidate);
})
} else if (!msg.selfSend && msg.type == this.$enums.MESSAGE_TYPE.RTC_REJECT) {
//
this.$message.error("对方拒绝了您的通话请求");
//
this.insertMessage("对方已拒绝")
//
this.close();
} else if (msg.type == this.$enums.MESSAGE_TYPE.RTC_FAILED) {
//
this.$message.error(msg.content)
//
this.insertMessage(msg.content)
this.close();
} else if (msg.type == this.$enums.MESSAGE_TYPE.RTC_CANDIDATE) {
// 线
this.peerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(msg.content)));
} else if (msg.type == this.$enums.MESSAGE_TYPE.RTC_HANDUP) {
//
this.$message.success("对方已挂断");
//
this.insertMessage("对方已挂断")
this.close();
}
},
call() {
let offerParam = {
offerToRecieveAudio: 1,
offerToRecieveVideo: this.isVideo ? 1 : 0
}
this.peerConnection.createOffer(offerParam).then((offer) => {
this.peerConnection.setLocalDescription(offer);
this.$http({
url: `/webrtc/private/call?uid=${this.rtcInfo.friend.id}&mode=${this.rtcInfo.mode}`,
method: 'post',
data: JSON.stringify(offer)
}).then(() => {
this.loading = true;
//
this.audio.play();
})
}, (error) => {
this.insertMessage("未接通")
this.$message.error(error);
});
},
accept(offer) {
let offerParam = {
offerToRecieveAudio: 1,
offerToRecieveVideo: this.isVideo ? 1 : 0
}
this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
this.peerConnection.createAnswer(offerParam).then((answer) => {
this.peerConnection.setLocalDescription(answer);
this.$http({
url: `/webrtc/private/accept?uid=${this.rtcInfo.friend.id}`,
method: 'post',
data: JSON.stringify(answer)
}).then(() => {
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.CHATING)
})
},
(error) => {
this.$message.error(error);
});
},
handup() {
this.$http({
url: `/webrtc/private/handup?uid=${this.rtcInfo.friend.id}`,
method: 'post'
})
this.insertMessage("已挂断")
this.close();
this.$message.success("您已挂断,通话结束")
},
cancel() {
this.$http({
url: `/webrtc/private/cancel?uid=${this.rtcInfo.friend.id}`,
method: 'post'
})
this.insertMessage("已取消")
this.close();
this.$message.success("已取消呼叫,通话结束")
},
sendFailed(reason) {
this.$http({
url: `/webrtc/private/failed?uid=${this.rtcInfo.friend.id}&reason=${reason}`,
method: 'post'
})
},
sendCandidate(candidate) {
this.$http({
url: `/webrtc/private/candidate?uid=${this.rtcInfo.friend.id}`,
method: 'post',
data: JSON.stringify(candidate)
})
},
close() {
this.isShow = false;
this.closeCamera();
this.loading = false;
this.videoTime = 0;
this.videoTimer && clearInterval(this.videoTimer);
this.audio.pause();
this.candidates = [];
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection.onicecandidate = null;
this.peerConnection.onaddstream = null;
}
if (this.$refs.friendVideo) {
this.$refs.friendVideo.srcObject = null;
}
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
},
resetTime() {
this.videoTime = 0;
this.videoTimer && clearInterval(this.videoTimer);
this.videoTimer = setInterval(() => {
this.videoTime++;
}, 1000)
},
handleClose() {
if (this.isAccepted) {
this.handup()
} else if (this.isWaiting) {
this.cancel();
} else {
this.close();
}
},
hasUserMedia() {
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator
.mozGetUserMedia ||
navigator.msGetUserMedia;
return !!navigator.getUserMedia;
},
hasRTCPeerConnection() {
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window
.mozRTCPeerConnection;
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window
.mozRTCSessionDescription;
window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window
.mozRTCIceCandidate;
return !!window.RTCPeerConnection;
},
initAudio() {
let url = require(`@/assets/audio/call.wav`);
this.audio.src = url;
this.audio.loop = true;
},
initICEServers() {
this.$http({
url: '/webrtc/private/iceservers',
method: 'get'
}).then((servers) => {
this.configuration.iceServers = servers;
})
}
},
watch: {
rtcState: {
handler(newState, oldState) {
// WAIT_CALLACCEPTED
if (newState == this.$enums.RTC_STATE.WAIT_CALL ||
newState == this.$enums.RTC_STATE.ACCEPTED) {
this.init();
}
}
}
},
computed: {
title() {
let strTitle = `${this.modeText}通话-${this.rtcInfo.friend.nickName}`;
if (this.isChating) {
strTitle += `(${this.currentTime})`;
} else if (this.isWaiting) {
strTitle += `(呼叫中)`;
}
return strTitle;
},
currentTime() {
let min = Math.floor(this.videoTime / 60);
let sec = this.videoTime % 60;
let strTime = min < 10 ? "0" : "";
strTime += min;
strTime += ":"
strTime += sec < 10 ? "0" : "";
strTime += sec;
return strTime;
},
rtcInfo() {
return this.$store.state.userStore.rtcInfo;
},
rtcState() {
return this.rtcInfo.state;
},
isVideo() {
return this.rtcInfo.mode == "video"
},
modeText() {
return this.isVideo ? "视频" : "语音";
},
isAccepted() {
return this.rtcInfo.state == this.$enums.RTC_STATE.CHATING ||
this.rtcInfo.state == this.$enums.RTC_STATE.ACCEPTED
},
isWaiting() {
return this.rtcInfo.state == this.$enums.RTC_STATE.WAIT_CALL;
},
isChating() {
return this.rtcInfo.state == this.$enums.RTC_STATE.CHATING;
}
},
mounted() {
this.initAudio();
this.initICEServers();
}
}
</script>
<style lang="scss">
.chat-video {
position: relative;
.el-loading-text {
color: white !important;
font-size: 16px !important;
}
.el-icon-loading {
color: white !important;
font-size: 30px !important;
}
.chat-video-box {
position: relative;
border: #4880b9 solid 1px;
background-color: #eeeeee;
.chat-video-friend {
height: 70vh;
.friend-head-image {
position: absolute;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
transform: rotateY(180deg);
}
}
.chat-video-mine {
position: absolute;
z-index: 99999;
width: 25vh;
right: 0;
bottom: 0;
box-shadow: 0px 0px 5px #ccc;
background-color: #cccccc;
video {
width: 100%;
object-fit: cover;
transform: rotateY(180deg);
}
}
}
.chat-voice-box {
position: relative;
display: flex;
justify-content: center;
border: #4880b9 solid 1px;
width: 100%;
height: 50vh;
padding-top: 10vh;
background-color: aliceblue;
.chat-voice-name {
text-align: center;
font-size: 22px;
font-weight: 600;
}
}
.chat-video-controllbar {
display: flex;
justify-content: space-around;
padding: 10px;
.icon {
font-size: 50px;
cursor: pointer;
}
}
}
</style>

14
im-ui/src/components/chat/ChatVoice.vue → im-ui/src/components/chat/ChatRecord.vue

@ -1,12 +1,12 @@
<template> <template>
<el-dialog class="chat-voice" title="语音录制" :visible.sync="visible" width="600px" :before-close="onClose"> <el-dialog class="chat-record" title="语音录制" :visible.sync="visible" width="600px" :before-close="onClose">
<div v-show="mode=='RECORD'"> <div v-show="mode=='RECORD'">
<div class="chat-voice-tip">{{stateTip}}</div> <div class="tip">{{stateTip}}</div>
<div>时长: {{state=='STOP'?0:parseInt(rc.duration)}}s</div> <div>时长: {{state=='STOP'?0:parseInt(rc.duration)}}s</div>
</div> </div>
<audio v-show="mode=='PLAY'" :src="url" controls ref="audio" @ended="onStopAudio()"></audio> <audio v-show="mode=='PLAY'" :src="url" controls ref="audio" @ended="onStopAudio()"></audio>
<el-divider content-position="center"></el-divider> <el-divider content-position="center"></el-divider>
<el-row class="chat-voice-btn-group"> <el-row class="btn-group">
<el-button round type="primary" v-show="state=='STOP'" @click="onStartRecord()">开始录音</el-button> <el-button round type="primary" v-show="state=='STOP'" @click="onStartRecord()">开始录音</el-button>
<el-button round type="warning" v-show="state=='RUNNING'" @click="onPauseRecord()">暂停录音</el-button> <el-button round type="warning" v-show="state=='RUNNING'" @click="onPauseRecord()">暂停录音</el-button>
<el-button round type="primary" v-show="state=='PAUSE'" @click="onResumeRecord()">继续录音</el-button> <el-button round type="primary" v-show="state=='PAUSE'" @click="onResumeRecord()">继续录音</el-button>
@ -27,7 +27,7 @@
import Recorder from 'js-audio-recorder'; import Recorder from 'js-audio-recorder';
export default { export default {
name: 'chatVoice', name: 'chatRecord',
props: { props: {
visible: { visible: {
type: Boolean type: Boolean
@ -126,13 +126,13 @@
</script> </script>
<style lang="scss"> <style lang="scss">
.chat-voice { .chat-record {
.chat-voice-tip { .tip {
font-size: 18px; font-size: 18px;
} }
.chat-voice-btn-group { .btn-group {
margin-bottom: 20px; margin-bottom: 20px;
} }
} }

277
im-ui/src/components/chat/ChatVideoAcceptor.vue

@ -1,277 +0,0 @@
<template>
<div v-show="isShow" class="chat-video-acceptor">
<head-image :id="rtcInfo.friend.id" :name="rtcInfo.friend.nickName" :url="rtcInfo.friend.headImage" :size="100"></head-image>
<div class="acceptor-text">
{{tip}}
</div>
<div class="acceptor-btn-group">
<div class="icon iconfont icon-phone-accept accept" @click="accpet()" title="接受"></div>
<div class="icon iconfont icon-phone-reject reject" @click="reject()" title="拒绝"></div>
</div>
</div>
</template>
<script>
import HeadImage from '../common/HeadImage.vue';
export default {
name: "videoAcceptor",
components: {
HeadImage
},
data() {
return {
isShow: false,
audio: new Audio()
}
},
methods: {
accpet() {
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.ACCEPTED);
//
this.close();
},
reject() {
this.$http({
url: `/webrtc/private/reject?uid=${this.rtcInfo.friend.id}`,
method: 'post'
})
//
this.insertMessage("已拒绝");
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
//
this.close();
},
failed(reason) {
this.$http({
url: `/webrtc/private/failed?uid=${this.rtcInfo.friend.id}&reason=${reason}`,
method: 'post'
})
//
this.insertMessage("未接听");
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
//
this.close();
},
onRtcCall(msgInfo, friend, mode) {
console.log("onRtcCall")
//
if (this.rtcInfo.state != this.$enums.RTC_STATE.FREE) {
//
let reason = "对方忙,无法与您通话";
this.$http({
url: `/webrtc/private/failed?uid=${msgInfo.sendId}&reason=${reason}`,
method: 'post'
})
return;
}
//
this.isShow = true;
// RTC
let rtcInfo = {
mode: mode,
isHost: false,
friend: friend,
sendId: msgInfo.sendId,
recvId: msgInfo.recvId,
offer: JSON.parse(msgInfo.content),
state: this.$enums.RTC_STATE.WAIT_ACCEPT
}
this.$store.commit("setRtcInfo", rtcInfo);
//
this.audio.play();
//
this.timer && clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.failed("对方无应答");
}, 30000)
},
onRtcCancel(msgInfo) {
//
if (msgInfo.sendId != this.rtcInfo.friend.id) {
return;
}
//
this.$message.success("对方取消了呼叫");
//
this.insertMessage("对方已取消");
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
//
this.close();
},
onRtcAccept(msgInfo) {
//
if (msgInfo.selfSend) {
this.$message.success("已在其他设备接听");
//
this.insertMessage("已在其他设备接听")
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
//
this.close();
}
},
onRtcReject(msgInfo){
//
if (msgInfo.selfSend) {
this.$message.success("已在其他设备拒绝通话");
//
this.insertMessage("已在其他设备拒绝")
//
this.$store.commit("setRtcState", this.$enums.RTC_STATE.FREE);
//
this.close();
}
},
onRTCMessage(msgInfo, friend) {
if (msgInfo.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE) {
this.onRtcCall(msgInfo, friend, "voice");
} else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO) {
this.onRtcCall(msgInfo, friend, "video");
} else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RTC_CANCEL) {
this.onRtcCancel(msgInfo);
} else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RTC_ACCEPT) {
this.onRtcAccept(msgInfo);
}else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RTC_REJECT) {
this.onRtcReject(msgInfo);
}
},
insertMessage(messageTip) {
//
let chat = {
type: 'PRIVATE',
targetId: this.rtcInfo.friend.id,
showName: this.rtcInfo.friend.nickName,
headImage: this.rtcInfo.friend.headImageThumb,
};
this.$store.commit("openChat", chat);
//
let MESSAGE_TYPE = this.$enums.MESSAGE_TYPE;
let msgInfo = {
type: this.rtcInfo.mode == "video" ? MESSAGE_TYPE.RT_VIDEO : MESSAGE_TYPE.RT_VOICE,
sendId: this.rtcInfo.sendId,
recvId: this.rtcInfo.recvId,
content: messageTip,
status: 1,
selfSend: this.rtcInfo.isHost,
sendTime: new Date().getTime()
}
this.$store.commit("insertMessage", msgInfo);
},
close() {
this.timer && clearTimeout(this.timer);
this.audio.pause();
this.isShow = false;
},
initAudio() {
let url = require(`@/assets/audio/call.wav`);
this.audio.src = url;
this.audio.loop = true;
}
},
computed: {
tip() {
let modeText = this.mode == "video" ? "视频" : "语音"
return `${this.rtcInfo.friend.nickName} 请求和您进行${modeText}通话...`
},
rtcInfo(){
return this.$store.state.userStore.rtcInfo;
}
},
mounted() {
//
this.initAudio();
}
}
</script>
<style scoped lang="scss">
.chat-video-acceptor {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
right: 5px;
bottom: 5px;
width: 250px;
height: 250px;
padding: 20px;
background-color: #eeeeee;
border: #dddddd solid 5px;
border-radius: 3%;
.acceptor-text {
padding: 10px;
text-align: center;
font-size: 16px;
}
.acceptor-btn-group {
display: flex;
justify-content: space-around;
margin-top: 20px;
width: 100%;
.icon {
font-size: 60px;
cursor: pointer;
border-radius: 50%;
&.accept {
color: green;
animation: anim 2s ease-in infinite, vibration 2s ease-in infinite;
@keyframes anim {
0% {
box-shadow: 0 1px 0 4px #ffffff;
}
10% {
box-shadow: 0 1px 0 8px rgba(255, 165, 0, 1);
}
25% {
box-shadow: 0 1px 0 12px rgba(255, 210, 128, 1), 0 1px 0 16px rgba(255, 201, 102, 1);
}
50% {
box-shadow: 0 2px 5px 10px rgba(255, 184, 51, 1), 0 2px 5px 23px rgba(248, 248, 255, 1);
}
}
@keyframes vibration {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-15deg);
}
100% {
transform: rotate(0deg);
}
}
}
&.reject {
color: red;
}
}
}
}
</style>

28
im-ui/src/components/common/HeadImage.vue

@ -28,6 +28,16 @@
type: Number, type: Number,
default: 50 default: 50
}, },
width: {
type: Number
},
height: {
type: Number
},
radius:{
type: String,
default: "10%"
},
url: { url: {
type: String type: String
}, },
@ -54,12 +64,18 @@
} }
}, },
computed:{ computed:{
avatarImageStyle(){ avatarImageStyle() {
return `width:${this.size}px; height:${this.size}px;` let w = this.width ? this.width : this.size;
let h = this.height ? this.height : this.size;
return `width:${w}px; height:${h}px;
border-radius: ${this.radius};`
}, },
avatarTextStyle(){ avatarTextStyle() {
return `width: ${this.size}px;height:${this.size}px; let w = this.width ? this.width : this.size;
color:${this.textColor};font-size:${this.size*0.6}px;` let h = this.height ? this.height : this.size;
return `width: ${w}px;height:${h}px;
color:${this.textColor};font-size:${w*0.6}px;
border-radius: ${this.radius};`
}, },
textColor(){ textColor(){
let hash = 0; let hash = 0;
@ -79,7 +95,7 @@
.avatar-image { .avatar-image {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 10%; display: block;
} }
.avatar-text{ .avatar-text{

28
im-ui/src/components/group/AddGroupMember.vue

@ -136,33 +136,7 @@
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
.el-checkbox {
display: flex;
align-items: center;
//
.el-checkbox__inner {
width: 20px;
height: 20px;
//
&::after {
height: 12px;
left: 7px;
}
}
//
.el-checkbox__input.is-checked+.el-checkbox__label {
color: #333333;
}
.el-checkbox__label {
line-height: 20px;
padding-left: 8px;
}
}
.agm-friend-checkbox { .agm-friend-checkbox {
margin-right: 20px; margin-right: 20px;
} }

70
im-ui/src/components/group/GroupMemberItem.vue

@ -0,0 +1,70 @@
<template>
<div class="group-member-item" :style="{'height':height+'px'}">
<div class="member-avatar">
<head-image :size="headImageSize" :name="member.aliasName"
:url="member.headImage" :online="member.online"> </head-image>
</div>
<div class="member-name" :style="{'line-height':height+'px'}">
<div>{{ member.aliasName }}</div>
</div>
<slot></slot>
</div>
</template>
<script>
import HeadImage from "../common/HeadImage.vue";
export default {
name: "groupMember",
components: { HeadImage },
data() {
return {};
},
props: {
member: {
type: Object,
required: true
},
height:{
type: Number,
default: 50
}
},
computed:{
headImageSize(){
return Math.ceil(this.height * 0.75)
}
}
}
</script>
<style lang="scss">
.group-member-item {
display: flex;
margin-bottom: 1px;
position: relative;
padding: 0 15px;
align-items: center;
background-color: #fafafa;
white-space: nowrap;
box-sizing: border-box;
&:hover {
background-color: #eeeeee;
}
&.active {
background-color: #eeeeee;
}
.member-name {
flex:1;
padding-left: 10px;
height: 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
font-size: 14px;
font-weight: 600;
}
}
</style>

161
im-ui/src/components/group/GroupMemberSelector.vue

@ -0,0 +1,161 @@
<template>
<el-dialog title="选择成员" :visible.sync="isShow" width="50%">
<div class="group-member-selector">
<div class="left-box">
<el-input placeholder="搜索" v-model="searchText">
<i class="el-icon-search el-input__icon" slot="suffix"> </i>
</el-input>
<el-scrollbar style="height:400px;">
<div v-for="m in members" :key="m.userId">
<group-member-item v-show="!m.quit&&m.aliasName.startsWith(searchText)"
:member="m" @click.native="onClickMember(m)">
<el-checkbox :disabled="m.locked" v-model="m.checked" @change="onChange(m)"
@click.native.stop=""></el-checkbox>
</group-member-item>
</div>
</el-scrollbar>
</div>
<div class="arrow el-icon-d-arrow-right"></div>
<div class="right-box">
<div class="select-tip"> 已勾选{{checkedMembers.length}}位成员</div>
<div class="checked-member-list">
<div v-for="m in members" :key="m.userId">
<group-member class="member-item" v-if="m.checked" :member="m"></group-member>
</div>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="close()"> </el-button>
<el-button type="primary" @click="ok()"> </el-button>
</span>
</el-dialog>
</template>
<script>
import GroupMemberItem from './GroupMemberItem.vue';
import GroupMember from './GroupMember.vue';
export default {
name: "addGroupMember",
components: {
GroupMemberItem,
GroupMember
},
data() {
return {
isShow: false,
searchText: "",
maxSize: -1,
members: []
}
},
props: {
groupId: {
type: Number
}
},
methods: {
open(maxSize, checkedIds, lockedIds) {
this.maxSize = maxSize;
this.isShow = true;
this.loadGroupMembers(checkedIds, lockedIds);
},
loadGroupMembers(checkedIds, lockedIds) {
this.$http({
url: `/group/members/${this.groupId}`,
method: 'get'
}).then((members) => {
members.forEach((m) => {
//
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
});
this.members = members;
});
},
onClickMember(m) {
if (!m.locked) {
m.checked = !m.checked;
}
if (this.checkedMembers.length > this.maxSize) {
this.$message.error(`最多选择${this.maxSize}位成员`)
m.checked = false;
}
},
onChange(m) {
if (this.checkedMembers.length > this.maxSize) {
this.$message.error(`最多选择${this.maxSize}位成员`)
m.checked = false;
}
},
ok() {
this.$emit("complete", this.checkedMembers);
this.isShow = false;
},
close() {
this.isShow = false;
}
},
computed: {
checkedMembers() {
let ids = [];
this.members.forEach((m) => {
if (m.checked) {
ids.push(m);
}
})
return ids;
}
}
}
</script>
<style lang="scss">
.group-member-selector {
display: flex;
.left-box {
width: 48%;
border: #587FF0 solid 1px;
border-radius: 5px;
overflow: hidden;
}
.arrow {
display: flex;
align-items: center;
font-size: 20px;
padding: 10px;
font-weight: 600;
color: #687Ff0;
}
.right-box {
width: 48%;
border: #587FF0 solid 1px;
border-radius: 5px;
.select-tip {
text-align: left;
height: 40px;
line-height: 40px;
text-indent: 5px;
}
.checked-member-list {
padding: 10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
.member-item {
padding: 2px;
}
}
}
}
</style>

116
im-ui/src/components/rtc/RtcGroupJoin.vue

@ -0,0 +1,116 @@
<template>
<el-dialog title="是否加入通话?" :visible.sync="isShow" width="400px">
<div class="rtc-group-join">
<div class="host-info">
<head-image :name="rtcInfo.host.nickName" :url="rtcInfo.host.headImage" :size="80"></head-image>
<div class="host-text">{{'发起人:'+rtcInfo.host.nickName}}</div>
</div>
<div class="users-info">
<div>{{rtcInfo.userInfos.length+'人正在通话中'}}</div>
<div class="user-list">
<div class="user-item" v-for="user in rtcInfo.userInfos" :key="user.id">
<head-image :url="user.headImage" :name="user.nickName" :size="40">
</head-image>
</div>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="onCancel()"> </el-button>
<el-button type="primary" @click="onOk()"> </el-button>
</span>
</el-dialog>
</template>
<script>
import HeadImage from '@/components/common/HeadImage'
export default{
name: "rtcGroupJoin",
components:{
HeadImage
},
data() {
return {
isShow: false,
rtcInfo: {
host:{},
userInfos:[]
}
}
},
props: {
groupId: {
type: Number
}
},
methods: {
open(rtcInfo) {
this.rtcInfo = rtcInfo;
this.isShow = true;
},
onOk() {
this.isShow = false;
let userInfos = this.rtcInfo.userInfos;
let mine = this.$store.state.userStore.userInfo;
if(!userInfos.find((user)=>user.id==mine.id)){
//
userInfos.push({
id: mine.id,
nickName: mine.nickName,
headImage: mine.headImageThumb,
isCamera: false,
isMicroPhone: true
})
}
let rtcInfo = {
isHost: false,
groupId: this.groupId,
inviterId: mine.id,
userInfos: userInfos
}
// home.vue
this.$eventBus.$emit("openGroupVideo", rtcInfo);
},
onCancel(){
this.isShow = false;
}
}
}
</script>
<style lang="scss" scoped>
.rtc-group-join {
height: 260px;
padding: 10px;
.host-info {
display: flex;
flex-direction: column;
font-size: 16px;
padding: 10px;
height: 100px;
align-items: center;
.host-text{
margin-top: 5px;
}
}
.users-info {
font-size: 16px;
margin-top: 20px;
.user-list {
display: flex;
padding: 5px 5px;
height: 90px;
flex-wrap: wrap;
justify-content: center;
.user-item{
padding: 2px;
}
}
}
}
</style>

42
im-ui/src/components/rtc/RtcGroupVideo.vue

@ -0,0 +1,42 @@
<template>
<el-dialog v-dialogDrag top="5vh" title="语音通话" :close-on-click-modal="false" :close-on-press-escape="false"
:visible.sync="isShow" width="50%">
<div class='rtc-group-video'>
<div style="padding-top:30px;font-weight: 600; text-align: center;font-size: 16px;">
多人音视频通话为付费功能有需要请联系作者...
</div>
<div style="padding-top:50px; text-align: center;font-size: 16px;">
点击下方文档了解详细信息:
</div>
<div style="padding-top:10px; text-align: center;font-size: 16px;">
<a href="https://www.yuque.com/u1475064/mufu2a/vi7engzluty594s2" target="_blank">
付费-音视频通话源码
</a>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: "rtcGroupVideo",
data() {
return {
isShow: false
}
},
methods: {
open() {
this.isShow = true;
}
}
}
</script>
<style lang="scss">
.rtc-group-video {
height: 300px;
background-color: #E8F2FF;
}
</style>

126
im-ui/src/components/rtc/RtcPrivateAcceptor.vue

@ -0,0 +1,126 @@
<template>
<div class="rtc-private-acceptor">
<head-image :id="friend.id" :name="friend.nickName" :url="friend.headImage" :size="100"></head-image>
<div class="acceptor-text">
{{tip}}
</div>
<div class="acceptor-btn-group">
<div class="icon iconfont icon-phone-accept accept" @click="$emit('accept')" title="接受"></div>
<div class="icon iconfont icon-phone-reject reject" @click="$emit('reject')" title="拒绝"></div>
</div>
</div>
</template>
<script>
import HeadImage from '../common/HeadImage.vue';
export default {
name: "rtcPrivateAcceptor",
components: {
HeadImage
},
data() {
return {}
},
props: {
mode:{
type: String
},
friend:{
type: Object
}
},
computed: {
tip() {
let modeText = this.mode == "video" ? "视频" : "语音"
return `${this.friend.nickName} 请求和您进行${modeText}通话...`
}
}
}
</script>
<style scoped lang="scss">
.rtc-private-acceptor {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
right: 5px;
bottom: 5px;
width: 250px;
height: 250px;
padding: 20px;
background-color: #eeeeee;
border: #dddddd solid 5px;
border-radius: 3%;
.acceptor-text {
padding: 10px;
text-align: center;
font-size: 16px;
}
.acceptor-btn-group {
display: flex;
justify-content: space-around;
margin-top: 20px;
width: 100%;
.icon {
font-size: 60px;
cursor: pointer;
border-radius: 50%;
&.accept {
color: green;
animation: anim 2s ease-in infinite, vibration 2s ease-in infinite;
@keyframes anim {
0% {
box-shadow: 0 1px 0 4px #ffffff;
}
10% {
box-shadow: 0 1px 0 8px rgba(255, 165, 0, 1);
}
25% {
box-shadow: 0 1px 0 12px rgba(255, 210, 128, 1), 0 1px 0 16px rgba(255, 201, 102, 1);
}
50% {
box-shadow: 0 2px 5px 10px rgba(255, 184, 51, 1), 0 2px 5px 23px rgba(248, 248, 255, 1);
}
}
@keyframes vibration {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-15deg);
}
100% {
transform: rotate(0deg);
}
}
}
&.reject {
color: red;
}
}
}
}
</style>

496
im-ui/src/components/rtc/RtcPrivateVideo.vue

@ -0,0 +1,496 @@
<template>
<div>
<el-dialog v-dialogDrag :title="title" top="5vh" :close-on-click-modal="false" :close-on-press-escape="false"
:visible.sync="showRoom" width="50%" height="70%" :before-close="onQuit">
<div class="rtc-private-video">
<div v-show="isVideo" class="rtc-video-box">
<div class="rtc-video-friend" v-loading="!isChating" element-loading-text="等待对方接听..."
element-loading-background="rgba(0, 0, 0, 0.3)" >
<head-image class="friend-head-image" :id="friend.id" :size="80" :name="friend.nickName"
:url="friend.headImage">
</head-image>
<video ref="remoteVideo" autoplay=""></video>
</div>
<div class="rtc-video-mine">
<video ref="localVideo" autoplay=""></video>
</div>
</div>
<div v-show="!isVideo" class="rtc-voice-box" v-loading="!isChating" element-loading-text="等待对方接听..."
element-loading-background="rgba(0, 0, 0, 0.3)">
<head-image class="friend-head-image" :id="friend.id" :size="200" :name="friend.nickName"
:url="friend.headImage">
<div class="rtc-voice-name">{{friend.nickName}}</div>
</head-image>
</div>
<div class="rtc-control-bar">
<div title="取消" class="icon iconfont icon-phone-reject reject"
style="color: red;" @click="onQuit()"></div>
</div>
</div>
</el-dialog>
<rtc-private-acceptor v-if="!isHost&&isWaiting" ref="acceptor" :friend="friend" :mode="mode" @accept="onAccept"
@reject="onReject"></rtc-private-acceptor>
</div>
</template>
<script>
import HeadImage from '../common/HeadImage.vue';
import RtcPrivateAcceptor from './RtcPrivateAcceptor.vue';
import ImWebRtc from '@/api/webrtc';
import ImCamera from '@/api/camera';
import RtcPrivateApi from '@/api/rtcPrivateApi'
export default {
name: 'rtcPrivateVideo',
components: {
HeadImage,
RtcPrivateAcceptor
},
data() {
return {
camera: new ImCamera(), //
webrtc: new ImWebRtc(), // webrtc
API: new RtcPrivateApi(), // API
audio: new Audio(), //
showRoom: false,
friend: {},
isHost: false, //
state: "CLOSE", // CLOSE: WAITING: CHATING: ERROR:
mode: 'video', // video: voice:
localStream: null, //
remoteStream: null, //
videoTime: 0,
videoTimer: null,
heartbeatTimer: null,
candidates: [],
}
},
methods: {
open(rtcInfo) {
this.showRoom = true;
this.mode = rtcInfo.mode;
this.isHost = rtcInfo.isHost;
this.friend = rtcInfo.friend;
if (this.isHost) {
this.onCall();
}
},
initAudio() {
let url = require(`@/assets/audio/call.wav`);
this.audio.src = url;
this.audio.loop = true;
},
initRtc() {
this.webrtc.init(this.configuration)
this.webrtc.setupPeerConnection((stream) => {
this.$refs.remoteVideo.srcObject = stream;
this.remoteStream = stream;
})
//
this.webrtc.onIcecandidate((candidate) => {
if (this.state == "CHATING") {
// ,
this.API.sendCandidate(this.friend.id, candidate);
} else {
// ,
this.candidates.push(candidate)
}
})
//
this.webrtc.onStateChange((state) => {
if (state == "connected") {
console.log("webrtc连接成功")
} else if (state == "disconnected") {
console.log("webrtc连接断开")
}
})
},
onCall() {
if (!this.checkDevEnable()) {
this.close();
}
// webrtc
this.initRtc();
//
this.startHeartBeat();
//
this.openStream().finally(() => {
this.webrtc.setStream(this.localStream);
this.webrtc.createOffer().then((offer) => {
//
this.API.call(this.friend.id, this.mode, offer).then(() => {
//
this.state = "WAITING";
//
this.audio.play();
}).catch(()=>{
this.close();
})
})
})
},
onAccept() {
if (!this.checkDevEnable()) {
this.API.failed(this.friend.id, "对方设备不支持通话")
this.close();
return;
}
//
this.showRoom = true;
this.state = "CHATING";
//
this.audio.pause();
// webrtc
this.initRtc();
//
this.openStream().finally(() => {
this.webrtc.setStream(this.localStream);
this.webrtc.createAnswer(this.offer).then((answer) => {
this.API.accept(this.friend.id, answer);
//
this.startChatTime();
//
this.waitTimer && clearTimeout(this.waitTimer);
})
})
},
onReject() {
console.log("onReject")
// 退
this.API.reject(this.friend.id);
// 退
this.close();
},
onHandup() {
this.API.handup(this.friend.id)
this.$message.success("您已挂断,通话结束")
this.close();
},
onCancel() {
this.API.cancel(this.friend.id)
this.$message.success("已取消呼叫,通话结束")
this.close();
},
onRTCMessage(msg) {
//
if (msg.type != this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE &&
msg.type != this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO &&
this.isClose) {
return;
}
// RTC
switch (msg.type) {
case this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE:
this.onRTCCall(msg, 'voice')
break;
case this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO:
this.onRTCCall(msg, 'video')
break;
case this.$enums.MESSAGE_TYPE.RTC_ACCEPT:
this.onRTCAccept(msg)
break;
case this.$enums.MESSAGE_TYPE.RTC_REJECT:
this.onRTCReject(msg)
break;
case this.$enums.MESSAGE_TYPE.RTC_CANCEL:
this.onRTCCancel(msg)
break;
case this.$enums.MESSAGE_TYPE.RTC_FAILED:
this.onRTCFailed(msg)
break;
case this.$enums.MESSAGE_TYPE.RTC_HANDUP:
this.onRTCHandup(msg)
break;
case this.$enums.MESSAGE_TYPE.RTC_CANDIDATE:
this.onRTCCandidate(msg)
break;
}
},
onRTCCall(msg, mode) {
this.offer = JSON.parse(msg.content);
this.isHost = false;
this.mode = mode;
this.$http({
url: `/friend/find/${msg.sendId}`,
method: 'get'
}).then((friend) => {
this.friend = friend;
this.state = "WAITING";
this.audio.play();
this.startHeartBeat();
// 30s
this.waitTimer = setTimeout(() => {
this.API.failed(this.friend.id,"对方无应答");
this.$message.error("您未接听");
this.close();
}, 30000)
})
},
onRTCAccept(msg) {
if (msg.selfSend) {
//
this.$message.success("已在其他设备接听");
this.close();
} else {
//
let offer = JSON.parse(msg.content);
this.webrtc.setRemoteDescription(offer);
//
this.state = 'CHATING'
//
this.audio.pause();
// candidate
this.candidates.forEach((candidate) => {
this.API.sendCandidate(this.friend.id, candidate);
})
}
},
onRTCReject(msg) {
if (msg.selfSend) {
this.$message.success("已在其他设备拒绝");
this.close();
} else {
this.$message.error("对方拒绝了您的通话请求");
this.close();
}
},
onRTCFailed(msg) {
//
this.$message.error(msg.content)
this.close();
},
onRTCCancel() {
//
this.$message.success("对方取消了呼叫");
this.close();
},
onRTCHandup() {
//
this.$message.success("对方已挂断");
this.close();
},
onRTCCandidate(msg) {
let candidate = JSON.parse(msg.content);
this.webrtc.addIceCandidate(candidate);
},
openStream() {
return new Promise((resolve, reject) => {
if (this.isVideo) {
// +
this.camera.openVideo().then((stream) => {
this.localStream = stream;
this.$nextTick(() => {
this.$refs.localVideo.srcObject = stream;
this.$refs.localVideo.muted = true;
})
resolve(stream);
}).catch((e) => {
this.$message.error("打开摄像头失败")
console.log("本摄像头打开失败:" + e.message)
reject(e);
})
} else {
//
this.camera.openAudio().then((stream) => {
this.localStream = stream;
this.$refs.localVideo.srcObject = stream;
resolve(stream);
}).catch((e) => {
this.$message.error("打开麦克风失败")
console.log("打开麦克风失败:" + e.message)
reject(e);
})
}
})
},
startChatTime() {
this.videoTime = 0;
this.videoTimer && clearInterval(this.videoTimer);
this.videoTimer = setInterval(() => {
this.videoTime++;
}, 1000)
},
checkDevEnable() {
//
if (!this.camera.isEnable()) {
this.message.error("访问摄像头失败");
return false;
}
// webrtc
if (!this.webrtc.isEnable()) {
this.message.error("初始化RTC失败,原因可能是: 1.服务器缺少ssl证书 2.您的设备不支持WebRTC");
return false;
}
return true;
},
startHeartBeat() {
// 15s
this.heartbeatTimer && clearInterval(this.heartbeatTimer);
this.heartbeatTimer = setInterval(() => {
this.API.heartbeat(this.friend.id);
}, 15000)
},
close() {
this.showRoom = false;
this.camera.close();
this.webrtc.close();
this.audio.pause();
this.videoTime = 0;
this.videoTimer && clearInterval(this.videoTimer);
this.heartbeatTimer && clearInterval(this.heartbeatTimer);
this.waitTimer && clearTimeout(this.waitTimer);
this.videoTimer = null;
this.heartbeatTimer = null;
this.waitTimer = null;
this.state = 'CLOSE';
this.candidates = [];
},
onQuit() {
if (this.isChating) {
this.onHandup()
} else if (this.isWaiting) {
this.onCancel();
} else {
this.close();
}
}
},
computed: {
title() {
let strTitle = `${this.modeText}通话-${this.friend.nickName}`;
if (this.isChating) {
strTitle += `(${this.currentTime})`;
} else if (this.isWaiting) {
strTitle += `(呼叫中)`;
}
return strTitle;
},
currentTime() {
let min = Math.floor(this.videoTime / 60);
let sec = this.videoTime % 60;
let strTime = min < 10 ? "0" : "";
strTime += min;
strTime += ":"
strTime += sec < 10 ? "0" : "";
strTime += sec;
return strTime;
},
configuration() {
const iceServers = this.$store.state.configStore.webrtc.iceServers;
return {
iceServers: iceServers
}
},
isVideo() {
return this.mode == "video"
},
modeText() {
return this.isVideo ? "视频" : "语音";
},
isChating() {
return this.state == "CHATING";
},
isWaiting() {
return this.state == "WAITING";
},
isClose() {
return this.state == "CLOSE";
}
},
mounted() {
//
this.initAudio();
},
created() {
//
window.addEventListener('beforeunload', () => {
this.onQuit();
});
},
beforeUnmount() {
this.onQuit();
}
}
</script>
<style lang="scss">
.rtc-private-video {
position: relative;
.el-loading-text {
color: white !important;
font-size: 16px !important;
}
.path {
stroke: white !important;
}
.rtc-video-box {
position: relative;
border: #4880b9 solid 1px;
background-color: #eeeeee;
.rtc-video-friend {
height: 70vh;
.friend-head-image {
position: absolute;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
transform: rotateY(180deg);
}
}
.rtc-video-mine {
position: absolute;
z-index: 99999;
width: 25vh;
right: 0;
bottom: 0;
box-shadow: 0px 0px 5px #ccc;
background-color: #cccccc;
video {
width: 100%;
object-fit: cover;
transform: rotateY(180deg);
}
}
}
.rtc-voice-box {
position: relative;
display: flex;
justify-content: center;
border: #4880b9 solid 1px;
width: 100%;
height: 50vh;
padding-top: 10vh;
background-color: aliceblue;
.rtc-voice-name {
text-align: center;
font-size: 22px;
font-weight: 600;
}
}
.rtc-control-bar {
display: flex;
justify-content: space-around;
padding: 10px;
.icon {
font-size: 50px;
cursor: pointer;
}
}
}
</style>

3
im-ui/src/main.js

@ -6,6 +6,7 @@ import 'element-ui/lib/theme-chalk/index.css';
import './assets/iconfont/iconfont.css'; import './assets/iconfont/iconfont.css';
import httpRequest from './api/httpRequest'; import httpRequest from './api/httpRequest';
import * as socketApi from './api/wssocket'; import * as socketApi from './api/wssocket';
import * as messageType from './api/messageType';
import emotion from './api/emotion.js'; import emotion from './api/emotion.js';
import element from './api/element.js'; import element from './api/element.js';
import store from './store'; import store from './store';
@ -16,11 +17,13 @@ import './utils/directive/dialogDrag';
Vue.use(ElementUI); Vue.use(ElementUI);
// 挂载全局 // 挂载全局
Vue.prototype.$wsApi = socketApi; Vue.prototype.$wsApi = socketApi;
Vue.prototype.$msgType = messageType
Vue.prototype.$date = date; Vue.prototype.$date = date;
Vue.prototype.$http = httpRequest // http请求方法 Vue.prototype.$http = httpRequest // http请求方法
Vue.prototype.$emo = emotion; // emo表情 Vue.prototype.$emo = emotion; // emo表情
Vue.prototype.$elm = element; // 元素操作 Vue.prototype.$elm = element; // 元素操作
Vue.prototype.$enums = enums; // 枚举 Vue.prototype.$enums = enums; // 枚举
Vue.prototype.$eventBus = new Vue(); // 全局事件
Vue.config.productionTip = false; Vue.config.productionTip = false;
new Vue({ new Vue({

16
im-ui/src/store/chatStore.js

@ -142,11 +142,13 @@ export default {
chat.lastContent = "[文件]"; chat.lastContent = "[文件]";
} else if (msgInfo.type == MESSAGE_TYPE.AUDIO) { } else if (msgInfo.type == MESSAGE_TYPE.AUDIO) {
chat.lastContent = "[语音]"; chat.lastContent = "[语音]";
} else if (msgInfo.type == MESSAGE_TYPE.TEXT || msgInfo.type == MESSAGE_TYPE.RECALL) { } else if (msgInfo.type == MESSAGE_TYPE.TEXT
|| msgInfo.type == MESSAGE_TYPE.RECALL
|| msgInfo.type == MESSAGE_TYPE.TIP_TEXT ) {
chat.lastContent = msgInfo.content; chat.lastContent = msgInfo.content;
} else if (msgInfo.type == MESSAGE_TYPE.RT_VOICE) { } else if (msgInfo.type == MESSAGE_TYPE.ACT_RT_VOICE) {
chat.lastContent = "[语音通话]"; chat.lastContent = "[语音通话]";
} else if (msgInfo.type == MESSAGE_TYPE.RT_VIDEO) { } else if (msgInfo.type == MESSAGE_TYPE.ACT_RT_VIDEO) {
chat.lastContent = "[视频通话]"; chat.lastContent = "[视频通话]";
} }
chat.lastSendTime = msgInfo.sendTime; chat.lastSendTime = msgInfo.sendTime;
@ -239,14 +241,14 @@ export default {
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
loadingPrivateMsg(state, loadding) { loadingPrivateMsg(state, loading) {
state.loadingPrivateMsg = loadding; state.loadingPrivateMsg = loading;
if (!state.loadingPrivateMsg && !state.loadingGroupMsg) { if (!state.loadingPrivateMsg && !state.loadingGroupMsg) {
this.commit("sort") this.commit("sort")
} }
}, },
loadingGroupMsg(state, loadding) { loadingGroupMsg(state, loading) {
state.loadingGroupMsg = loadding; state.loadingGroupMsg = loading;
if (!state.loadingPrivateMsg && !state.loadingGroupMsg) { if (!state.loadingPrivateMsg && !state.loadingGroupMsg) {
this.commit("sort") this.commit("sort")
} }

32
im-ui/src/store/configStore.js

@ -0,0 +1,32 @@
import http from '../api/httpRequest.js'
export default {
state: {
webrtc: {}
},
mutations: {
setConfig(state, config) {
state.webrtc = config.webrtc;
},
clear(state){
state.webrtc = {};
}
},
actions:{
loadConfig(context){
return new Promise((resolve, reject) => {
http({
url: '/system/config',
method: 'GET'
}).then((config) => {
console.log("系统配置",config)
context.commit("setConfig",config);
resolve();
}).catch((res)=>{
reject(res);
});
})
}
}
}

4
im-ui/src/store/index.js

@ -4,12 +4,13 @@ import chatStore from './chatStore.js';
import friendStore from './friendStore.js'; import friendStore from './friendStore.js';
import userStore from './userStore.js'; import userStore from './userStore.js';
import groupStore from './groupStore.js'; import groupStore from './groupStore.js';
import configStore from './configStore.js';
import uiStore from './uiStore.js'; import uiStore from './uiStore.js';
Vue.use(Vuex) Vue.use(Vuex)
export default new Vuex.Store({ export default new Vuex.Store({
modules: {chatStore,friendStore,userStore,groupStore,uiStore}, modules: {chatStore,friendStore,userStore,groupStore,configStore,uiStore},
state: {}, state: {},
mutations: { mutations: {
}, },
@ -20,6 +21,7 @@ export default new Vuex.Store({
promises.push(this.dispatch("loadFriend")); promises.push(this.dispatch("loadFriend"));
promises.push(this.dispatch("loadGroup")); promises.push(this.dispatch("loadGroup"));
promises.push(this.dispatch("loadChat")); promises.push(this.dispatch("loadChat"));
promises.push(this.dispatch("loadConfig"));
return Promise.all(promises); return Promise.all(promises);
}) })
}, },

4
im-ui/src/view/Chat.vue

@ -6,7 +6,7 @@
<i class="el-icon-search el-input__icon" slot="prefix"> </i> <i class="el-icon-search el-input__icon" slot="prefix"> </i>
</el-input> </el-input>
</div> </div>
<div class="chat-list-loadding" v-if="loading" v-loading="true" element-loading-text="消息接收中..." <div class="chat-list-loading" v-if="loading" v-loading="true" element-loading-text="消息接收中..."
element-loading-spinner="el-icon-loading" element-loading-background="#eee"> element-loading-spinner="el-icon-loading" element-loading-background="#eee">
<div class="chat-loading-box"></div> <div class="chat-loading-box"></div>
</div> </div>
@ -85,7 +85,7 @@
} }
.chat-list-loadding{ .chat-list-loading{
height: 50px; height: 50px;
background-color: #eee; background-color: #eee;

72
im-ui/src/view/Home.vue

@ -7,7 +7,7 @@
@click.native="showSettingDialog = true"> @click.native="showSettingDialog = true">
</head-image> </head-image>
</div> </div>
<el-menu background-color="#19082f" style="margin-top: 25px;"> <el-menu background-color="#19082f" style="margin-top: 25px;">
<el-menu-item title="聊天"> <el-menu-item title="聊天">
<router-link class="link" v-bind:to="'/home/chat'"> <router-link class="link" v-bind:to="'/home/chat'">
<span class="icon iconfont icon-chat"></span> <span class="icon iconfont icon-chat"></span>
@ -40,8 +40,8 @@
@close="$store.commit('closeUserInfoBox')"></user-info> @close="$store.commit('closeUserInfoBox')"></user-info>
<full-image :visible="uiStore.fullImage.show" :url="uiStore.fullImage.url" <full-image :visible="uiStore.fullImage.show" :url="uiStore.fullImage.url"
@close="$store.commit('closeFullImageBox')"></full-image> @close="$store.commit('closeFullImageBox')"></full-image>
<chat-private-video ref="privateVideo"></chat-private-video> <rtc-private-video ref="rtcPrivateVideo"></rtc-private-video>
<chat-video-acceptor ref="videoAcceptor"></chat-video-acceptor> <rtc-group-video ref="rtcGroupVideo"></rtc-group-video>
</el-container> </el-container>
</template> </template>
@ -50,8 +50,9 @@
import Setting from '../components/setting/Setting.vue'; import Setting from '../components/setting/Setting.vue';
import UserInfo from '../components/common/UserInfo.vue'; import UserInfo from '../components/common/UserInfo.vue';
import FullImage from '../components/common/FullImage.vue'; import FullImage from '../components/common/FullImage.vue';
import ChatPrivateVideo from '../components/chat/ChatPrivateVideo.vue'; import RtcPrivateVideo from '../components/rtc/RtcPrivateVideo.vue';
import ChatVideoAcceptor from '../components/chat/ChatVideoAcceptor.vue'; import RtcPrivateAcceptor from '../components/rtc/RtcPrivateAcceptor.vue';
import RtcGroupVideo from '../components/rtc/RtcGroupVideo.vue';
export default { export default {
components: { components: {
@ -59,8 +60,9 @@
Setting, Setting,
UserInfo, UserInfo,
FullImage, FullImage,
ChatPrivateVideo, RtcPrivateVideo,
ChatVideoAcceptor RtcPrivateAcceptor,
RtcGroupVideo
}, },
data() { data() {
return { return {
@ -70,6 +72,15 @@
}, },
methods: { methods: {
init() { init() {
this.$eventBus.$on('openPrivateVideo', (rctInfo) => {
//
this.$refs.rtcPrivateVideo.open(rctInfo);
});
this.$eventBus.$on('openGroupVideo', (rctInfo) => {
//
this.$refs.rtcGroupVideo.open(rctInfo);
});
this.$store.dispatch("load").then(() => { this.$store.dispatch("load").then(() => {
// ws // ws
this.$wsApi.connect(process.env.VUE_APP_WS_URL, sessionStorage.getItem("accessToken")); this.$wsApi.connect(process.env.VUE_APP_WS_URL, sessionStorage.getItem("accessToken"));
@ -125,7 +136,7 @@
}, },
handlePrivateMessage(msg) { handlePrivateMessage(msg) {
// //
if (msg.type == this.$enums.MESSAGE_TYPE.LOADDING) { if (msg.type == this.$enums.MESSAGE_TYPE.LOADING) {
this.$store.commit("loadingPrivateMsg", JSON.parse(msg.content)) this.$store.commit("loadingPrivateMsg", JSON.parse(msg.content))
return; return;
} }
@ -146,6 +157,11 @@
} }
// //
msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id; msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id;
// webrtc
if (this.$msgType.isRtcPrivate(msg.type)) {
this.$refs.rtcPrivateVideo.onRTCMessage(msg)
return;
}
// id // id
let friendId = msg.selfSend ? msg.recvId : msg.sendId; let friendId = msg.selfSend ? msg.recvId : msg.sendId;
this.loadFriendInfo(friendId).then((friend) => { this.loadFriendInfo(friendId).then((friend) => {
@ -153,21 +169,6 @@
}) })
}, },
insertPrivateMessage(friend, msg) { insertPrivateMessage(friend, msg) {
// webrtc
if (msg.type >= this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE &&
msg.type <= this.$enums.MESSAGE_TYPE.RTC_CANDIDATE) {
let rtcInfo = this.$store.state.userStore.rtcInfo;
//
if (msg.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE ||
msg.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO ||
rtcInfo.state == this.$enums.RTC_STATE.FREE ||
rtcInfo.state == this.$enums.RTC_STATE.WAIT_ACCEPT) {
this.$refs.videoAcceptor.onRTCMessage(msg,friend)
} else {
this.$refs.privateVideo.onRTCMessage(msg)
}
return;
}
let chatInfo = { let chatInfo = {
type: 'PRIVATE', type: 'PRIVATE',
@ -180,13 +181,14 @@
// //
this.$store.commit("insertMessage", msg); this.$store.commit("insertMessage", msg);
// //
if (!msg.selfSend && msg.status != this.$enums.MESSAGE_STATUS.READED) { if (!msg.selfSend && this.$msgType.isNormal(msg.type) &&
msg.status != this.$enums.MESSAGE_STATUS.READED) {
this.playAudioTip(); this.playAudioTip();
} }
}, },
handleGroupMessage(msg) { handleGroupMessage(msg) {
// //
if (msg.type == this.$enums.MESSAGE_TYPE.LOADDING) { if (msg.type == this.$enums.MESSAGE_TYPE.LOADING) {
this.$store.commit("loadingGroupMsg", JSON.parse(msg.content)) this.$store.commit("loadingGroupMsg", JSON.parse(msg.content))
return; return;
} }
@ -214,12 +216,20 @@
} }
// //
msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id; msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id;
//
if (this.$msgType.isRtcGroup(msg.type)) {
this.$nextTick(() => {
this.$refs.rtcGroupVideo.onRTCMessage(msg);
})
return;
}
this.loadGroupInfo(msg.groupId).then((group) => { this.loadGroupInfo(msg.groupId).then((group) => {
// //
this.insertGroupMessage(group, msg); this.insertGroupMessage(group, msg);
}) })
}, },
insertGroupMessage(group, msg) { insertGroupMessage(group, msg) {
let chatInfo = { let chatInfo = {
type: 'GROUP', type: 'GROUP',
targetId: group.id, targetId: group.id,
@ -231,7 +241,8 @@
// //
this.$store.commit("insertMessage", msg); this.$store.commit("insertMessage", msg);
// //
if (!msg.selfSend && msg.status != this.$enums.MESSAGE_STATUS.READED) { if (!msg.selfSend && msg.type <= this.$enums.MESSAGE_TYPE.VIDEO &&
msg.status != this.$enums.MESSAGE_STATUS.READED) {
this.playAudioTip(); this.playAudioTip();
} }
}, },
@ -335,15 +346,16 @@
background-color: #19082f !important; background-color: #19082f !important;
padding: 0 !important; padding: 0 !important;
text-align: center; text-align: center;
.link { .link {
text-decoration: none; text-decoration: none;
&.router-link-active .icon { &.router-link-active .icon {
color: #ba785a; color: #ba785a;
} }
} }
.icon { .icon {
font-size: 26px !important; font-size: 26px !important;
color: #ddd; color: #ddd;
} }
@ -376,7 +388,7 @@
.icon { .icon {
font-size: 28px; font-size: 28px;
} }
&:hover { &:hover {
color: white; color: white;
} }

27
im-ui/src/view/Login.vue

@ -8,35 +8,20 @@
<li>加入uniapp移动端,支持移动端和web端同时在线多端消息同步</li> <li>加入uniapp移动端,支持移动端和web端同时在线多端消息同步</li>
<li>目前uniapp移动端支持安卓iosh5微信小程序</li> <li>目前uniapp移动端支持安卓iosh5微信小程序</li>
<li>聊天窗口支持粘贴截图@群成员已读未读显示</li> <li>聊天窗口支持粘贴截图@群成员已读未读显示</li>
<li>支持群聊已读显示(回执消息)</li> <li>语雀文档:
<li>语雀文档 <a href="https://www.yuque.com/u1475064/mufu2a" target="_blank">盒子IM详细介绍文档</a>
<a href="https://www.yuque.com/u1475064/mufu2a" target="_blank">盒子IM详细介绍文档</a>,目前限时免费开放中
</li> </li>
</ul> </ul>
</div> </div>
<div> <div>
<h3>最近更新(2024-03-17)</h3> <h3>最近更新(2024-06-22)</h3>
<ul> <ul>
<li>web端音视频功能优化:支持语音呼叫会话中加入通话状态消息</li> <li>群语音通话功能上线,且同时支持web端和uniapp端</li>
<li>uniapp端支持音视频通话并与web端打通</li> <li>音视频通话部分源码未开源可付费获取:
<li>uniapp端音视频源码通话源码暂未开源需付费获取: <a href="https://www.yuque.com/u1475064/mufu2a/vi7engzluty594s2" target="_blank">音视频源码购买说明</a>
<a href="https://www.yuque.com/u1475064/mufu2a/vi7engzluty594s2" target="_blank">uniapp端音视频通源码购买说明</a>
</li> </li>
</ul> </ul>
</div> </div>
<div>
<h3>最近更新(2024-03-31)</h3>
<ul>
<li>uniapp移动端支持发送语音消息</li>
</ul>
</div>
<div>
<h3>最近更新(2024-04-27)</h3>
<ul>
<li>uniapp端加载离线消息慢以及卡顿问题优化</li>
<li>web端样式风格调整</li>
</ul>
</div>
<div> <div>
<h3>如果本项目对您有帮助,请在gitee上帮忙点个star</h3> <h3>如果本项目对您有帮助,请在gitee上帮忙点个star</h3>
</div> </div>

71
im-uniapp/App.vue

@ -15,8 +15,6 @@
init() { init() {
// //
store.dispatch("load").then(() => { store.dispatch("load").then(() => {
//
this.initAudit();
// websocket // websocket
this.initWebSocket(); this.initWebSocket();
}).catch((e) => { }).catch((e) => {
@ -50,6 +48,7 @@
} }
}); });
wsApi.onClose((res) => { wsApi.onClose((res) => {
console.log("ws断开",res);
// 1000 // 1000
if (res.code != 1000) { if (res.code != 1000) {
// //
@ -82,7 +81,7 @@
}, },
handlePrivateMessage(msg) { handlePrivateMessage(msg) {
// //
if (msg.type == enums.MESSAGE_TYPE.LOADDING) { if (msg.type == enums.MESSAGE_TYPE.LOADING) {
store.commit("loadingPrivateMsg", JSON.parse(msg.content)) store.commit("loadingPrivateMsg", JSON.parse(msg.content))
return; return;
} }
@ -109,32 +108,32 @@
}, },
insertPrivateMessage(friend, msg) { insertPrivateMessage(friend, msg) {
// webrtc //
if (msg.type >= enums.MESSAGE_TYPE.RTC_CALL_VOICE && if (this.$msgType.isRtcPrivate(msg.type)) {
msg.type <= enums.MESSAGE_TYPE.RTC_CANDIDATE) {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// //
return; return;
// #endif // #endif
// //
let delayTime = 100;
if(msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE if(msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE
|| msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO){ || msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO){
let mode = msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO? "video":"voice"; let mode = msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO? "video":"voice";
let pages = getCurrentPages(); let pages = getCurrentPages();
let curPage = pages[pages.length-1].route; let curPage = pages[pages.length-1].route;
if(curPage != "pages/chat/chat-video"){ if(curPage != "pages/chat/chat-private-video"){
const friendInfo = encodeURIComponent(JSON.stringify(friend)); const friendInfo = encodeURIComponent(JSON.stringify(friend));
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/chat-video?mode=${mode}&friend=${friendInfo}&isHost=false` url: `/pages/chat/chat-private-video?mode=${mode}&friend=${friendInfo}&isHost=false`
}) })
delayTime = 500;
} }
} }
setTimeout(() => { setTimeout(() => {
uni.$emit('WS_RTC',msg); uni.$emit('WS_RTC_PRIVATE',msg);
},500) },delayTime)
return; return;
} }
let chatInfo = { let chatInfo = {
type: 'PRIVATE', type: 'PRIVATE',
targetId: friend.id, targetId: friend.id,
@ -146,12 +145,12 @@
// //
store.commit("insertMessage", msg); store.commit("insertMessage", msg);
// //
!msg.selfSend && this.playAudioTip(); this.playAudioTip();
}, },
handleGroupMessage(msg) { handleGroupMessage(msg) {
// //
if (msg.type == enums.MESSAGE_TYPE.LOADDING) { if (msg.type == enums.MESSAGE_TYPE.LOADING) {
store.commit("loadingGroupMsg",JSON.parse(msg.content)) store.commit("loadingGroupMsg",JSON.parse(msg.content))
return; return;
} }
@ -186,6 +185,35 @@
}, },
insertGroupMessage(group, msg) { insertGroupMessage(group, msg) {
//
if (this.$msgType.isRtcGroup(msg.type)) {
// #ifdef MP-WEIXIN
//
return;
// #endif
//
let delayTime = 100;
if(msg.type == enums.MESSAGE_TYPE.RTC_GROUP_SETUP){
let pages = getCurrentPages();
let curPage = pages[pages.length-1].route;
if(curPage != "pages/chat/chat-group-video"){
const userInfos = encodeURIComponent(msg.content);
const inviterId = msg.sendId;
const groupId = msg.groupId
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${groupId}&isHost=false
&inviterId=${inviterId}&userInfos=${userInfos}`
})
delayTime = 500;
}
}
// chat-group-video
setTimeout(() => {
uni.$emit('WS_RTC_GROUP',msg);
},delayTime)
return;
}
let chatInfo = { let chatInfo = {
type: 'GROUP', type: 'GROUP',
targetId: group.id, targetId: group.id,
@ -197,7 +225,7 @@
// //
store.commit("insertMessage", msg); store.commit("insertMessage", msg);
// //
!msg.selfSend && this.playAudioTip(); this.playAudioTip();
}, },
loadFriendInfo(id) { loadFriendInfo(id) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -251,21 +279,6 @@
return true; return true;
} }
return loginInfo.expireTime < new Date().getTime(); return loginInfo.expireTime < new Date().getTime();
},
initAudit() {
if (store.state.userStore.userInfo.type == 1) {
//
uni.setTabBarItem({
index: 2,
text: "群聊"
})
} else {
//
uni.setTabBarItem({
index: 2,
text: "搜索"
})
}
} }
}, },
onLaunch() { onLaunch() {

20
im-uniapp/common/enums.js

@ -5,14 +5,14 @@ const MESSAGE_TYPE = {
FILE:2, FILE:2,
AUDIO:3, AUDIO:3,
VIDEO:4, VIDEO:4,
RT_VOICE:5,
RT_VIDEO:6,
RECALL:10, RECALL:10,
READED:11, READED:11,
RECEIPT:12, RECEIPT:12,
TIP_TIME:20, TIP_TIME:20,
TIP_TEXT:21, TIP_TEXT:21,
LOADDING:30, LOADING:30,
ACT_RT_VOICE:40,
ACT_RT_VIDEO:41,
RTC_CALL_VOICE: 100, RTC_CALL_VOICE: 100,
RTC_CALL_VIDEO: 101, RTC_CALL_VIDEO: 101,
RTC_ACCEPT: 102, RTC_ACCEPT: 102,
@ -20,7 +20,19 @@ const MESSAGE_TYPE = {
RTC_CANCEL: 104, RTC_CANCEL: 104,
RTC_FAILED: 105, RTC_FAILED: 105,
RTC_HANDUP: 106, RTC_HANDUP: 106,
RTC_CANDIDATE: 107 RTC_CANDIDATE: 107,
RTC_GROUP_SETUP:200,
RTC_GROUP_ACCEPT:201,
RTC_GROUP_REJECT:202,
RTC_GROUP_FAILED:203,
RTC_GROUP_CANCEL:204,
RTC_GROUP_QUIT:205,
RTC_GROUP_INVITE:206,
RTC_GROUP_JOIN:207,
RTC_GROUP_OFFER:208,
RTC_GROUP_ANSWER:209,
RTC_GROUP_CANDIDATE:210,
RTC_GROUP_DEVICE:211
} }
const USER_STATE = { const USER_STATE = {

40
im-uniapp/common/messageType.js

@ -0,0 +1,40 @@
// 是否普通消息
let isNormal = function(type){
return type>=0 && type < 10;
}
// 是否状态消息
let isStatus = function(type){
return type>=10 && type < 20;
}
// 是否提示消息
let isTip = function(type){
return type>=20 && type < 30;
}
// 操作交互类消息
let isAction = function(type){
return type>=40 && type < 50;
}
// 单人通话信令
let isRtcPrivate = function(type){
return type>=100 && type < 300;
}
// 多人通话信令
let isRtcGroup = function(type){
return type>=200 && type < 400;
}
export {
isNormal,
isStatus,
isTip,
isAction,
isRtcPrivate,
isRtcGroup
}

19
im-uniapp/components/chat-message-item/chat-message-item.vue

@ -7,7 +7,7 @@
{{$date.toTimeText(msgInfo.sendTime)}} {{$date.toTimeText(msgInfo.sendTime)}}
</view> </view>
<view class="chat-msg-normal" v-if="msgInfo.type>=0 && msgInfo.type<10" <view class="chat-msg-normal" v-if="isNormal"
:class="{'chat-msg-mine':msgInfo.selfSend}"> :class="{'chat-msg-mine':msgInfo.selfSend}">
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage" <head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage"
:name="showName" :size="80"></head-image> :name="showName" :size="80"></head-image>
@ -52,13 +52,13 @@
<text v-if="audioPlayState=='PAUSE'" class="iconfont icon-play"></text> <text v-if="audioPlayState=='PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState=='PLAYING'" class="iconfont icon-pause"></text> <text v-if="audioPlayState=='PLAYING'" class="iconfont icon-pause"></text>
</view> </view>
<view class="chat-realtime chat-msg-text" v-if="isRTMessage" <view class="chat-realtime chat-msg-text" v-if="isAction"
@click="$emit('call')" @longpress="onShowMenu($event)"> @click="$emit('call')" @longpress="onShowMenu($event)">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE" class="iconfont icon-chat-voice"></text> <text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VOICE" class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO" class="iconfont icon-chat-video"></text> <text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VIDEO" class="iconfont icon-chat-video"></text>
<text>{{msgInfo.content}}</text> <text>{{msgInfo.content}}</text>
</view> </view>
<view class="chat-msg-status" v-if="!isRTMessage"> <view class="chat-msg-status" v-if="!isAction">
<text class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId <text class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status==$enums.MESSAGE_STATUS.READED">已读</text> && msgInfo.status==$enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId <text class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId
@ -225,9 +225,12 @@
} }
return items; return items;
}, },
isRTMessage() { isAction(){
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE || return this.$msgType.isAction(this.msgInfo.type);
this.msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO },
isNormal() {
const type = this.msgInfo.type;
return this.$msgType.isNormal(type) || this.$msgType.isAction(type)
} }
} }

174
im-uniapp/components/group-member-selector/group-member-selector.vue

@ -0,0 +1,174 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="chat-group-member-choose">
<view class="top-bar">
<view class="top-tip">选择成员</view>
<button class="top-btn" type="warn" size="mini" @click="onClean()">清空 </button>
<button class="top--btn" type="primary" size="mini" @click="onOk()">确定({{checkedIds.length}})
</button>
</view>
<scroll-view v-show="checkedIds.length>0" scroll-x="true" scroll-left="120">
<view class="checked-users">
<view v-for="m in members" v-show="m.checked" class="user-item">
<head-image :name="m.aliasName" :url="m.headImage" :size="60"></head-image>
</view>
</view>
</scroll-view>
<view class="search-bar">
<uni-search-bar v-model="searchText" cancelButton="none" placeholder="搜索"></uni-search-bar>
</view>
<view class="member-items">
<scroll-view class="scroll-bar" scroll-with-animation="true" scroll-y="true">
<view v-for="m in members" v-show="!m.quit && m.aliasName.startsWith(searchText)" :key="m.userId">
<view class="member-item" @click="onSwitchChecked(m)">
<head-image :name="m.aliasName" :online="m.online" :url="m.headImage"
:size="90"></head-image>
<view class="member-name">{{ m.aliasName}}</view>
<view class="member-checked">
<radio :checked="m.checked" :disabled="m.locked" @click.stop="onSwitchChecked(m)" />
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "chat-group-member-choose",
props: {
members: {
type: Array
},
maxSize: {
type: Number,
default: -1
}
},
data() {
return {
searchText: "",
};
},
methods: {
init(checkedIds, lockedIds) {
this.members.forEach((m) => {
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
});
},
open() {
this.$refs.popup.open();
},
onSwitchChecked(m) {
if (!m.locked) {
m.checked = !m.checked;
}
//
if (this.maxSize > 0 && this.checkedIds.length > this.maxSize) {
m.checked = false;
uni.showToast({
title: `最多选择${this.maxSize}位用户`,
icon: "none"
})
}
},
onClean() {
this.members.forEach((m) => {
if (!m.locked) {
m.checked = false;
}
})
},
onOk() {
this.$refs.popup.close();
this.$emit("complete", this.checkedIds)
},
isChecked(m) {
return this.checkedIds.indexOf(m.userId) >= 0;
}
},
computed: {
checkedIds() {
let ids = [];
this.members.forEach((m) => {
if (m.checked) {
ids.push(m.userId);
}
})
return ids;
}
}
}
</script>
<style lang="scss" scoped>
.chat-group-member-choose {
position: relative;
border: #dddddd solid 1rpx;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
border-radius: 15rpx;
.top-bar {
display: flex;
align-items: center;
height: 70rpx;
padding: 10rpx;
.top-tip {
flex: 1;
}
.top-btn {
margin-left: 10rpx;
}
}
.checked-users {
display: flex;
align-items: center;
height: 90rpx;
.user-item {
padding: 3rpx;
}
}
.member-items {
position: relative;
flex: 1;
overflow: hidden;
.member-item {
height: 120rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
.member-name {
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
.scroll-bar {
height: 800rpx;
}
}
}
</style>

89
im-uniapp/components/group-rtc-join/group-rtc-join.vue

@ -0,0 +1,89 @@
<template>
<uni-popup ref="popup" type="center">
<uni-popup-dialog mode="base" message="成功消息" :duration="2000" title="是否加入通话?" confirmText="加入"
@confirm="onOk">
<div class="group-rtc-join">
<div class="host-info">
<div>发起人</div>
<head-image :name="rtcInfo.host.nickName" :url="rtcInfo.host.headImage" :size="80"></head-image>
</div>
<div class="user-info">
<div>{{rtcInfo.userInfos.length+'人正在通话中'}}</div>
<scroll-view scroll-x="true" scroll-left="120">
<view class="user-list">
<view v-for="user in rtcInfo.userInfos" class="user-item">
<head-image :name="user.nickName" :url="user.headImage" :size="80"></head-image>
</view>
</view>
</scroll-view>
</div>
</div>
</uni-popup-dialog>
</uni-popup>
</template>
<script>
export default {
data() {
return {
rtcInfo: {}
}
},
props: {
groupId: {
type: Number
}
},
methods: {
open(rtcInfo) {
this.rtcInfo = rtcInfo;
this.$refs.popup.open();
},
onOk() {
let users = this.rtcInfo.userInfos;
let mine = this.$store.state.userStore.userInfo;
//
if(!users.find((user)=>user.id==mine.id)){
users.push({
id: mine.id,
nickName: mine.nickName,
headImage: mine.headImageThumb,
isCamera: false,
isMicroPhone: true
})
}
const userInfos = encodeURIComponent(JSON.stringify(users));
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${this.groupId}&isHost=false
&inviterId=${mine.id}&userInfos=${userInfos}`
})
}
}
}
</script>
<style lang="scss" scoped>
.group-rtc-join {
width: 100%;
.host-info {
font-size: 16px;
padding: 10px;
}
.user-info {
font-size: 16px;
padding: 10px;
}
.user-list {
display: flex;
align-items: center;
height: 90rpx;
.user-item {
padding: 3rpx;
}
}
}
</style>

118
im-uniapp/components/user-search/user-search.vue

@ -1,118 +0,0 @@
<template>
<!-- for wx audit -->
<view class="page user-search">
<view class="search-bar">
<uni-search-bar v-model="searchText" :focus="true" @confirm="onSearch()" can
cancelButton="none" ceholder="用户名/昵称"></uni-search-bar>
</view>
<view class="user-items">
<scroll-view class="scroll-bar" scroll-with-animation="true" scroll-y="true">
<view v-for="(user) in users" :key="user.id" v-show="user.id != $store.state.userStore.userInfo.id">
<view class="user-item">
<head-image :id="user.id" :name="user.nickName"
:online="user.online" :url="user.headImage"
:size="100"></head-image>
<view class="user-name">{{ user.nickName}}</view>
<view class="user-btns">
<button type="primary" v-show="!isFriend(user.id)" size="mini"
@click.stop="onAddFriend(user)">加为好友</button>
<button type="default" v-show="isFriend(user.id)" size="mini" disabled>已添加</button>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
searchText: "",
users: []
}
},
methods: {
onSearch() {
this.$http({
url: "/user/findByName?name=" + this.searchText,
method: "GET"
}).then((data) => {
this.users = data;
})
},
onAddFriend(user) {
this.$http({
url: "/friend/add?friendId=" + user.id,
method: "POST"
}).then((data) => {
let friend = {
id: user.id,
nickName: user.nickName,
headImage: user.headImage,
online: user.online
}
this.$store.commit("addFriend", friend);
uni.showToast({
title: "添加成功,对方已成为您的好友",
icon: "none"
})
})
},
onShowUserInfo(user) {
uni.navigateTo({
url: "/pages/common/user-info?id=" + user.id
})
},
isFriend(userId) {
let friends = this.$store.state.friendStore.friends;
let friend = friends.find((f) => f.id == userId);
return friend&&!friend.delete;
}
}
}
</script>
<style scoped lang="scss">
.user-search {
position: relative;
border: #dddddd solid 1px;
display: flex;
flex-direction: column;
.search-bar {
background: white;
}
.user-items{
position: relative;
flex: 1;
overflow: hidden;
.user-item {
height: 120rpx;
display: flex;
margin-bottom: 1rpx;
position: relative;
padding: 0 30rpx ;
align-items: center;
background-color: white;
white-space: nowrap;
.user-name {
flex:1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
.scroll-bar {
height: 100%;
}
}
}
</style>

13
im-uniapp/hybrid/html/rtc-group/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="favicon.ico">
<title>语音通话</title>
</head>
<body>
<div style="padding-top:10px; text-align: center;font-size: 16px;">音视频通话为付费功能,有需要请联系作者...</div>
</body>
</html>

0
im-uniapp/hybrid/html/index.html → im-uniapp/hybrid/html/rtc-private/index.html

2
im-uniapp/main.js

@ -4,6 +4,7 @@ import emotion from './common/emotion.js';
import * as enums from './common/enums.js'; import * as enums from './common/enums.js';
import * as date from './common/date'; import * as date from './common/date';
import * as socketApi from './common/wssocket'; import * as socketApi from './common/wssocket';
import * as messageType from './common/messageType';
import store from './store'; import store from './store';
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
// #ifdef H5 // #ifdef H5
@ -19,6 +20,7 @@ export function createApp() {
app.use(store); app.use(store);
app.config.globalProperties.$http = request; app.config.globalProperties.$http = request;
app.config.globalProperties.$wsApi = socketApi; app.config.globalProperties.$wsApi = socketApi;
app.config.globalProperties.$msgType = messageType;
app.config.globalProperties.$emo = emotion; app.config.globalProperties.$emo = emotion;
app.config.globalProperties.$enums = enums; app.config.globalProperties.$enums = enums;
app.config.globalProperties.$date = date; app.config.globalProperties.$date = date;

2
im-uniapp/manifest.json

@ -100,7 +100,7 @@
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wxda94f40bfad0262c", "appid" : "wxda94f40bfad0262c",
"libVersion": "latest", "libVersion" : "latest",
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },

6
im-uniapp/pages.json

@ -17,7 +17,9 @@
}, { }, {
"path": "pages/chat/chat-box" "path": "pages/chat/chat-box"
},{ },{
"path": "pages/chat/chat-video" "path": "pages/chat/chat-private-video"
},{
"path": "pages/chat/chat-group-video"
}, { }, {
"path": "pages/friend/friend-add" "path": "pages/friend/friend-add"
}, { }, {
@ -61,7 +63,7 @@
"pagePath": "pages/group/group", "pagePath": "pages/group/group",
"iconPath": "static/tarbar/group.png", "iconPath": "static/tarbar/group.png",
"selectedIconPath": "static/tarbar/group_active.png", "selectedIconPath": "static/tarbar/group_active.png",
"text": "搜索" "text": "群聊"
}, },
{ {
"pagePath": "pages/mine/mine", "pagePath": "pages/mine/mine",

130
im-uniapp/pages/chat/chat-box.vue

@ -6,14 +6,13 @@
<uni-icons class="btn-side right" type="more-filled" size="30" @click="onShowMore()"></uni-icons> <uni-icons class="btn-side right" type="more-filled" size="30" @click="onShowMore()"></uni-icons>
</view> </view>
<view class="chat-msg" @click="switchChatTabBox('none',true)"> <view class="chat-msg" @click="switchChatTabBox('none',true)">
<scroll-view class="scroll-box" scroll-y="true" <scroll-view class="scroll-box" scroll-y="true" upper-threshold="200" @scrolltoupper="onScrollToTop"
upper-threshold="200" @scrolltoupper="onScrollToTop" :scroll-into-view="'chat-item-'+scrollMsgIdx">
:scroll-into-view="'chat-item-'+scrollMsgIdx"> <view v-if="chat" v-for="(msgInfo,idx) in chat.messages" :key="idx">
<view v-for="(msgInfo,idx) in chat.messages" :key="idx"> <chat-message-item v-if="idx>=showMinIdx&&!msgInfo.delete" :headImage="headImage(msgInfo)"
<chat-message-item v-if="idx>=showMinIdx&&!msgInfo.delete" :headImage="headImage(msgInfo)" @call="onRtCall(msgInfo)" @call="onRtCall(msgInfo)" :showName="showName(msgInfo)" @recall="onRecallMessage"
:showName="showName(msgInfo)" @recall="onRecallMessage" @delete="onDeleteMessage" @delete="onDeleteMessage" @longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile"
@longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile" :id="'chat-item-'+idx" :id="'chat-item-'+idx" :msgInfo="msgInfo" :groupMembers="groupMembers">
:msgInfo="msgInfo" :groupMembers="groupMembers">
</chat-message-item> </chat-message-item>
</view> </view>
</scroll-view> </scroll-view>
@ -31,14 +30,14 @@
<view class="send-bar"> <view class="send-bar">
<view v-if="!showRecord" class="iconfont icon-voice-circle" @click="onRecorderInput()"></view> <view v-if="!showRecord" class="iconfont icon-voice-circle" @click="onRecorderInput()"></view>
<view v-else class="iconfont icon-keyboard" @click="onKeyboardInput()"></view> <view v-else class="iconfont icon-keyboard" @click="onKeyboardInput()"></view>
<chat-record v-if="showRecord" class="chat-record" @send="onSendRecord" ></chat-record> <chat-record v-if="showRecord" class="chat-record" @send="onSendRecord"></chat-record>
<view v-else class="send-text"> <view v-else class="send-text">
<textarea class="send-text-area" v-model="sendText" auto-height :show-confirm-bar="false" <textarea class="send-text-area" v-model="sendText" auto-height :show-confirm-bar="false"
:placeholder="isReceipt?'[回执消息]':''" :adjust-position="false" @confirm="sendTextMessage()" :placeholder="isReceipt?'[回执消息]':''" :adjust-position="false" @confirm="sendTextMessage()"
@keyboardheightchange="onKeyboardheightchange" @input="onTextInput" confirm-type="send" confirm-hold @keyboardheightchange="onKeyboardheightchange" @input="onTextInput" confirm-type="send" confirm-hold
:hold-keyboard="true"></textarea> :hold-keyboard="true"></textarea>
</view> </view>
<view v-if="chat.type=='GROUP'" class="iconfont icon-at" @click="openAtBox()"></view> <view v-if="chat && chat.type=='GROUP'" class="iconfont icon-at" @click="openAtBox()"></view>
<view class="iconfont icon-icon_emoji" @click="onShowEmoChatTab()"></view> <view class="iconfont icon-icon_emoji" @click="onShowEmoChatTab()"></view>
<view v-if="sendText==''" class="iconfont icon-add" @click="onShowToolsChatTab()"> <view v-if="sendText==''" class="iconfont icon-add" @click="onShowToolsChatTab()">
</view> </view>
@ -82,11 +81,15 @@
</view> </view>
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<!-- 音视频不支持小程序 --> <!-- 音视频不支持小程序 -->
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVideoCall()"> <view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVideo()">
<view class="tool-icon iconfont icon-video"></view> <view class="tool-icon iconfont icon-video"></view>
<view class="tool-name">视频通话</view> <view class="tool-name">视频通话</view>
</view> </view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVoiceCall()"> <view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVoice()">
<view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view>
</view>
<view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="onGroupVideo()">
<view class="tool-icon iconfont icon-call"></view> <view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view> <view class="tool-name">语音通话</view>
</view> </view>
@ -101,8 +104,14 @@
</scroll-view> </scroll-view>
<view v-if="showKeyBoard"></view> <view v-if="showKeyBoard"></view>
</view> </view>
<!-- @用户时选择成员 -->
<chat-at-box ref="atBox" :ownerId="group.ownerId" :members="groupMembers" <chat-at-box ref="atBox" :ownerId="group.ownerId" :members="groupMembers"
@complete="onAtComplete"></chat-at-box> @complete="onAtComplete"></chat-at-box>
<!-- 群语音通话时选择成员 -->
<group-member-selector ref="selBox" :members="groupMembers"
:maxSize="$store.state.configStore.webrtc.maxChannel"
@complete="onInviteOk"></group-member-selector>
<group-rtc-join ref="rtcJoin" :groupId="group.id"></group-rtc-join>
</view> </view>
</template> </template>
@ -124,17 +133,18 @@
keyboardHeight: 322, keyboardHeight: 322,
atUserIds: [], atUserIds: [],
recordText: "", recordText: "",
needScrollToBottom: false, //
showMinIdx: 0 // showMinIdx showMinIdx: 0 // showMinIdx
} }
}, },
methods: { methods: {
onRecorderInput() { onRecorderInput() {
this.showRecord = true; this.showRecord = true;
this.switchChatTabBox('none',true); this.switchChatTabBox('none', true);
}, },
onKeyboardInput() { onKeyboardInput() {
this.showRecord = false; this.showRecord = false;
this.switchChatTabBox('none',false); this.switchChatTabBox('none', false);
}, },
onSendRecord(data) { onSendRecord(data) {
let msgInfo = { let msgInfo = {
@ -161,26 +171,66 @@
// //
this.scrollToBottom(); this.scrollToBottom();
this.isReceipt = false; this.isReceipt = false;
}) })
}, },
onRtCall(msgInfo) { onRtCall(msgInfo) {
if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE) { if (msgInfo.type == this.$enums.MESSAGE_TYPE.ACT_RT_VOICE) {
this.onVoiceCall(); this.onPriviteVoice();
} else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO) { } else if (msgInfo.type == this.$enums.MESSAGE_TYPE.ACT_RT_VIDEO) {
this.onVideoCall(); this.onPriviteVideo();
} }
}, },
onVideoCall() { onPriviteVideo() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/chat-video?mode=video&friend=${friendInfo}&isHost=true` url: `/pages/chat/chat-private-video?mode=video&friend=${friendInfo}&isHost=true`
}) })
}, },
onVoiceCall() { onPriviteVoice() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/chat-video?mode=voice&friend=${friendInfo}&isHost=true` url: `/pages/chat/chat-private-video?mode=voice&friend=${friendInfo}&isHost=true`
})
},
onGroupVideo() {
this.$http({
url: "/webrtc/group/info?groupId="+this.group.id,
method: 'GET'
}).then((rtcInfo)=>{
if(rtcInfo.isChating){
//
this.$refs.rtcJoin.open(rtcInfo);
}else {
//
let ids = [this.mine.id];
this.$refs.selBox.init(ids, ids);
this.$refs.selBox.open();
}
})
},
onInviteOk(ids) {
if(ids.length < 2){
return;
}
let users = [];
ids.forEach(id => {
let m = this.groupMembers.find(m => m.userId == id);
// ,url
users.push({
id: m.userId,
nickName: m.aliasName,
headImage: m.headImage,
isCamera: false,
isMicroPhone: true
})
})
const groupId = this.group.id;
const inviterId = this.mine.id;
const userInfos = encodeURIComponent(JSON.stringify(users));
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${groupId}&isHost=true
&inviterId=${inviterId}&userInfos=${userInfos}`
}) })
}, },
moveChatToTop() { moveChatToTop() {
@ -302,13 +352,13 @@
}); });
}, },
onShowEmoChatTab(){ onShowEmoChatTab() {
this.showRecord = false; this.showRecord = false;
this.switchChatTabBox('emo',true) this.switchChatTabBox('emo', true)
}, },
onShowToolsChatTab(){ onShowToolsChatTab() {
this.showRecord = false; this.showRecord = false;
this.switchChatTabBox('tools',true) this.switchChatTabBox('tools', true)
}, },
switchChatTabBox(chatTabBox, hideKeyBoard) { switchChatTabBox(chatTabBox, hideKeyBoard) {
this.chatTabBox = chatTabBox; this.chatTabBox = chatTabBox;
@ -496,11 +546,11 @@
}); });
}, },
onScrollToTop() { onScrollToTop() {
if(this.showMinIdx==0){ if (this.showMinIdx == 0) {
console.log("消息已滚动到顶部") console.log("消息已滚动到顶部")
return; return;
} }
// #ifndef H5 // #ifndef H5
// //
this.scrollToMsgIdx(this.showMinIdx); this.scrollToMsgIdx(this.showMinIdx);
@ -541,7 +591,8 @@
}); });
}, },
readedMessage() { readedMessage() {
if(this.unreadCount == 0){ console.log("readedMessage")
if (this.unreadCount == 0) {
return; return;
} }
let url = "" let url = ""
@ -642,7 +693,14 @@
messageSize: function(newSize, oldSize) { messageSize: function(newSize, oldSize) {
// //
if (newSize > oldSize) { if (newSize > oldSize) {
this.scrollToBottom(); console.log("messageSize",newSize,oldSize)
let pages = getCurrentPages();
let curPage = pages[pages.length-1].route;
if(curPage == "pages/chat/chat-box"){
this.scrollToBottom();
}else {
this.needScrollToBottom = true;
}
} }
}, },
unreadCount: { unreadCount: {
@ -673,11 +731,16 @@
this.$store.commit("activeChat", options.chatIdx); this.$store.commit("activeChat", options.chatIdx);
// //
this.isReceipt = false; this.isReceipt = false;
//
this.scrollToBottom();
}, },
onUnload() { onUnload() {
this.$store.commit("activeChat", -1); this.$store.commit("activeChat", -1);
},
onShow(){
if(this.needScrollToBottom){
//
this.scrollToBottom();
this.needScrollToBottom = false;
}
} }
} }
</script> </script>
@ -718,7 +781,6 @@
} }
} }
.chat-msg { .chat-msg {
flex: 1; flex: 1;
padding: 0; padding: 0;

144
im-uniapp/pages/chat/chat-group-video.vue

@ -0,0 +1,144 @@
<template>
<view class="page chat-group-video">
<view>
<web-view id="chat-video-wv" @message="onMessage" :src="url"></web-view>
</view>
</view>
</template>
<script>
import UNI_APP from '@/.env.js'
export default {
data() {
return {
url: "",
wv: '',
isHost: false,
groupId: null,
inviterId: null,
userInfos: []
}
},
methods: {
onMessage(e) {
this.onWebviewMessage(e.detail.data[0]);
},
onInsertMessage(msgInfo) {
},
onWebviewMessage(event) {
console.log("来自webview的消息:" + JSON.stringify(event))
switch (event.key) {
case "WV_READY":
this.initWebView();
break;
case "WV_CLOSE":
uni.navigateBack();
break;
case "INSERT_MESSAGE":
this.onInsertMessage(event.data);
break;
}
},
sendMessageToWebView(key, message) {
// webview100ms
if (!this.wv) {
setTimeout(() => this.sendMessageToWebView(key, message), 100)
return;
}
let event = {
key: key,
data: message
}
// #ifdef APP-PLUS
this.wv.evalJS(`onEvent('${encodeURIComponent(JSON.stringify(event))}')`)
// #endif
// #ifdef H5
this.wv.postMessage(event, '*');
// #endif
},
initWebView() {
// #ifdef APP-PLUS
// APPwebview
this.wv = this.$scope.$getAppWebview().children()[0]
// #endif
// #ifdef H5
// H5webviewiframe
this.wv = document.getElementById('chat-video-wv').contentWindow
// #endif
},
initUrl() {
this.url = "/hybrid/html/rtc-group/index.html?";
this.url += "baseUrl=" + UNI_APP.BASE_URL;
this.url += "&groupId=" + this.groupId;
this.url += "&userId=" + this.$store.state.userStore.userInfo.id;
this.url += "&inviterId=" + this.inviterId;
this.url += "&isHost=" + this.isHost;
this.url += "&loginInfo=" + JSON.stringify(uni.getStorageSync("loginInfo"));
this.url += "&userInfos=" + JSON.stringify(this.userInfos);
this.url += "&config=" + JSON.stringify(this.$store.state.configStore.webrtc);
},
},
onBackPress() {
console.log("onBackPress")
this.sendMessageToWebView("NAV_BACK", {})
},
onLoad(options) {
uni.$on('WS_RTC_GROUP', msg => {
// web-view
this.sendMessageToWebView("RTC_MESSAGE", msg);
})
// #ifdef H5
window.onmessage = (e) => {
this.onWebviewMessage(e.data.data.arg);
}
// #endif
//
this.isHost = JSON.parse(options.isHost);
//
this.inviterId = options.inviterId;
//
this.groupId = options.groupId;
//
this.userInfos = JSON.parse(decodeURIComponent(options.userInfos));
// url
this.initUrl();
},
onUnload() {
uni.$off('WS_RTC_GROUP')
}
}
</script>
<style lang="scss" scoped>
.chat-group-video {
.header {
display: flex;
justify-content: center;
align-items: center;
height: 60rpx;
padding: 5px;
background-color: white;
line-height: 50px;
font-size: 40rpx;
font-weight: 600;
border: #dddddd solid 1px;
.btn-side {
position: absolute;
line-height: 60rpx;
font-size: 28rpx;
cursor: pointer;
&.left {
left: 30rpx;
}
&.right {
right: 30rpx;
}
}
}
}
</style>

24
im-uniapp/pages/chat/chat-video.vue → im-uniapp/pages/chat/chat-private-video.vue

@ -1,6 +1,6 @@
<template> <template>
<view class="page chat-video"> <view class="page chat-private-video">
<web-view id="chat-video-wv" @message="onMessage" :src="url"></web-view> <web-view id="chat-video-wv" @message="onMessage" :src="url"></web-view>
</view> </view>
</template> </template>
@ -20,16 +20,6 @@
onMessage(e) { onMessage(e) {
this.onWebviewMessage(e.detail.data[0]); this.onWebviewMessage(e.detail.data[0]);
}, },
onInsertMessage(msgInfo){
let chat = {
type: 'PRIVATE',
targetId: this.friend.id,
showName: this.friend.nickName,
headImage: this.friend.headImage,
};
this.$store.commit("openChat",chat);
this.$store.commit("insertMessage", msgInfo);
},
onWebviewMessage(event) { onWebviewMessage(event) {
console.log("来自webview的消息:" + JSON.stringify(event)) console.log("来自webview的消息:" + JSON.stringify(event))
switch (event.key) { switch (event.key) {
@ -39,9 +29,6 @@
case "WV_CLOSE": case "WV_CLOSE":
uni.navigateBack(); uni.navigateBack();
break; break;
case "INSERT_MESSAGE":
this.onInsertMessage(event.data);
break;
} }
}, },
sendMessageToWebView(key, message) { sendMessageToWebView(key, message) {
@ -72,20 +59,21 @@
// #endif // #endif
}, },
initUrl(){ initUrl(){
this.url = "/hybrid/html/index.html"; this.url = "/hybrid/html/rtc-private/index.html";
this.url += "?mode="+this.mode; this.url += "?mode="+this.mode;
this.url += "&isHost="+this.isHost; this.url += "&isHost="+this.isHost;
this.url += "&baseUrl="+UNI_APP.BASE_URL; this.url += "&baseUrl="+UNI_APP.BASE_URL;
this.url += "&loginInfo="+JSON.stringify(uni.getStorageSync("loginInfo")); this.url += "&loginInfo="+JSON.stringify(uni.getStorageSync("loginInfo"));
this.url += "&userInfo="+JSON.stringify(this.$store.state.userStore.userInfo); this.url += "&userInfo="+JSON.stringify(this.$store.state.userStore.userInfo);
this.url += "&friend="+JSON.stringify(this.friend); this.url += "&friend="+JSON.stringify(this.friend);
this.url += "&config=" + JSON.stringify(this.$store.state.configStore.webrtc);
}, },
}, },
onBackPress() { onBackPress() {
this.sendMessageToWebView("NAV_BACK",{}) this.sendMessageToWebView("NAV_BACK",{})
}, },
onLoad(options) { onLoad(options) {
uni.$on('WS_RTC', msg => { uni.$on('WS_RTC_PRIVATE', msg => {
// web-view // web-view
this.sendMessageToWebView("RTC_MESSAGE", msg); this.sendMessageToWebView("RTC_MESSAGE", msg);
}) })
@ -104,7 +92,7 @@
this.initUrl(); this.initUrl();
}, },
onUnload() { onUnload() {
uni.$off('WS_RTC') uni.$off('WS_RTC_PRIVATE')
} }
} }
</script> </script>

6
im-uniapp/pages/group/group.vue

@ -1,5 +1,5 @@
<template> <template>
<view v-if="$store.state.userStore.userInfo.type == 1" class="tab-page group"> <view class="tab-page group">
<view class="nav-bar"> <view class="nav-bar">
<view class="nav-search"> <view class="nav-search">
<uni-search-bar @focus="onFocusSearch" cancelButton="none" placeholder="点击搜索群聊"></uni-search-bar> <uni-search-bar @focus="onFocusSearch" cancelButton="none" placeholder="点击搜索群聊"></uni-search-bar>
@ -19,10 +19,6 @@
</scroll-view> </scroll-view>
</view> </view>
</view> </view>
<!-- wx audit -->
<view v-else>
<user-search></user-search>
</view>
</template> </template>
<script> <script>

19
im-uniapp/ssl/cert.crt

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDIjCCAgoCCQCw2aUcFWVX4jANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJj
bjEMMAoGA1UECAwDZ3oIMQswCQYDVQQHDAJnejEcMBoGA1UECgwTRGVmYXVsdCBD
b21wYW55IEx0ZDELMAkGA1UEAwwCYngwHhcNMjQwNDI4MTQzNTIzWhcNMzQwNDI2
MTQzNTIzWjBTMQswCQYDVQQGEwJjbjEMMAoGA1UECAwDZ3oIMQswCQYDVQQHDAJn
ejEcMBoGA1UECgwTRGVmYXVsdCBDb21wYW55IEx0ZDELMAkGA1UEAwwCYngwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqZXvAXwH0xA5TSposclmZXxox
pfT5F0eSOaxRE2NFfUbHoCrCHYV8pPAIy9S6vbG5Bbh4eprv4smH4lHWfa+81nI8
sKizmJ3jdFSzCRrIHzbdlQsY0Vg+VasyoWjyjVDJeDzz/G/vQUeb19+kXlHVDETt
J7sZEqNyDxewsiDBUf2f+fvsgtIWakuD7CGe/P9e6gHz0D++GezOUKgUtL3eUkCa
pI8+ecoAG1ud/3MtRvGyq9FwwsQwsscu1YVmt7fRhuGbcM3/bog1VXe/to/msKUC
gCZjWS82D9sw0ikEAn7jagKJu1ezybmN9/JljhpC8UgZnqPT01LzfFvDECN7AgMB
AAEwDQYJKoZIhvcNAQELBQADggEBAMnjP0ANnPSTbwSCufVXwJgX5tWcSGjezFAY
Du+rbdUipn2O4/NCkTTPpDbrDKRET2zDUrxJOXu/UZBS8lreowtUQCk8lX7kH5oj
72lmcOFgWUyk8ULTPzrl0sdaQ8mhsvf+vHO9Ww/+RqQlzlr+eMuamMm1wDrbczWK
z1tq2QQuIhxf1pIznHag5eWui6Z0RIRQaozbXWU6VuSf703CNixxdZsdNWHpdiJW
vj8LewFaSmGp6HzwMX6/Kx/kocqpeeCZ6CharePv2C5bC5Kd5KVFCHnp5xbcZKUq
8Q7CSH5WKV3QkoFKGPz1qh17qeryxgoqLQXLapptNKOS76QBivM=
-----END CERTIFICATE-----

27
im-uniapp/ssl/cert.key

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA6mV7wF8B9MQOU0qaLHJZmV8aMaX0+RdHkjmsURNjRX1Gx6Aq
wh2FfKTwCMvUur2xuQW4eHqa7+LJh+JR1n2vvNZyPLCos5id43RUswkayB823ZUL
GNFYPlWrMqFo8o1QyXg88/xv70FHm9ffpF5R1QxE7Se7GRKjcg8XsLIgwVH9n/n7
7ILSFmpLg+whnvz/XuoB89A/vhnszlCoFLS93lJAmqSPPnnKABtbnf9zLUbxsqvR
cMLEMLLHLtWFZre30Ybhm3DN/26INVV3v7aP5rClAoAmY1kvNg/bMNIpBAJ+42oC
ibtXs8m5jffyZY4aQvFIGZ6j09NS83xbwxAjewIDAQABAoIBAQC8uyPunFE15Rrn
w9zpxtUQIjw0F71tR2pQefGegm7fR+TS3csv65dM6n1/h6ftCMpuAQYstAPG/aNp
rzhX7XGwKjdnWJMtsMgImeWNFtqiokeKGPULcZyM7GvhY4feLR0Ma60gg3UZf0WK
XUJs1aksUym4jtIeeRxzvWVE19h57uEG1fJM3Rf0OFhb1iUYVjtW4iW+RTtc1mpb
hoda+8P3Tua33WuhSYtFusDx35ZM2WDgYlgeMxm94JUFUUOIhiggasYNsu1YmQl4
AqhRncGn6p/gZVQsjkeCtmTIyD+igqulI/OkqI3DmFCzFSoSXLFE7HZ4pL500Vxn
aOvOYRCZAoGBAP1Aopr0scpLzt7Lei+dbLc3wxziCyeNtVDvswFS93Lx1bnSJw4m
0PAvQGoOdeiPI1vmsdDJdV5R0Vmbybyz7JPiTyUyti4p909s5AtpPqdLptjuO2ge
2b1YD/HnubL0omlejKu5fKg3zaPqhr/Z8f6WfYSsm1dV10arSBj4JdvVAoGBAOzw
epHXXnAfaC/cEOUOOVe5o/MNxIYYfJkG6VtmB3v+oY0/C+SyUbkY3Qu5yjCDBYhP
rLVr1+TiLE3Sqj+ndRvICy8T6Iv+hA2ijvJiNVAjtqkwM5YOMJdFYI6fem1N+Hkv
ipOQUWFmwUBAKQm4BSGtNdbL89KTTV1tMubH4joPAoGBANRGmkWSd3gepO8A1ZEV
vmuw1N3f5wOnd2S5Fm00su9pIAGa0lu9U4MPyEldh52AZV4B9+gPBU8i+3zF5YpD
sjifCEIgyK3XRVIQ7vFVrUujUN4iii8TNOXN68eTuYb0ITJ7KyRB3OhPphIQYhRr
xbjlQZ6045yH+mNk7JDpZyplAoGAVF4sxtGRZwtH5gLOYUF3Wa1Ym6tDVxxRAYxc
e5cRAy3gCJNygLSeNPKNgydcv3ln9umn7dHAxldivzNMO+483O+WS+Ui4PZ3vwMr
M1OU+Dw/Rm9LbxsOYk7p2t8ekN06pKwxA+pXj/8uwNoXwsYrzZoHmbx1zX12BtZj
UZnLDDECgYBpvFK+cntSzE+qpsvxYnosSswcJvmGoOzBCE2aWebwXp0QOwjg/Zh/
VR5Mc8L8xHpcpUJZXaTmyeouwc2XPfBvvbWlGZFh7zBn2dKCNxT62fPXKFX2rBgE
k4f033ToXD6Lv0JT94JfjS0GB+zzHjfcS/K8Lr3d3lUmkiI1LFD5GA==
-----END RSA PRIVATE KEY-----

14
im-uniapp/store/chatStore.js

@ -51,7 +51,6 @@ export default {
} }
}) })
}) })
console.log(cacheChats.length)
}, },
openChat(state, chatInfo) { openChat(state, chatInfo) {
let chats = this.getters.findChats(); let chats = this.getters.findChats();
@ -172,9 +171,9 @@ export default {
chat.lastContent = "[语音]"; chat.lastContent = "[语音]";
} else if (msgInfo.type == MESSAGE_TYPE.TEXT || msgInfo.type == MESSAGE_TYPE.RECALL) { } else if (msgInfo.type == MESSAGE_TYPE.TEXT || msgInfo.type == MESSAGE_TYPE.RECALL) {
chat.lastContent = msgInfo.content; chat.lastContent = msgInfo.content;
} else if (msgInfo.type == MESSAGE_TYPE.RT_VOICE) { } else if (msgInfo.type == MESSAGE_TYPE.ACT_RT_VOICE) {
chat.lastContent = "[语音通话]"; chat.lastContent = "[语音通话]";
} else if (msgInfo.type == MESSAGE_TYPE.RT_VIDEO) { } else if (msgInfo.type == MESSAGE_TYPE.ACT_RT_VIDEO) {
chat.lastContent = "[视频通话]"; chat.lastContent = "[视频通话]";
} }
chat.lastSendTime = msgInfo.sendTime; chat.lastSendTime = msgInfo.sendTime;
@ -276,14 +275,14 @@ export default {
} }
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
loadingPrivateMsg(state, loadding) { loadingPrivateMsg(state, loading) {
state.loadingPrivateMsg = loadding; state.loadingPrivateMsg = loading;
if (!this.getters.isLoading()) { if (!this.getters.isLoading()) {
this.commit("refreshChats") this.commit("refreshChats")
} }
}, },
loadingGroupMsg(state, loadding) { loadingGroupMsg(state, loading) {
state.loadingGroupMsg = loadding; state.loadingGroupMsg = loading;
if (!this.getters.isLoading()) { if (!this.getters.isLoading()) {
this.commit("refreshChats") this.commit("refreshChats")
} }
@ -295,7 +294,6 @@ export default {
}); });
// 将消息一次性装载回来 // 将消息一次性装载回来
state.chats = cacheChats; state.chats = cacheChats;
console.log(cacheChats.length)
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
saveToStorage(state) { saveToStorage(state) {

32
im-uniapp/store/configStore.js

@ -0,0 +1,32 @@
import http from '../common/request'
export default {
state: {
webrtc: {}
},
mutations: {
setConfig(state, config) {
state.webrtc = config.webrtc;
},
clear(state){
state.webrtc = {};
}
},
actions:{
loadConfig(context){
return new Promise((resolve, reject) => {
http({
url: '/system/config',
method: 'GET'
}).then((config) => {
console.log("系统配置",config)
context.commit("setConfig",config);
resolve();
}).catch((res)=>{
reject(res);
});
})
}
}
}

10
im-uniapp/store/index.js

@ -2,15 +2,16 @@ import chatStore from './chatStore.js';
import friendStore from './friendStore.js'; import friendStore from './friendStore.js';
import userStore from './userStore.js'; import userStore from './userStore.js';
import groupStore from './groupStore.js'; import groupStore from './groupStore.js';
import { import configStore from './configStore.js';
createStore import { createStore } from 'vuex';
} from 'vuex';
const store = createStore({ const store = createStore({
modules: { modules: {
chatStore, chatStore,
friendStore, friendStore,
userStore, userStore,
groupStore groupStore,
configStore
}, },
state: {}, state: {},
actions: { actions: {
@ -20,6 +21,7 @@ const store = createStore({
promises.push(this.dispatch("loadFriend")); promises.push(this.dispatch("loadFriend"));
promises.push(this.dispatch("loadGroup")); promises.push(this.dispatch("loadGroup"));
promises.push(this.dispatch("loadChat")); promises.push(this.dispatch("loadChat"));
promises.push(this.dispatch("loadConfig"));
return Promise.all(promises); return Promise.all(promises);
}) })
}, },

3
im-uniapp/store/userStore.js

@ -5,6 +5,9 @@ import http from '../common/request'
export default { export default {
state: { state: {
userInfo: {}, userInfo: {},
config:{
webrtc:{}
},
state: USER_STATE.FREE state: USER_STATE.FREE
}, },

10
im-uniapp/vite.config.js

@ -1,6 +1,7 @@
import { defineConfig } from "vite" import { defineConfig } from "vite"
import uni from "@dcloudio/vite-plugin-uni"; import uni from "@dcloudio/vite-plugin-uni";
const path = require('path')
const fs = require('fs')
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
uni() uni()
@ -14,6 +15,11 @@ export default defineConfig({
changeOrigin: true changeOrigin: true
}, },
} },
// 音视频功能需要ssl证书,如需调试请打开注释
// https: {
// cert: fs.readFileSync(path.join(__dirname, 'ssl/cert.crt')),
// key: fs.readFileSync(path.join(__dirname, 'ssl/cert.key'))
// }
} }
}) })
Loading…
Cancel
Save