【HarmonyOS实战开发】鸿蒙分布式媒体会话-使用投播组件

200 篇文章 0 订阅
200 篇文章 8 订阅

播控特性简介

使用媒体播控,可以简单高效地将音视频投放到其他HarmonyOS设备上播放,如在手机上播放的音视频,可以投到2in1设备上继续播放。

HarmonyOS提供了统一的应用内音视频投播功能设计,通过使用系统提供的投播组件和接口,应用只需要设置对应的资源信息、监听投播中的相关状态,以及应用主动控制的行为(如:播放、暂停)。其他动作包括图标切换、设备的发现、连接、认证等,均由系统完成。

基本概念

  • 媒体会话(AVSession)

    音视频管控服务,用于对系统中所有音视频行为进行统一的管理。

    本地播放时,应用需要向媒体会话提供播放的媒体信息(如正在播放的歌曲、歌曲的播放状态等),并接收和响应播控中心发出的控制命令(如暂停、下一首等)。具体请参考本地媒体会话。

    投播时,通过AVSession,应用可以进行投播能力的设置和查询,并创建投播控制器。

    应用可以在启动内容显示(比如:视频播放)时,获取支持投播的扩展屏设备并注册监听,当存在扩展屏时,可在扩展屏上全屏绘制要投播的内容。

  • 投播组件(AVCastPicker)

    系统级的投播组件,可嵌入应用界面的UI组件。当用户点击该组件后,系统将进行设备发现、连接、认证等流程,应用仅需要通过接口获取投播中相关的回调信息。

  • 投播控制器(AVCastController)

    在投播后,由应用发起的用于控制远端播放的接口,包括播放、暂停、调节音量、设置播放模式、设置播放速度等能力。

  • 后台长时任务

    应用实现后台播放,需申请后台长时任务,避免应用在投播后被系统后台清理或冻结。具体参考长时任务。

运作机制

image.png

  • 发现和连接设备

    用户在应用界面上点击AVCastPicker组件,触发系统发现可用于投播的设备。用户在设备列表中选择对应设备后,系统连接对应设备。应用无需关注设备的发现连接过程,仅需关注设备在远端是否可用。

    应用需要接入AVSession,才可以使用系统提供的统一投播能力,由系统进行设备发现和管理。

  • 进入远端投播

    应用通过AVSession监听设备的连接情况,监听到设备已连接后,可通过AVSession获取一个AVCastController对象用于发送控制命令(如播放、暂停、下一首等)。

    应用在进入远端投播时,应停止本地的播放器,避免本端和远端设备同时播放的情况。同时,建议应用重新绘制应用界面,比如界面变更为一个遥控器,来控制远端播放。

  • 在本端控制播放

    在本端(包括应用内和播控中心)控制播放时,控制命令将通过AVCastController发送,系统将完成数据传输和信息同步,然后更新远端系统预置播放器的状态。

  • 在远端控制播放

    用户同样可以在远端直接控制播放,会直接修改远端播放器的状态。

  • 远端播放器状态回调

    当远端播放器状态变更后,会触发回调,将状态信息返回到本端。应用可以通过AVCastController监听到远端播放器的状态变化。

投播组件开发指导

通过本开发指导,完成一次音视频跨设备投播。

约束与限制

需同时满足以下条件,才能使用该功能:

  • 设备限制

    本端设备:HarmonyOS NEXT Developer Preview0及以上版本的手机设备

    远端设备:HarmonyOS NEXT Developer Preview0及以上版本的2in1设备/华为智慧屏HarmonyOS3.1及以上版本

  • 使用限制

    • 双端设备打开蓝牙和WIFI,并可访问网络。

接口说明

在开发具体功能前,请先查阅参考文档,获取详细的接口说明。

  • AVCastPicker:投播组件,提供设备发现认证连接的统一入口。
  • AVCastController:投播控制器,用于投播场景下,完成播放控制、远端播放状态监听等操作。

说明
AVCastController由系统获取并返回,在设备连接成功后获取,在设备断开后不能继续使用,否则会抛出异常。

支持在线DRM视频资源投播能力,需注册DRM许可证请求回调函数,获取许可证后,调用处理许可证响应函数。

image.png

开发步骤

  1. 创建播放器,并创建AVSession。

    通过AVSessionManager创建并激活媒体会话。

    示例中的context的获取方式请参见获取UIAbility的上下文信息。

 import  { avSession }  from '@kit.AVSessionKit'; // 导入AVSession模块

// 声明全局的session对象,此写法是加在class类外的声明,如果需要在class类内申明全局变量,需要去掉 export let
export let session: avSession.AVSession;
// 创建session
async createSession(context: Context) {
  session = await avSession.createAVSession(context, 'video_test', 'video'); // 'audio'代表音频应用,'video'代表视频应用
  await session.activate();
  // 需要将应用加入支持投播的应用名单中,才能成功投播。
  session.setExtras({
    requireAbilityList: ['url-cast'],
  }); 
  console.info(`Session created. sessionId: ${session.sessionId}`);
}
  1. 设置媒体资源信息。

说明
需要在AVCastPicker中仅显示支持DRM资源投播的设备时,应在AVMetaData设置明确的drmSchemes。

// 与session声明不在同一文件时,需要import
import { session } from './xxx'; // session声明的文件

  public setAVMetadata(playInfo: avSession.AVMediaDescription): Promise<void> {
    const metadata: avSession.AVMetadata = {
        assetId: playInfo.assetId, // 需要配置实际id
        title: playInfo.title, // 播放媒体资源的标题
        subtitle: playInfo.subtitle,// 播放媒体资源的副标题
        // 发现Cast+ Stream 和 DLNA协议设备,TYPE_CAST_PLUS_STREAM为默认必选。
        filter: avSession.ProtocolType.TYPE_CAST_PLUS_STREAM|avSession.ProtocolType.TYPE_DLNA,
        mediaImage: playInfo.mediaImage,
        artist: playInfo.artist,
        // 如果是DRM资源,配置支持的DRM uuid 用于设备过滤。非DRM资源不配置。
        drmSchemes: ['3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c']
      };
    return session.setAVMetadata(metadata);
  }
  1. 在需要投播的播放界面创建投播组件AVCastPicker。
 import { AVCastPicker } from '@kit.AVSessionKit';;

// 创建组件,并设置大小
build() {
  Row() {
    Column() {
      AVCastPicker()
       .size({height:'100%',width:'100%'})
    }
  }
}
  1. 设置AVSession的信息,注册AVSession的回调。用于感知投播连接。

    说明

    下面代码展示设备连接成功后的相应的处理

    • 连接成功后通过session获取AVCastController,用于后期的投播控制;
    • 如需要推送DRM在线资源,根据远端设备支持的DRM能力,从服务端获取对应的资源;
    • 推送DRM资源后,应注册监听许可证请求事件,从服务器端获取许可证后,通过处理许可证响应函数提供给远端。
