committed by
Gitee
96 changed files with 5030 additions and 2032 deletions
@ -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 { |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -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)); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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); |
|||
|
|||
} |
|||
@ -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); |
|||
}; |
|||
} |
|||
@ -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<>(); |
|||
|
|||
|
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
@ -0,0 +1,3 @@ |
|||
import Vue from 'vue'; |
|||
|
|||
export default new Vue(); |
|||
@ -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 |
|||
} |
|||
@ -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; |
|||
@ -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; |
|||
@ -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; |
|||
Binary file not shown.
File diff suppressed because it is too large
@ -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_CALL是主动呼叫弹出,ACCEPTED是被呼叫接受后弹出 |
|||
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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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); |
|||
}); |
|||
}) |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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,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) { |
|||
// 如果webview还没初始化好,则延迟100ms再推送 |
|||
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 |
|||
// APP的webview |
|||
this.wv = this.$scope.$getAppWebview().children()[0] |
|||
// #endif |
|||
// #ifdef H5 |
|||
// H5的webview就是iframe |
|||
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> |
|||
@ -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----- |
|||
@ -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----- |
|||
@ -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); |
|||
}); |
|||
}) |
|||
} |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue