# 网易云信 呼叫组件 API

基于云信 NIM V10 SDK（`nim-web-sdk-ng`）封装的一对一音视频呼叫组件，支持 Web 端和微信小程序端。

## 构建与验证平台

- 对外构建入口：`cd Electron && npm run build:web-callkit-sdk`
- 正式发布产物命令：`cd Electron && npm run package:web-callkit-sdk`
- 正式产物输出：`Electron/out/release-artifacts/xkit-yx-call-kit-<version>.tgz`
- 包内实现入口：`cd Web/call-kit && npm run build`
- 验证平台：`Web/basic-react` 与 `Web/basic-vue3`
- 当前正式 `build` 会生成 `dist/`、`dist/types/` 与 `miniprogram_dist/`

## 正式 consumer 安装矩阵

如果你只接 SDK，不接 UIKit，正式 consumer 只需要：

- `@xkit-yx/call-kit`
- `nim-web-sdk-ng`
- `nertc-web-sdk`

如果你要接 UIKit，则在上面基础上再加对应 UIKit 包和框架 peer：

- React：`@xkit-yx/call-kit-react-ui` + `react` + `react-dom`
- Vue3：`@xkit-yx/call-kit-vue3-ui` + `vue`

registry 安装示意：

```bash
npm install @xkit-yx/call-kit nim-web-sdk-ng nertc-web-sdk
npm install @xkit-yx/call-kit-react-ui react react-dom
```

tarball 安装示意：

```bash
npm install ./xkit-yx-call-kit-<version>.tgz nim-web-sdk-ng nertc-web-sdk
```

补充说明：

- 当前正式包不要求客户理解 monorepo 内部 `packages/callkit-*` 结构
- 当前正式包不要求客户必须使用 `umi` 或 `vite`
- `Web/basic-react` / `Web/basic-vue3` 的宿主框架，只属于 example 验证平台自身要求

## 正式包边界与 example 宿主边界

这份 SDK README 只描述正式对外 contract：

- `@xkit-yx/call-kit` 的安装要求
- `setup/call/accept/hangup` 等运行 API
- 与 UIKit 配合时的最小接入链路

它不把以下内容当作正式接入门槛：

- `Web/basic-react` 的 `umi` 宿主要求
- `Web/basic-vue3` 的 `vite` 宿主要求
- demo shell、调试面板和 example 页面结构

如果客户只是把 SDK 接到自己原有 Web 项目里，不需要复制 example 的宿主工程结构。

## 验证平台

- `Web/basic-react`：React consumer 验证平台
- `Web/basic-vue3`：Vue3 consumer 验证平台

这些 example 的平台差异审核基线，统一以
`specs/002-electron-callkit/contracts/electron-web-example-platform-baseline.md`
为准。当前固定口径是：

- Web example 只按 `external` 路径审核，不承担 `managed` 模式切换
- Web example 保留群呼验证路径
- 与 Electron 对齐时，只把 `external` 和 1v1 主链路视为 shared 交集能力
- Electron example 支持默认 `external` + 可切换 `managed`，且群呼入口必须禁用并明确标注“暂不支持”

这两个 example 的职责是：

- 验证正式 `sdk/uikit` 的 tarball / registry 接入链路
- 作为发布前“低门槛接入”的样板
- 不再直连仓库内部 shared source 路径

## Phase D 最小接入

Web 正式接入现在固定成两层：

1. 宿主先完成 `nim-web-sdk-ng` 登录，再通过 `NECall.getInstance().setup({ nim, appkey, ... })` 接入 `@xkit-yx/call-kit`
2. 页面层再组合 React 或 Vue3 的 UIKit `CallViewProvider`

补充说明：

- Web 没有 Electron `host-helper` 这一层
- demo-shell 属于 example host 层，不是正式接入必需项
- 当前 Web demo 页面只剩登录/登出、runtime/provider 装配与少量参数转换；这些页面壳层不属于 shared formal contract

---

## Web 端（单呼）

### 初始化

```typescript
import NIM from 'nim-web-sdk-ng'
import { NECall } from '@xkit-yx/call-kit'

const nim = NIM.getInstance({
  appkey: 'your_appkey',
  apiVersion: 'v2',
  debugLevel: 'debug',
})
await nim.V2NIMLoginService.login('accountId', 'token')

// 使用单例模式获取实例
const neCall = NECall.getInstance()
neCall.setup({
  nim,           // NIM V10 实例
  appkey: 'your_appkey',
  debug: true,
})
```

#### `setup` 参数说明

| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `nim` | `V2NIM` | 必填 | NIM V10 实例 |
| `appkey` | `string` | 必填 | 应用 AppKey |
| `rtcContext` | `RTCConfig` | - | RTC 私有化配置 |
| `enableRecord` | `boolean` | `true` | 是否开启话单 |
| `enableAutoJoinSignalChannel` | `boolean` | `false` | 被叫是否自动加入信令频道 |
| `enableJoinRtcWhenCall` | `boolean` | `false` | 主叫发起呼叫时是否提前加入 RTC |
| `enableSubscribeVideoInAudioCall` | `boolean` | `false` | 音频通话时是否也订阅对端视频，常用于接收音频通话中的视频占位图 |
| `debug` | `boolean` | `false` | 是否打印日志 |

---

### 主叫方

```typescript
// 发起呼叫
await neCall.call({
  accId: 'calleeAccId',  // 被叫 accountId
  callType: '2',          // '1': 音频, '2': 视频
})

// 设置本地/远端视频视图（仅 Web 端）
const localView = document.getElementById('localView')
const remoteView = document.getElementById('remoteView')
neCall.setLocalView(localView)
neCall.setRemoteView(remoteView)

// 取消呼叫
await neCall.hangup()
```

---

### 被叫方

```typescript
// 监听来电邀请
neCall.on('onReceiveInvited', (info) => {
  // info.callerAccId: 主叫 accountId
  // info.callType: '1' | '2'
  // info.channelId: 信令频道 id（挂断时使用）
  // info.extraInfo: 扩展信息（透传）
  console.log('来电:', info)
})

// 接听
await neCall.accept()

// 拒绝
await neCall.hangup()
```

---

### 通话中（主叫 & 被叫）

```typescript
// 通话建立成功
neCall.on('onCallConnected', (callInfo) => {
  console.log('通话已建立', callInfo)
})

// 开关本地视频
await neCall.enableLocalVideo(true)   // 开启
await neCall.enableLocalVideo(false)  // 关闭

// 开关本地音频
await neCall.enableLocalAudio(true)   // 开启
await neCall.enableLocalAudio(false)  // 关闭（静音）

// 切换通话类型（音频 ↔ 视频）
await neCall.switchCallType({ callType: '1', state: 1 }) // 发起切换请求
// state: 1=邀请, 2=同意, 3=拒绝

// 监听对端切换通话类型
neCall.on('onCallTypeChange', ({ callType, state }) => {
  // callType: '1' | '2'
  // state: 1=邀请, 2=同意, 3=拒绝
})

// 挂断
await neCall.hangup()
```

---

### 通话结束

```typescript
neCall.on('onCallEnd', ({ reasonCode, extraString }) => {
  // reasonCode 含义：
  // 2  - 呼叫超时（自动挂断）
  // 3  - 对方正在通话中（占线）
  // 11 - 本地主动取消呼叫
  // 12 - 对端主动取消呼叫
  // 13 - 本地主动拒绝
  // 14 - 对端主动拒绝
  // 15 - 本地主动挂断
  // 16 - 对端挂断 / RTC 房间关闭
  // 17 - 多端登录：其他端已拒绝
  // 18 - 多端登录：其他端已接听
  // 20 - 对端 RTC 断开（网络异常）
})

// 话单发送回调（通话结束时组件自动发送话单消息给对端）
neCall.on('onRecordSend', (record) => {
  // 可在此更新本端通话记录 UI
  console.log('话单已发送', record)
})
```

---

### 其他方法

```typescript
// 设置呼叫超时（秒）
neCall.setTimeout(30)

// 动态配置
neCall.setCallConfig({
  enableOffline: true,              // 是否支持离线推送
  enableSwitchVideoConfirm: false,  // 切换视频是否需要对端确认
  enableSwitchAudioConfirm: false,  // 切换音频是否需要对端确认
  enableJoinRtcWhenCall: false,     // 主叫是否提前加入 RTC
  enableSubscribeVideoInAudioCall: false, // 音频通话时也订阅对端视频
})

// 获取当前配置
const config = neCall.getCallConfig()

// 获取通话信息（通话中有效）
const callInfo = neCall.getCallInfo()
// callInfo.callStatus: 0=闲置 1=呼叫中 2=被叫中 3=通话中
// callInfo.callType: 1=音频 2=视频
// callInfo.callerInfo / calleeInfo: { accId, uid }

// IM 断线重连后调用（触发离线消息重新拉取）
neCall.reconnect()

// 销毁实例（退出登录时调用）
neCall.destroy()
```

### 音频通话视频占位图（仅 Web）

```typescript
// 接收端需要开启订阅，才能在音频通话中收到对端发送的视频占位流
neCall.setup({
  nim,
  appkey: 'your_appkey',
  enableSubscribeVideoInAudioCall: true,
})

// 在发起/接听前设置音频通话占位图
await neCall.setAudioCallPlaceholderImage({
  url: 'https://your-cdn.example.com/call-placeholder.png',
  layout: {
    objectFit: 'contain',
    backgroundColor: '#000000',
    width: 1280,
    height: 720,
    fps: 5,
  },
})
```

`setAudioCallPlaceholderImage` 会把远端图片绘制到 `canvas`，再通过 `captureStream()` 生成 `MediaStreamTrack`，在音频通话时作为自定义视频流推送到房间里。接收端只有在 `enableSubscribeVideoInAudioCall` 为 `true` 时才会订阅这路视频；否则音频通话仍然只订阅音频。

当前版本要求在音频通话建连前调用该方法，已建立的音频通话暂不支持热切换占位视频源。

---

## 微信小程序端

小程序端使用独立构建产物，通过 `live-pusher`（本地推流）和 `live-player`（远端拉流）实现音视频通话。

### 初始化

```typescript
// App.vue / app.js
import V2NIM from 'nim-web-sdk-ng/dist/v2/NIM_MINIAPP_SDK'
import { NECall } from '@xkit-yx/call-kit/miniprogram_dist/index'

const nim = V2NIM.getInstance({
  appkey: 'your_appkey',
  apiVersion: 'v2',
  debugLevel: 'debug',
})
await nim.V2NIMLoginService.login(accountId, token)

const neCall = NECall.getInstance()
neCall.setup({
  nim,
  appkey: 'your_appkey',
  debug: true,
})

// 监听来电（建议在 App 全局注册，防止重复注册）
neCall.off('onReceiveInvited')
neCall.on('onReceiveInvited', () => {
  const pages = getCurrentPages()
  const isOnCallPage = pages.some((p) => p.route === 'pages/call/call')
  if (!isOnCallPage) {
    wx.navigateTo({ url: '/pages/call/call' })
  }
})
```

### 小程序端特有事件

| 事件 | 回调参数 | 说明 |
|------|----------|------|
| `onStreamPublish` | `url: string` | 本地推流地址（赋值给 `live-pusher` 的 `url`） |
| `onStreamSubscribed` | `url: string` | 远端拉流地址（赋值给 `live-player` 的 `src`） |
| `onVideoMuteOrUnmute` | `mute: boolean` | 对端关闭/开启摄像头 |

### live-player mode 适配（鸿蒙系统）

鸿蒙系统的 `live-player` 需要使用 `'live'` 模式，其他平台使用 `'RTC'`：

```javascript
function getPlayerMode() {
  try {
    const info = wx.getDeviceInfo ? wx.getDeviceInfo() : wx.getSystemInfoSync()
    const platform = (info.platform || '').toLowerCase()
    const system = (info.system || '').toLowerCase()
    if (platform.includes('ohos') || system.includes('harmonyos')) {
      return 'live'
    }
  } catch (e) {}
  return 'RTC'
}
```

---

## 事件列表（完整）