import { BusinessError } from '@kit.BasicServicesKit';
import { avSession } from '@kit.AVSessionKit';
import { session } from './xxx'; // session声明的文件

  castController: avSession.AVCastController | undefined = undefined;
  getAVCastController() {
    // 监听设备连接状态的变化
    session.on('outputDeviceChange', async (connectState: avSession.ConnectionState,
                                                 device: avSession.OutputDeviceInfo) => {
      let currentDevice: avSession.DeviceInfo = device?.devices?.[0];
      if (currentDevice.castCategory === avSession.AVCastCategory.CATEGORY_REMOTE && connectState === avSession.ConnectionState.STATE_CONNECTED) { // 设备连接成功
        console.info(`Device connected: ${device}`);
        this.castController = await session.getAVCastController();
        console.info('Succeeded in getting a cast controller');
        // 查询当前播放的状态
        let avPlaybackState = await this.castController?.getAVPlaybackState();
        console.info(`Succeeded in AVPlaybackState resource obtained: ${avPlaybackState}`);
        // 监听播放状态的变化
        this.castController?.on('playbackStateChange', 'all', (state: avSession.AVPlaybackState) => {
          console.info(`Succeeded in Playback state changed: ${state}`);
        });
        if (currentDevice.supportedProtocols === avSession.ProtocolType.TYPE_CAST_PLUS_STREAM) {
          // 此设备支持cast+投播协议
        } else if (currentDevice.supportedProtocols === avSession.ProtocolType.TYPE_DLNA) {
          // 此设备支持DLNA投播协议
        }
        // 此设备支持chinaDRM,监听许可证请求事件,也可在发起DRM资源投播前监听。
        if (currentDevice.supportedDrmCapabilities?.includes('3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c')) {
          this.castController?.on('keyRequest', this.keyRequestCallback);
        }
      }
    })
  }

  // 处理DRM许可证请求事件
  private keyRequestCallback: avSession.KeyRequestCallback = async (assetId: string, requestData: Uint8Array) => {
    // 根据assetId获取对应的DRM url
    let drmUrl: string = 'http://license.xxx.xxx.com:8080/drmproxy/getLicense';
    // 从服务器获取许可证,具体实现可参考附录。
    let licenseResponseData = await this.getLicense(drmUrl, requestData); 
    try {
      // 处理DRM许可证响应
      await this.castController?.processMediaKeyResponse(assetId, licenseResponseData);
    } catch (error) {
      console.error(`Failed to process the response corresponding to the media key. Error: ${error}`);
    }
  }
  1. 使用AVCastController进行资源播放。

    说明

    下面代码示例中的url仅作示意使用,开发者需根据实际情况,确认资源有效性并设置:

    • 如果使用本地资源播放,必须确认资源文件可用,并使用应用沙箱路径访问对应资源,参考获取应用文件路径。应用沙箱的介绍及如何向应用沙箱推送文件,请参考文件管理。
    • 如果通过FilePicker使用用户文件,请参考选择用户文件。
    • 如果使用网络播放路径,需申请相关权限:ohos.permission.INTERNET。
    • 如果是DRM资源,需配置drmSchemes字段。
 playItem() {
    // 设置播放参数,开始播放
    let playItem : avSession.AVQueueItem = {
      itemId: 0,
      description: {
        assetId: 'VIDEO-1',
        title: 'ExampleTitle',
        artist: 'ExampleArtist',
        mediaUri: 'https://xxx.xxx.com/example.mp4',
        mediaType: 'VIDEO',
        mediaSize: 1000,
        startPosition: 0,
        duration: 100000,
        albumCoverUri: 'https://www.example.jpeg',
        albumTitle: '《ExampleAlbum》',
        appName: 'ExampleApp',
        // DRM资源,需要配置支持的DRM类型, 以chinaDRM为例。
        drmScheme: '3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c',
      }
    };
    // 准备播放,这个不会触发真正的播放,会进行加载和缓冲
    this.castController?.prepare(playItem, () => {
      console.info('Preparation done');
    });
    // 启动播放
    this.castController?.start(playItem, () => {
      console.info('Playback started');
    });
  }
  1. 使用AVCastController,监听控制命令和进行播放控制。
playControl() {
    // 记录从avsession获取的远端控制器
    // 下发播放命令
    let avCommand: avSession.AVCastControlCommand = {command:'play'};
    this.castController?.sendControlCommand(avCommand);

    // 下发暂停命令
    avCommand = {command:'pause'};
    this.castController?.sendControlCommand(avCommand);

    // 监听上下一首切换
    this.castController?.on('playPrevious', () => {
      console.info('PlayPrevious done');
    });
    this.castController?.on('playNext', () => {
      console.info('PlayNext done');
    });
  }
  1. 申请投播长时任务,避免应用在投播进入后台时被系统冻结,导致无法持续投播。

    说明

    在申请长时任务时,需要在module.json5文件中:

    1. 配置长时任务权限ohos.permission.KEEP_BACKGROUND_RUNNING。
    2. 为需要使用长时任务的UIAbility声明相应的后台模式类型:MULTI_DEVICE_CONNECTION。
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { wantAgent } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