| 事件名 | 回调参数 | 适用平台 | 说明 |
|--------|----------|----------|------|
| `onReceiveInvited` | `NEInviteInfo` | Web / 小程序 | 收到来电邀请 |
| `onCallConnected` | `NECallInfo \| undefined` | Web / 小程序 | 通话建立成功 |
| `onCallEnd` | `NECallEndInfo` | Web / 小程序 | 通话结束 |
| `onCallTypeChange` | `NECallTypeChangeInfo` | Web / 小程序 | 通话类型切换 |
| `onRecordSend` | `SignalControllerCallRecord` | Web / 小程序 | 话单发送完成 |
| `onStreamPublish` | `url: string` | 小程序 | 本地推流地址就绪 |
| `onStreamSubscribed` | `url: string` | 小程序 | 远端拉流地址就绪 |
| `onVideoMuteOrUnmute` | `mute: boolean` | 小程序 | 对端摄像头开关变化 |

### `onCallEnd` reasonCode 含义

| code | 说明 |
|------|------|
| 2 | 呼叫超时，自动挂断 |
| 3 | 对方正在通话中（占线） |
| 11 | 本地主动取消呼叫 |
| 12 | 对端主动取消呼叫 |
| 13 | 本地主动拒绝 |
| 14 | 对端主动拒绝 |
| 15 | 本地主动挂断 |
| 16 | 对端挂断 / RTC 房间关闭 |
| 17 | 多端登录：其他端已拒绝 |
| 18 | 多端登录：其他端已接听 |
| 20 | 对端 RTC 断开（网络异常） |

---

## 类型定义

```typescript
type NESetupConfig = {
  nim: V2NIM                          // NIM V10 实例
  appkey: string                      // 应用 AppKey
  rtcContext?: RTCConfig              // RTC 私有化配置
  enableRecord?: boolean              // 是否开启话单，默认 true
  enableAutoJoinSignalChannel?: boolean  // 被叫是否自动加入信令，默认 false
  enableJoinRtcWhenCall?: boolean     // 主叫是否提前加入 RTC，默认 false
  enableSubscribeVideoInAudioCall?: boolean // 音频通话时也订阅对端视频，常用于视频占位图
  debug?: boolean                     // 打印日志，默认 false
}

type NECallParam = {
  accId: string                       // 被叫 accountId
  callType: '1' | '2'                 // '1': 音频, '2': 视频
  extraInfo?: string                  // 透传到被叫 onReceiveInvited
  rtcChannelName?: string             // 自定义 RTC 频道名
  globalExtraCopy?: string            // 服务端抄送自定义信息
  pushConfig?: SignalControllerPushInfo  // 离线推送配置
}

type NEHangupParam = {
  reason?: number                     // 挂断原因码
  extraString?: string                // 附加信息
  channelId?: string                  // 信令频道 id
  duration?: number                   // 通话时长（秒），用于话单生成
}

type NECallConfig = {
  enableOffline?: boolean             // 支持离线推送，默认 true
  enableSwitchVideoConfirm?: boolean  // 切换视频需对端确认，默认 false
  enableSwitchAudioConfirm?: boolean  // 切换音频需对端确认，默认 false
  enableJoinRtcWhenCall?: boolean     // 主叫提前加入 RTC，默认 false
  enableSubscribeVideoInAudioCall?: boolean // 音频通话时也订阅对端视频，常用于视频占位图
}

type NECallPlaceholderImageObjectFit =
  | 'contain'
  | 'cover'
  | 'fill'
  | 'none'
  | 'scale-down'

type NEAudioCallPlaceholderImageLayout = {
  objectFit?: NECallPlaceholderImageObjectFit // 图片布局方式，默认 contain
  backgroundColor?: string            // 画布背景色，默认 #000000
  width?: number                      // 输出宽度，默认 1280
  height?: number                     // 输出高度，默认 720
  fps?: number                        // canvas 重绘帧率，默认 5
}

type NEAudioCallPlaceholderImageConfig = {
  url: string                         // 占位图地址
  layout?: NEAudioCallPlaceholderImageLayout
}

type NEInviteInfo = {
  callerAccId: string                 // 主叫 accountId
  callType: '1' | '2'                 // 通话类型
  extraInfo?: string                  // 透传信息
  channelId: string                   // 信令频道 id
}

type NECallEndInfo = {
  reasonCode: number                  // 结束原因码（见上表）
  extraString?: string                // 附加信息
  message?: string                    // 结束消息
}
```