let context: Context = getContext(this);

function startContinuousTask() {
    let wantAgentInfo: wantAgent.WantAgentInfo = {
        // 点击通知后,将要执行的动作列表
        wants: [
          {
            bundleName: "com.example.myapplication",
            abilityName: "EntryAbility",
          }
        ],
        // 点击通知后,动作类型
        operationType: wantAgent.OperationType.START_ABILITY,
        // 使用者自定义的一个私有值
        requestCode: 0,
        // 点击通知后,动作执行属性
        wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };

    // 通过wantAgent模块的getWantAgent方法获取WantAgent对象
    try {
        wantAgent.getWantAgent(wantAgentInfo).then((wantAgentObj) => {
            try {
                backgroundTaskManager.startBackgroundRunning(context,
                    backgroundTaskManager.BackgroundMode.MULTI_DEVICE_CONNECTION, wantAgentObj).then(() => {
                    console.info('Succeeded in requesting to start running in background');
                }).catch((error: BusinessError) => {
                    console.error(`Failed to request to start running in background. Code: ${error.code}, message: ${error.message}`);
                });
            } catch (error) {
                console.error(`Failed to request to start running in background. Error: ${error}`);
            }
        });
    } catch (error) {
        console.error(`Failed to get WantAgent. Error: ${error}`);
    }
}
  1. 处理音频焦点。请参考多音频并发处理。

    在应用进入投播后,当前应用需要取消注册焦点处理事件,以免被其他应用的焦点申请而影响。

  2. 结束投播。

    当远端设备断开的时候,应用会收到事件,系统会自动断开连接。

    应用也可以使用断开投播的接口,主动进行投播连接的断开。

async release() {
  // 一般来说,应用退出时,而不希望继续投播,可以主动结束
  await session.stopCasting();
}

附录

从服务器获取许可证

开发者需要根据实际的资源和服务地址获取DRM许可证,以下示例代码仅作为参考。

import { http } from '@kit.NetworkKit';

 // 获取DRM许可证, 仅做参考,需要结合实际资源和服务地址进行获取。
  async getLicense(drmUrl: string, requestData: Uint8Array): Promise<Uint8Array | undefined> {
    let licenseRequestStr: string = this.byteToString(requestData);
    let licenseResponseStr: string = 'defaultStr';
    let httpRequest = http.createHttp();
    try {
      let response: http.HttpResponse = await httpRequest.request(drmUrl, {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'application/json',
          'Accept-Encoding': 'gzip, deflate',
        },
        extraData: licenseRequestStr,
        expectDataType: http.HttpDataType.STRING,
      });
      if (response?.responseCode == http.ResponseCode.OK) {
        if (typeof response.result == 'string') {
          licenseResponseStr = response.result;
        }
      }
      httpRequest.destroy();
    } catch (error) {
      console.error(`Failed to request Http. Error: ${error}`);
      return undefined;
    }
    return this.stringToByte(licenseResponseStr);
  }

  /**
   * Uint8Array to string
   * @param arr Uint8Array
   * @returns string
   */
  byteToString(arr: Uint8Array): string {
    let str: string = ''
    let _arr: Uint8Array = arr

    for (let i = 0; i < _arr.length; i++) {
      // 将数值转为二进制字符串
      let binaryStr: string = _arr[i].toString(2)
      let matchArray = binaryStr.match(new RegExp('/^1+?(?=0)/'))
      if (matchArray && binaryStr.length == 8) {
        let bytesLength: number = matchArray[0].length
        let store: string = _arr[i].toString(2).slice(7 - bytesLength)

        for (let j = 1; j < bytesLength; j++) {
          store += _arr[j + i].toString(2).slice(2)
        }
        str += String.fromCharCode(Number.parseInt(store, 2))
        i += bytesLength - 1
      } else {
        str += String.fromCharCode(_arr[i])
      }
    }
    return str
  }

  /**
   * string 转 Uint8Array
   * @param str string
   * @returns Uint8Array
   */
  stringToByte(str: string): Uint8Array {
    let bytes: number[] = new Array()
    let unicode: number

    for (let i = 0; i < str.length; i++) {
      unicode = str.charCodeAt(i)
      if (unicode >= 0x010000 && unicode <= 0x10FFFF) {
        bytes.push(((unicode >> 18) & 0x07) | 0xf0)
        bytes.push(((unicode >> 12) & 0x3F) | 0x80)
        bytes.push(((unicode >> 6) & 0x3F) | 0x80)
        bytes.push((unicode & 0x3F) | 0x80)
      } else if (unicode >= 0x000800 && unicode <= 0x00FFF) {
        bytes.push(((unicode >> 12) & 0x07) | 0xf0)
        bytes.push(((unicode >> 6) & 0x3F) | 0x80)
        bytes.push((unicode & 0x3F) | 0x80)
      } else if (unicode >= 0x000800 && unicode <= 0x0007FF) {
        bytes.push(((unicode >> 6) & 0x3F) | 0x80)
        bytes.push((unicode & 0x3F) | 0x80)
      } else {
        bytes.push(unicode & 0xFF)
      }
    }
    return new Uint8Array(bytes);
  }

扩展屏投播开发指导

通过本节开发指导,可在系统镜像投屏后,获取投屏设备信息,实现扩展屏模式的投播,实现双屏协作的能力。

运作机制

image.png

  • 虚拟扩展屏

    是在系统投屏启动过程中建立的,依据双端协商的投屏视频流的分辨率创建,支持1080P 及以上分辨率。默认镜像主屏内容,当虚拟扩展屏上有UIAbility绘制时,会投屏该屏内容。

  • UIAbility A(本机内容)

    在本端主屏上显示的内容。假定UIAbility A 与 UIAbility B 属于同一应用,UIAbility A可以控制UIAbility B,实现双屏联动。

  • UIAbility B(投屏内容)

    在虚拟扩展屏上绘制的内容,考虑到远端投屏用户体验,UIAbility B 应铺满全屏。从安全角度考虑,在启动UIAbility B 时,系统会校验主屏前台UIAbility是否归属同一应用,如果校验失败会禁止其在虚拟扩展屏启动。

约束与限制

需同时满足以下条件,才能使用该功能:

  • 设备限制

    本端设备:HarmonyOS NEXT Developer Beta1及以上版本的手机设备

    远端设备:华为智慧屏HarmonyOS2.0及以上版本

  • 使用限制

    需要系统发起无线/有线投屏后才可通过接口获取有效的扩展投屏设备。

接口说明

在开发具体功能前,请先查阅参考文档,获取详细的接口说明。
image.png

开发步骤

  1. UIAibility A创建AVSession, 获取可用扩展屏投播设备并注册监听。

    说明

    获取的屏幕信息CastDisplayInfo中包含屏幕ID,屏幕名称、状态以及分辨率宽度、高度基础属性,其中屏幕id 值同于Display的id,如需要获取更详细的信息可参考Display获取设备信息说明。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import  { avSession }  from '@kit.AVSessionKit'; // 导入AVSession模块
import { BusinessError } from '@kit.BasicServicesKit';

export default class AbilityA extends UIAbility{
  private session: avSession.AVSession | undefined = undefined;
  private extCastDisplayInfo: avSession.CastDisplayInfo | undefined = undefined;
  // 注册监听可投屏设备变化事件
  private onCastDisplayChangedCallback = (castDisplayInfo: avSession.CastDisplayInfo) => {
    // 新增扩展屏,进入扩展屏显示
    if (this.extCastDisplayInfo === undefined && castDisplayInfo.state === avSession.CastDisplayState.STATE_ON) {
      console.info('Succeeded in opening the cast display');
      this.extCastDisplayInfo = castDisplayInfo;
      this.startExternalDisplay();
    } else if (this.extCastDisplayInfo?.id == castDisplayInfo.id) {
      this.extCastDisplayInfo = castDisplayInfo;
      // 扩展屏不可用,退出扩展屏显示
      if (castDisplayInfo.state === avSession.CastDisplayState.STATE_OFF){
        console.info('Succeeded in closing the cast display');
        this.stopExternalDisplay();
        this.extCastDisplayInfo = undefined;
      }
    }
  };

  // 创建AVSession, 获取可用扩展屏投播设备并注册监听
  initAVSession(context: Context) {
    avSession.createAVSession(context, 'CastDisplay', 'video').then((session: avSession.AVSession) => {
      this.session = session;
      this.session?.on('castDisplayChange', this.onCastDisplayChangedCallback);

      // 获取当前系统可用的扩展屏显示设备
      session.getAllCastDisplays().then((infoArr: avSession.CastDisplayInfo[]) => {
        // 有多个扩展屏时可以提供用户选择,也可使用其中任一个作为扩展屏使用。
        if (infoArr.length > 0) {
          this.extCastDisplayInfo = infoArr[0];
          this.startExternalDisplay();
        }
      }).catch((err: BusinessError<void>) => {
        console.error(`Failed to get all CastDisplay. Code: ${err.code}, message: ${err.message}`);
      });
    });
  }

  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    super.onCreate(want, launchParam);
    this.initAVSession(this.context);
  }

  onDestroy() {
    this.stopExternalDisplay();
    // 去注册监听
    this.session?.off('castDisplayChange');
  }
}
  1. 在UIAblityA中构建扩展屏启动和退出能力。
// 扩展屏启动UIAbilityB
  startExternalDisplay() {
    if (this.extCastDisplayInfo !== undefined &&
      this.extCastDisplayInfo.id !== 0 &&
      this.extCastDisplayInfo.state === avSession.CastDisplayState.STATE_ON) {
      let id = this.extCastDisplayInfo?.id;
      console.info(`Succeeded in starting ability and the id of display is ${id}`);
      this.context.startAbility({
        bundleName: 'com.example.myapplication', // 应用自有包名
        abilityName: 'AbilityB'
      }, {
        displayId: id // 扩展屏ID
      });
      AppStorage.setOrCreate('CastDisplayState', 1);
    }
  }

  // 停止使用扩展屏
  stopExternalDisplay() {
    AppStorage.setOrCreate('CastDisplayState', 0);
    // 更新本页面显示。
  }
  1. UIAbility B 扩展屏显示内容绘制,需响应退出处理。
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

export default class AbilityB extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    windowStage.getMainWindowSync().setWindowLayoutFullScreen(true); // 设置为全屏
    windowStage.loadContent('pages/CastPage', (err: BusinessError) => {
      if (err.code) {
        console.error(`Failed to load the content. Code: ${err.code}, message: ${err.message}`);
        return;
      }
      console.info('Succeeded in loading the content. ');
    });
  }
}
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct CastPage {
  // 监测到CastDisplayState变化后,当设备断开时,销毁本页内容。
  @StorageLink('CastDisplayState') @Watch('onDestroyExtend') private displayState: number = 1;

  private onDestroyExtend() {
    if (this.displayState === 1) return;
    let context = (getContext(this) as common.UIAbilityContext)
    context.terminateSelf().then(() => {
      console.info('CastPage finished');
    }).catch((err: BusinessError) => {
      console.error(`Failed to destroying CastPage. Code: ${err.code}, message: ${err.message}`);
    });
  }
  //...
}

在案例应用中,开发者可以参考以下实现,完成接入播控中心、投播等功能。

案例介绍

华为视频接入播控中心和投播实践

华为视频在进入影片详情页播放时,支持在控制中心查看当前播放的视频信息,并操作视频完成快进、快退、拖动进度、播放暂停、下一集、调节音量等,方便用户通过控制中心来操作当前播放的视频。

当用户希望通过大屏播放当前华为视频的影片时,可以在华为视频内或播控中心内进行投播,将影片投播到同一网络下的华为智慧屏等大屏设备进行播放,并通过播控中心方便地完成播放暂停、快进快退、下一集等操作。

写在最后

●如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
●点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
●关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识。
●更多鸿蒙最新技术知识点,请移步前往小编:https://gitee.com/

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值