鸿蒙5.0版开发:应用框架ArkWeb开发-托管网页中的媒体播放

 往期鸿蒙全套实战文章必看:(文中附带鸿蒙全栈学习资料)


托管网页中的媒体播放

Web组件提供了应用接管网页中媒体播放的能力,用来支持应用增强网页的媒体播放,如画质增强等。

使用场景

网页播放媒体时,存在着网页视频不够清晰、网页的播放器界面简陋功能少、一些视频不能播放的问题。

此时,应用开发者可以使用该功能,通过自己或者第三方的播放器,接管网页媒体播放来改善网页的媒体播放体验。

实现原理

ArkWeb内核播放媒体的框架

不开启该功能时,ArkWeb内核的播放架构如下所示:

说明

  • 上图中1表示ArkWeb内核创建WebMediaPlayer来播放网页中的媒体资源。
  • 上图中2表示WebMediaPlayer使用系统解码器来渲染媒体数据。

开启该功能后,ArkWeb内核的播放架构如下:

说明

  • 上图中1表示ArkWeb内核创建WebMediaPlayer来播放网页中的媒体资源。
  • 上图中2表示WebMediaPlayer使用应用提供的本地播放器(NativeMediaPlayer)来渲染媒体数据。

ArkWeb内核与应用的交互

开发指导

开启接管网页媒体播放

需要先通过enableNativeMediaPlayer接口,开启接管网页媒体播放的功能。

// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
    }
  }
}

创建本地播放器(NativeMediaPlayer)

该功能开启后,每当网页中有媒体需要播放时,ArkWeb内核会触发由onCreateNativeMediaPlayer注册的回调函数。

开发者则需要调用 onCreateNativeMediaPlayer 来注册一个创建本地播放器的回调函数。

该回调函数需要根据媒体信息来判断是否要创建一个本地播放器来接管当前的网页媒体资源。

  • 如果应用不接管当前的为网页媒体资源, 需要在回调函数里返回 null 。
  • 如果应用接管当前的为网页媒体资源, 需要在回调函数里返回一个本地播放器实例。

本地播放器需要实现NativeMediaPlayerBridge接口,以便ArkWeb内核对本地播放器进行播控操作。

// xxx.ets
import { webview } from '@kit.ArkWeb';

// 实现 webview.NativeMediaPlayerBridge 接口。
// ArkWeb 内核调用该类的方法来对 NativeMediaPlayer 进行播控。
class NativeMediaPlayerImpl implements webview.NativeMediaPlayerBridge {
  // ... 实现 NativeMediaPlayerBridge 里的接口方法 ...
  constructor(handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) {}
  updateRect(x: number, y: number, width: number, height: number) {}
  play() {}
  pause() {}
  seek(targetTime: number) {}
  release() {}
  setVolume(volume: number) {}
  setMuted(muted: boolean) {}
  setPlaybackRate(playbackRate: number) {}
  enterFullscreen() {}
  exitFullscreen() {}
}

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
        .onPageBegin((event) => {
          this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) => {
            // 判断需不需要接管当前的媒体。
            if (!shouldHandle(mediaInfo)) {
              // 本地播放器不接管该媒体。
              // 返回 null 。ArkWeb 内核将用自己的播放器来播放该媒体。
              return null;
            }
            // 接管当前的媒体。
            // 返回一个本地播放器实例给 ArkWeb 内核。
            let nativePlayer: webview.NativeMediaPlayerBridge = new NativeMediaPlayerImpl(handler, mediaInfo);
            return nativePlayer;
          });
        })
    }
  }
}

// stub
function shouldHandle(mediaInfo: webview.MediaInfo) {
  return true;
}

绘制本地播放器组件

应用接管网页的媒体后,开发者需要将本地播放器组件及视频画面绘制到ArkWeb内核提供的Surface上。ArkWeb内核再将Surface与网页进行合成,最后上屏显示。

该流程与同层渲染绘制相同。

  1. 在应用启动阶段,应保存UIContext,以便后续的同层渲染绘制流程能够使用该UIContext。

    // xxxAbility.ets
    
    import { UIAbility } from '@kit.AbilityKit';
    import { window } from '@kit.ArkUI';
    
    export default class EntryAbility extends UIAbility {
      onWindowStageCreate(windowStage: window.WindowStage): void {
        windowStage.loadContent('pages/Index', (err, data) => {
          if (err.code) {
            return;
          }
          // 保存UIContext, 在后续的同层渲染绘制中使用。
          AppStorage.setOrCreate<UIContext>("UIContext", windowStage.getMainWindowSync().getUIContext());
        });
      }
    
      // ... 其他需要重写的方法 ...
    }
  2. 使用ArkWeb内核创建的Surface进行同层渲染绘制。

    // xxx.ets
    import { webview } from '@kit.ArkWeb';
    import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
    
    interface ComponentParams {}
    
    class MyNodeController extends NodeController {
      private rootNode: BuilderNode<[ComponentParams]> | undefined;
    
      constructor(surfaceId: string, renderType: NodeRenderType) {
        super();
    
        // 获取之前保存的 UIContext 。
        let uiContext = AppStorage.get<UIContext>("UIContext");
        this.rootNode = new BuilderNode(uiContext as UIContext, { surfaceId: surfaceId, type: renderType });
      }
    
      makeNode(uiContext: UIContext): FrameNode | null {
        if (this.rootNode) {
          return this.rootNode.getFrameNode() as FrameNode;
        }
        return null;
      }
    
      build() {
        // 构造本地播放器组件
      }
    }
    
    @Entry
    @Component
    struct WebComponent {
      node_controller?: MyNodeController;
      controller: webview.WebviewController = new webview.WebviewController();
      @State show_native_media_player: boolean = false;
    
      build() {
        Column() {
          Stack({ alignContent: Alignment.TopStart }) {
            if (this.show_native_media_player) {
              NodeContainer(this.node_controller)
                .width(300)
                .height(150)
                .backgroundColor(Color.Transparent)
                .border({ width: 2, color: Color.Orange })
            }
            Web({ src: 'www.example.com', controller: this.controller })
              .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
              .onPageBegin((event) => {
                this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo:    webview.MediaInfo) => {
                  // 接管当前的媒体。
    
                  // 使用同层渲染流程提供的 surface 来构造一个本地播放器组件。
                  this.node_controller = new MyNodeController(mediaInfo.surfaceInfo.id, NodeRenderType.RENDER_TYPE_TEXTURE);
                  this.node_controller.build();
    
                  // 展示本地播放器组件。
                  this.show_native_media_player = true;
    
                  // 返回一个本地播放器实例给 ArkWeb 内核。
                  return null;
                });
              })
          }
        }
      }
    }

执行ArkWeb内核发送给本地播放器的播控指令

为了方便ArkWeb内核对本地播放器进行播控操作,应用开发者需要令本地播放器实现NativeMediaPlayerBridge接口,并根据每个接口方法的功能对本地播放器进行相应操作。

// xxx.ets
import { webview } from '@kit.ArkWeb';

class ActualNativeMediaPlayerListener {
  constructor(handler: webview.NativeMediaPlayerHandler) {}
}

class NativeMediaPlayerImpl implements webview.NativeMediaPlayerBridge {
  constructor(handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) {
    // 1. 创建一个本地播放器的状态监听。
    let listener: ActualNativeMediaPlayerListener = new ActualNativeMediaPlayerListener(handler);
    // 2. 创建一个本地播放器。
    // 3. 监听该本地播放器。
    // ...
  }

  updateRect(x: number, y: number, width: number, height: number) {
    // <video> 标签的位置和大小发生了变化。
    // 根据该信息变化,作出相应的改变。
  }

  play() {
    // 启动本地播放器播放。
  }

  pause() {
    // 暂停本地播放器播放。
  }

  seek(targetTime: number) {
    // 本地播放器跳转到指定的时间点。
  }

  release() {
    // 销毁本地播放器。
  }

  setVolume(volume: number) {
    // ArkWeb 内核要求调整本地播放器的音量。
    // 设置本地播放器的音量。
  }

  setMuted(muted: boolean) {
    // 将本地播放器静音或取消静音。
  }

  setPlaybackRate(playbackRate: number) {
    // 调整本地播放器的播放速度。
  }

  enterFullscreen() {
    // 将本地播放器设置为全屏播放。
  }

  exitFullscreen() {
    // 将本地播放器退出全屏播放。
  }
}

将本地播放器的状态信息通知给ArkWeb内核

ArkWeb内核需要本地播放器的状态信息来更新到网页(例如:视频的宽高、播放时间、缓存时间等),因此,应用开发者需要将本地播放器的状态信息通知给ArkWeb内核。

onCreateNativeMediaPlayer接口中, ArkWeb内核传递给应用一个NativeMediaPlayerHandler对象。应用开发者需要通过该对象,将本地播放器的最新状态信息通知给ArkWeb内核。

// xxx.ets
import { webview } from '@kit.ArkWeb';

class ActualNativeMediaPlayerListener {
  handler: webview.NativeMediaPlayerHandler;

  constructor(handler: webview.NativeMediaPlayerHandler) {
    this.handler = handler;
  }

  onPlaying() {
    // 本地播放器开始播放。
    this.handler.handleStatusChanged(webview.PlaybackStatus.PLAYING);
  }
  onPaused() {
    // 本地播放器暂停播放。
    this.handler.handleStatusChanged(webview.PlaybackStatus.PAUSED);
  }
  onSeeking() {
    // 本地播放器开始执行跳转到目标时间点。
    this.handler.handleSeeking();
  }
  onSeekDone() {
    // 本地播放器 seek 完成。
    this.handler.handleSeekFinished();
  }
  onEnded() {
    // 本地播放器播放完成。
    this.handler.handleEnded();
  }
  onVolumeChanged() {
    // 获取本地播放器的音量。
    let volume: number = getVolume();
    this.handler.handleVolumeChanged(volume);
  }
  onCurrentPlayingTimeUpdate() {
    // 更新播放时间。
    let currentTime: number = getCurrentPlayingTime();
    // 将时间单位换算成秒。
    let currentTimeInSeconds = convertToSeconds(currentTime);
    this.handler.handleTimeUpdate(currentTimeInSeconds);
  }
  onBufferedChanged() {
    // 缓存发生了变化。
    // 获取本地播放器的缓存时长。
    let bufferedEndTime: number = getCurrentBufferedTime();
    // 将时间单位换算成秒。
    let bufferedEndTimeInSeconds = convertToSeconds(bufferedEndTime);
    this.handler.handleBufferedEndTimeChanged(bufferedEndTimeInSeconds);

    // 检查缓存状态。
    // 如果缓存状态发生了变化,则向 ArkWeb 内核通知缓存状态。
    let lastReadyState: webview.ReadyState = getLastReadyState();
    let currentReadyState: webview.ReadyState = getCurrentReadyState();
    if (lastReadyState != currentReadyState) {
      this.handler.handleReadyStateChanged(currentReadyState);
    }
  }
  onEnterFullscreen() {
    // 本地播放器进入了全屏状态。
    let isFullscreen: boolean = true;
    this.handler.handleFullscreenChanged(isFullscreen);
  }
  onExitFullscreen() {
    // 本地播放器退出了全屏状态。
    let isFullscreen: boolean = false;
    this.handler.handleFullscreenChanged(isFullscreen);
  }
  onUpdateVideoSize(width: number, height: number) {
    // 当本地播放器解析出视频宽高时, 通知 ArkWeb 内核。
    this.handler.handleVideoSizeChanged(width, height);
  }
  onDurationChanged(duration: number) {
    // 本地播放器解析到了新的媒体时长, 通知 ArkWeb 内核。
    this.handler.handleDurationChanged(duration);
  }
  onError(error: webview.MediaError, errorMessage: string) {
    // 本地播放器出错了,通知 ArkWeb 内核。
    this.handler.handleError(error, errorMessage);
  }
  onNetworkStateChanged(state: webview.NetworkState) {
    // 本地播放器的网络状态发生了变化, 通知 ArkWeb 内核。
    this.handler.handleNetworkStateChanged(state);
  }
  onPlaybackRateChanged(playbackRate: number) {
    // 本地播放器的播放速率发生了变化, 通知 ArkWeb 内核。
    this.handler.handlePlaybackRateChanged(playbackRate);
  }
  onMutedChanged(muted: boolean) {
    // 本地播放器的静音状态发生了变化, 通知 ArkWeb 内核。
    this.handler.handleMutedChanged(muted);
  }

  // ... 监听本地播放器其他的状态 ...
}
@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  @State show_native_media_player: boolean = false;

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({enable: true, shouldOverlay: false})
        .onPageBegin((event) => {
          this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) => {
            // 接管当前的媒体。

            // 创建一个本地播放器实例。
            // let nativePlayer: NativeMediaPlayerImpl = new NativeMediaPlayerImpl(handler, mediaInfo);

            // 创建一个本地播放器状态监听对象。
            let nativeMediaPlayerListener: ActualNativeMediaPlayerListener = new ActualNativeMediaPlayerListener(handler);
            // 监听本地播放器状态。
            // nativePlayer.setListener(nativeMediaPlayerListener);

            // 返回这个本地播放器实例给 ArkWeb 内核。
            return null;
          });
        })
    }
  }
}

// stub
function getVolume() {
  return 1;
}
function getCurrentPlayingTime() {
  return 1;
}
function getCurrentBufferedTime() {
  return 1;
}
function convertToSeconds(input: number) {
  return input;
}
function getLastReadyState() {
  return webview.ReadyState.HAVE_NOTHING;
}
function getCurrentReadyState() {
  return webview.ReadyState.HAVE_NOTHING;
}

完整示例

  • 使用前请在module.json5添加如下权限。

    "ohos.permission.INTERNET"
  • 应用侧代码,在应用启动阶段保存UIContext。

    // xxxAbility.ets
    
    import { UIAbility } from '@kit.AbilityKit';
    import { window } from '@kit.ArkUI';
    
    export default class EntryAbility extends UIAbility {
      onWindowStageCreate(windowStage: window.WindowStage): void {
        windowStage.loadContent('pages/Index', (err, data) => {
          if (err.code) {
            return;
          }
          // 保存 UIContext, 在后续的同层渲染绘制中会用到。
          AppStorage.setOrCreate<UIContext>("UIContext", windowStage.getMainWindowSync().getUIContext());
        });
      }
    
      // ... 其他需要重写的方法 ...
    }
  • 应用侧代码,视频托管使用示例。

    // Index.ets
    
    import { webview } from '@kit.ArkWeb';
    import { BuilderNode, FrameNode, NodeController, NodeRenderType, UIContext } from '@kit.ArkUI';
    import { AVPlayerDemo, AVPlayerListener } from './PlayerDemo';
    
    // 实现 webview.NativeMediaPlayerBridge 接口。
    // ArkWeb 内核调用该类的方法来对 NativeMediaPlayer 进行播控。
    class NativeMediaPlayerImpl implements webview.NativeMediaPlayerBridge {
      private surfaceId: string;
      mediaSource: string;
      private mediaHandler: webview.NativeMediaPlayerHandler;
      nativePlayerInfo: NativePlayerInfo;
      nativePlayer: AVPlayerDemo;
      httpHeaders: Record<string, string>;
    
      constructor(nativePlayerInfo: NativePlayerInfo, handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) {
        console.log(`NativeMediaPlayerImpl.constructor, surface_id[${mediaInfo.surfaceInfo.id}]`);
        this.nativePlayerInfo = nativePlayerInfo;
        this.mediaHandler = handler;
        this.surfaceId = mediaInfo.surfaceInfo.id;
        this.mediaSource = mediaInfo.mediaSrcList.find((item)=>{item.source.indexOf('.mp4') > 0})?.source
          || mediaInfo.mediaSrcList[0].source;
        this.httpHeaders = mediaInfo.headers;
        this.nativePlayer = new AVPlayerDemo();
    
        // 使用同层渲染功能,将视频及其播控组件绘制到网页中
        this.nativePlayerInfo.node_controller = new MyNodeController(
          this.nativePlayerInfo, this.surfaceId, this.mediaHandler, this, NodeRenderType.RENDER_TYPE_TEXTURE);
        this.nativePlayerInfo.node_controller.build();
        this.nativePlayerInfo.show_native_media_player = true;
    
        console.log(`NativeMediaPlayerImpl.mediaSource: ${this.mediaSource}, headers: ${JSON.stringify(this.httpHeaders)}`);
      }
    
      updateRect(x: number, y: number, width: number, height: number): void {
        let width_in_vp = px2vp(width);
        let height_in_vp = px2vp(height);
        console.log(`updateRect(${x}, ${y}, ${width}, ${height}), vp:{${width_in_vp}, ${height_in_vp}}`);
    
        this.nativePlayerInfo.updateNativePlayerRect(x, y, width, height);
      }
    
      play() {
        console.log('NativeMediaPlayerImpl.play');
        this.nativePlayer.play();
      }
      pause() {
        console.log('NativeMediaPlayerImpl.pause');
        this.nativePlayer.pause();
      }
      seek(targetTime: number) {
        console.log(`NativeMediaPlayerImpl.seek(${targetTime})`);
        this.nativePlayer.seek(targetTime);
      }
      setVolume(volume: number) {
        console.log(`NativeMediaPlayerImpl.setVolume(${volume})`);
        this.nativePlayer.setVolume(volume);
      }
      setMuted(muted: boolean) {
        console.log(`NativeMediaPlayerImpl.setMuted(${muted})`);
      }
      setPlaybackRate(playbackRate: number) {
        console.log(`NativeMediaPlayerImpl.setPlaybackRate(${playbackRate})`);
        this.nativePlayer.setPlaybackRate(playbackRate);
      }
      release() {
        console.log('NativeMediaPlayerImpl.release');
        this.nativePlayer?.release();
        this.nativePlayerInfo.show_native_media_player = false;
        this.nativePlayerInfo.node_width = 300;
        this.nativePlayerInfo.node_height = 150;
        this.nativePlayerInfo.destroyed();
      }
      enterFullscreen() {
        console.log('NativeMediaPlayerImpl.enterFullscreen');
      }
      exitFullscreen() {
        console.log('NativeMediaPlayerImpl.exitFullscreen');
      }
    }
    
    // 监听NativeMediaPlayer的状态,然后通过 webview.NativeMediaPlayerHandler 将状态上报给 ArkWeb 内核。
    class AVPlayerListenerImpl implements AVPlayerListener {
      handler: webview.NativeMediaPlayerHandler;
      component: NativePlayerComponent;
    
      constructor(handler: webview.NativeMediaPlayerHandler, component: NativePlayerComponent) {
        this.handler = handler;
        this.component = component;
      }
      onPlaying() {
        console.log('AVPlayerListenerImpl.onPlaying');
        this.handler.handleStatusChanged(webview.PlaybackStatus.PLAYING);
      }
      onPaused() {
        console.log('AVPlayerListenerImpl.onPaused');
        this.handler.handleStatusChanged(webview.PlaybackStatus.PAUSED);
      }
      onDurationChanged(duration: number) {
        console.log(`AVPlayerListenerImpl.onDurationChanged(${duration})`);
        this.handler.handleDurationChanged(duration);
      }
      onBufferedTimeChanged(buffered: number) {
        console.log(`AVPlayerListenerImpl.onBufferedTimeChanged(${buffered})`);
        this.handler.handleBufferedEndTimeChanged(buffered);
      }
      onTimeUpdate(time: number) {
        this.handler.handleTimeUpdate(time);
      }
      onEnded() {
        console.log('AVPlayerListenerImpl.onEnded');
        this.handler.handleEnded();
      }
      onError() {
        console.log('AVPlayerListenerImpl.onError');
        this.component.has_error = true;
        setTimeout(()=>{
          this.handler.handleError(1, "Oops!");
        }, 200);
      }
      onVideoSizeChanged(width: number, height: number) {
        console.log(`AVPlayerListenerImpl.onVideoSizeChanged(${width}, ${height})`);
        this.handler.handleVideoSizeChanged(width, height);
        this.component.onSizeChanged(width, height);
      }
      onDestroyed(): void {
        console.log('AVPlayerListenerImpl.onDestroyed');
      }
    }
    
    interface ComponentParams {
      text: string;
      text2: string;
      playerInfo: NativePlayerInfo;
      handler: webview.NativeMediaPlayerHandler;
      player: NativeMediaPlayerImpl;
    }
    
    // 自定义的播放器组件
    @Component
    struct NativePlayerComponent {
      params?: ComponentParams;
      @State bgColor: Color = Color.Red;
      mXComponentController: XComponentController = new XComponentController();
    
      videoController: VideoController = new VideoController();
      offset_x: number = 0;
      offset_y: number = 0;
      @State video_width_percent: number = 100;
      @State video_height_percent: number = 100;
      view_width: number = 0;
      view_height: number = 0;
      video_width: number = 0;
      video_height: number = 0;
    
      fullscreen: boolean = false;
      @State has_error: boolean = false;
    
      onSizeChanged(width: number, height: number) {
        this.video_width = width;
        this.video_height = height;
        let scale: number = this.view_width / width;
        let scaled_video_height: number = scale * height;
        this.video_height_percent = scaled_video_height / this.view_height * 100;
        console.log(`NativePlayerComponent.onSizeChanged(${width},${height}), video_height_percent[${this.video_height_percent }]`);
      }
    
      build() {
        Column() {
          Stack() {
            XComponent({ id: 'video_player_id', type: XComponentType.SURFACE, controller: this.mXComponentController })
              .width(this.video_width_percent + '%')
              .height(this.video_height_percent + '%')
              .onLoad(()=>{
                if (!this.params) {
                  console.log('this.params is null');
                  return;
                }
                console.log('NativePlayerComponent.onLoad, params[' + this.params
                  + '], text[' + this.params.text + '], text2[' + this.params.text2
                  + '], web_tab[' + this.params.playerInfo + '], handler[' + this.params.handler + ']');
                this.params.player.nativePlayer.setSurfaceID(this.mXComponentController.getXComponentSurfaceId());
    
                this.params.player.nativePlayer.avPlayerLiveDemo({
                  url: this.params.player.mediaSource,
                  listener: new AVPlayerListenerImpl(this.params.handler, this),
                  httpHeaders: this.params.player.httpHeaders,
                });
              })
            Column() {
              Row() {
                Button(this.params?.text)
                  .height(50)
                  .border({ width: 2, color: Color.Red })
                  .backgroundColor(this.bgColor)
                  .onClick(()=>{
                    console.log(`NativePlayerComponent.Button[${this.params?.text}] is clicked`);
                    this.params?.player.nativePlayer?.play();
                  })
                  .onTouch((event: TouchEvent) => {
                    event.stopPropagation();
                  })
                Button(this.params?.text2)
                  .height(50)
                  .border({ width: 2, color: Color.Red })
                  .onClick(()=>{
                    console.log(`NativePlayerComponent.Button[${this.params?.text2}] is clicked`);
                    this.params?.player.nativePlayer?.pause();
                  })
                  .onTouch((event: TouchEvent) => {
                    event.stopPropagation();
                  })
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceEvenly)
            }
            if (this.has_error) {
              Column() {
                Text('Error')
                  .fontSize(30)
              }
              .backgroundColor('#eb5555')
              .width('100%')
              .height('100%')
              .justifyContent(FlexAlign.Center)
            }
          }
        }
        .width('100%')
        .height('100%')
        .onAreaChange((oldValue: Area, newValue: Area) => {
          console.log(`NativePlayerComponent.onAreaChange(${JSON.stringify(oldValue)}, ${JSON.stringify(newValue)})`);
          this.view_width = new Number(newValue.width).valueOf();
          this.view_height = new Number(newValue.height).valueOf();
          this.onSizeChanged(this.video_width, this.video_height);
        })
      }
    }
    
    @Builder
    function NativePlayerComponentBuilder(params: ComponentParams) {
      NativePlayerComponent({ params: params })
        .backgroundColor(Color.Green)
        .border({ width: 1, color: Color.Brown })
        .width('100%')
        .height('100%')
    }
    
    // 通过 NodeController 来动态创建自定义的播放器组件, 并将组件内容绘制到 surfaceId 指定的 surface 上。
    class MyNodeController extends NodeController {
      private rootNode: BuilderNode<[ComponentParams]> | undefined;
      playerInfo: NativePlayerInfo;
      listener: webview.NativeMediaPlayerHandler;
      player: NativeMediaPlayerImpl;
    
      constructor(playerInfo: NativePlayerInfo,
                  surfaceId: string,
                  listener: webview.NativeMediaPlayerHandler,
                  player: NativeMediaPlayerImpl,
                  renderType: NodeRenderType) {
        super();
        this.playerInfo = playerInfo;
        this.listener = listener;
        this.player = player;
        let uiContext = AppStorage.get<UIContext>("UIContext");
        this.rootNode = new BuilderNode(uiContext as UIContext, { surfaceId: surfaceId, type: renderType });
        console.log(`MyNodeController, rootNode[${this.rootNode}], playerInfo[${playerInfo}], listener[${listener}], surfaceId[${surfaceId}]`);
      }
    
      makeNode(): FrameNode | null {
        if (this.rootNode) {
          return this.rootNode.getFrameNode() as FrameNode;
        }
        return null;
      }
    
      build() {
        let params: ComponentParams = {
          "text": "play",
          "text2": "pause",
          playerInfo: this.playerInfo,
          handler: this.listener,
          player: this.player
        };
        if (this.rootNode) {
          this.rootNode.build(wrapBuilder(NativePlayerComponentBuilder), params);
        }
      }
    
      postTouchEvent(event: TouchEvent) {
        return this.rootNode?.postTouchEvent(event);
      }
    }
    
    class Rect {
      x: number = 0;
      y: number = 0;
      width: number = 0;
      height: number = 0;
    
      static toNodeRect(rectInPx: webview.RectEvent) : Rect {
        let rect = new Rect();
        rect.x = px2vp(rectInPx.x);
        rect.y = px2vp(rectInPx.x);
        rect.width = px2vp(rectInPx.width);
        rect.height = px2vp(rectInPx.height);
        return rect;
      }
    }
    
    @Observed
    class NativePlayerInfo {
      public web: WebComponent;
      public embed_id: string;
      public player: webview.NativeMediaPlayerBridge;
      public node_controller?: MyNodeController;
      public show_native_media_player: boolean = false;
      public node_pos_x: number;
      public node_pos_y: number;
      public node_width: number;
      public node_height: number;
    
      playerComponent?: NativeMediaPlayerComponent;
    
      constructor(web: WebComponent, handler: webview.NativeMediaPlayerHandler, videoInfo: webview.MediaInfo) {
        this.web = web;
        this.embed_id = videoInfo.embedID;
    
        let node_rect = Rect.toNodeRect(videoInfo.surfaceInfo.rect);
        this.node_pos_x = node_rect.x;
        this.node_pos_y = node_rect.y;
        this.node_width = node_rect.width;
        this.node_height = node_rect.height;
    
        this.player = new NativeMediaPlayerImpl(this, handler, videoInfo);
      }
    
      updateNativePlayerRect(x: number, y: number, width: number, height: number) {
        this.playerComponent?.updateNativePlayerRect(x, y, width, height);
      }
    
      destroyed() {
        let info_list = this.web.native_player_info_list;
        console.log(`NativePlayerInfo[${this.embed_id}] destroyed, list.size[${info_list.length}]`);
        this.web.native_player_info_list = info_list.filter((item) => item.embed_id != this.embed_id);
        console.log(`NativePlayerInfo after destroyed, new_list.size[${this.web.native_player_info_list.length}]`);
      }
    }
    
    @Component
    struct NativeMediaPlayerComponent {
      @ObjectLink playerInfo: NativePlayerInfo;
    
      aboutToAppear() {
        this.playerInfo.playerComponent = this;
      }
    
      build() {
        NodeContainer(this.playerInfo.node_controller)
          .width(this.playerInfo.node_width)
          .height(this.playerInfo.node_height)
          .offset({x: this.playerInfo.node_pos_x, y: this.playerInfo.node_pos_y})
          .backgroundColor(Color.Transparent)
          .border({ width: 2, color: Color.Orange })
          .onAreaChange((oldValue, newValue) => {
            console.log(`NodeContainer[${this.playerInfo.embed_id}].onAreaChange([${oldValue.width} x ${oldValue.height}]->[${newValue.width} x ${newValue.height}]`);
          })
      }
    
      updateNativePlayerRect(x: number, y: number, width: number, height: number) {
        let node_rect = Rect.toNodeRect({x, y, width, height});
        this.playerInfo.node_pos_x = node_rect.x;
        this.playerInfo.node_pos_y = node_rect.y;
        this.playerInfo.node_width = node_rect.width;
        this.playerInfo.node_height = node_rect.height;
      }
    }
    
    @Entry
    @Component
    struct WebComponent {
      controller: WebviewController = new webview.WebviewController();
      page_url: Resource = $rawfile('main.html');
    
      @State native_player_info_list: NativePlayerInfo[] = [];
    
      area?: Area;
    
      build() {
        Column() {
          Stack({alignContent: Alignment.TopStart}) {
            ForEach(this.native_player_info_list, (item: NativePlayerInfo) => {
              if (item.show_native_media_player) {
                NativeMediaPlayerComponent({ playerInfo: item })
              }
            }, (item: NativePlayerInfo) => {
              return item.embed_id;
            })
            Web({ src: this.page_url, controller: this.controller })
              .enableNativeMediaPlayer({ enable: true, shouldOverlay: true })
              .onPageBegin(() => {
                this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) => {
                  console.log('onCreateNativeMediaPlayer(' + JSON.stringify(mediaInfo) + ')');
                  let nativePlayerInfo = new NativePlayerInfo(this, handler, mediaInfo);
                  this.native_player_info_list.push(nativePlayerInfo);
                  return nativePlayerInfo.player;
                });
              })
              .onNativeEmbedGestureEvent((event)=>{
                if (!event.touchEvent || !event.embedId) {
                  event.result?.setGestureEventResult(false);
                  return;
                }
                console.log(`WebComponent.onNativeEmbedGestureEvent, embedId[${event.embedId}]`);
                let native_player_info = this.getNativePlayerInfoByEmbedId(event.embedId);
                if (!native_player_info) {
                  console.log(`WebComponent.onNativeEmbedGestureEvent, embedId[${event.embedId}], no native_player_info`);
                  event.result?.setGestureEventResult(false);
                  return;
                }
                if (!native_player_info.node_controller) {
                  console.log(`WebComponent.onNativeEmbedGestureEvent, embedId[${event.embedId}], no node_controller`);
                  event.result?.setGestureEventResult(false);
                  return;
                }
                let ret = native_player_info.node_controller.postTouchEvent(event.touchEvent);
                console.log(`WebComponent.postTouchEvent, ret[${ret}], touchEvent[${JSON.stringify(event.touchEvent)}]`);
                event.result?.setGestureEventResult(ret);
              })
              .width('100%')
              .height('100%')
              .onAreaChange((oldValue: Area, newValue: Area) => {
                oldValue;
                this.area = newValue;
              })
          }
        }
      }
    
      getNativePlayerInfoByEmbedId(embedId: string) : NativePlayerInfo | undefined {
        return this.native_player_info_list.find((item)=> item.embed_id == embedId);
      }
    }
  • 应用侧代码,视频播放示例, ./PlayerDemo.ets。

    import { media } from '@kit.MediaKit';
    import { BusinessError } from '@kit.BasicServicesKit';
    
    export interface AVPlayerListener {
      onPlaying() : void;
      onPaused() : void;
      onDurationChanged(duration: number) : void;
      onBufferedTimeChanged(buffered: number) : void;
      onTimeUpdate(time: number) : void;
      onEnded() : void;
      onError() : void;
      onVideoSizeChanged(width: number, height: number): void;
      onDestroyed(): void;
    }
    
    interface PlayerParam {
      url: string;
      listener?: AVPlayerListener;
      httpHeaders?: Record<string, string>;
    }
    
    interface PlayCommand {
      func: Function;
      name?: string;
    }
    
    interface CheckPlayCommandResult {
      ignore: boolean;
      index_to_remove: number;
    }
    
    export class AVPlayerDemo {
      private surfaceID: string = ''; // surfaceID用于播放画面显示,具体的值需要通过Xcomponent接口获取,相关文档链接见上面Xcomponent创建方法
    
      avPlayer?: media.AVPlayer;
      prepared: boolean = false;
    
      commands: PlayCommand[] = [];
    
      setSurfaceID(surface_id: string) {
        console.log(`AVPlayerDemo.setSurfaceID : ${surface_id}`);
        this.surfaceID = surface_id;
      }
      // 注册avplayer回调函数
      setAVPlayerCallback(avPlayer: media.AVPlayer, listener?: AVPlayerListener) {
        // seek操作结果回调函数
        avPlayer.on('seekDone', (seekDoneTime: number) => {
          console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
        });
        // error回调监听函数,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程
        avPlayer.on('error', (err: BusinessError) => {
          console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
          listener?.onError();
          avPlayer.reset(); // 调用reset重置资源,触发idle状态
        });
        // 状态机变化回调函数
        avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => {
          switch (state) {
            case 'idle': // 成功调用reset接口后触发该状态机上报
              console.info('AVPlayer state idle called.');
              avPlayer.release(); // 调用release接口销毁实例对象
              break;
            case 'initialized': // avplayer 设置播放源后触发该状态上报
              console.info('AVPlayer state initialized called.');
              avPlayer.surfaceId = this.surfaceID; // 设置显示画面,当播放的资源为纯音频时无需设置
              avPlayer.prepare();
              break;
            case 'prepared': // prepare调用成功后上报该状态机
              console.info('AVPlayer state prepared called.');
              this.prepared = true;
              this.schedule();
              break;
            case 'playing': // play成功调用后触发该状态机上报
              console.info('AVPlayer state playing called.');
              listener?.onPlaying();
              break;
            case 'paused': // pause成功调用后触发该状态机上报
              console.info('AVPlayer state paused called.');
              listener?.onPaused();
              break;
            case 'completed': // 播放结束后触发该状态机上报
              console.info('AVPlayer state completed called.');
              avPlayer.stop(); //调用播放结束接口
              break;
            case 'stopped': // stop接口成功调用后触发该状态机上报
              console.info('AVPlayer state stopped called.');
              listener?.onEnded();
              break;
            case 'released':
              this.prepared = false;
              listener?.onDestroyed();
              console.info('AVPlayer state released called.');
              break;
            default:
              console.info('AVPlayer state unknown called.');
              break;
          }
        });
        avPlayer.on('durationUpdate', (duration: number) => {
          console.info(`AVPlayer state durationUpdate success,new duration is :${duration}`);
          listener?.onDurationChanged(duration/1000);
        });
        avPlayer.on('timeUpdate', (time:number) => {
          listener?.onTimeUpdate(time/1000);
        });
        avPlayer.on('bufferingUpdate', (infoType: media.BufferingInfoType, value: number) => {
          console.info(`AVPlayer state bufferingUpdate success,and infoType value is:${infoType}, value is : ${value}`);
          if (infoType == media.BufferingInfoType.BUFFERING_PERCENT) {
          }
          listener?.onBufferedTimeChanged(value);
        })
        avPlayer.on('videoSizeChange', (width: number, height: number) => {
          console.info(`AVPlayer state videoSizeChange success,and width is:${width}, height is : ${height}`);
          listener?.onVideoSizeChanged(width, height);
        })
      }
    
      // 以下demo为通过url设置网络地址来实现播放直播码流的demo
      async avPlayerLiveDemo(playerParam: PlayerParam) {
        // 创建avPlayer实例对象
        this.avPlayer = await media.createAVPlayer();
        // 创建状态机变化回调函数
        this.setAVPlayerCallback(this.avPlayer, playerParam.listener);
    
        let mediaSource: media.MediaSource = media.createMediaSourceWithUrl(playerParam.url, playerParam.httpHeaders);
        let strategy: media.PlaybackStrategy = {
          preferredWidth: 100,
          preferredHeight: 100,
          preferredBufferDuration: 100,
          preferredHdr: false
        };
        this.avPlayer.setMediaSource(mediaSource, strategy);
        console.log(`AVPlayer url:[${playerParam.url}]`);
      }
    
      schedule() {
        if (!this.avPlayer) {
          return;
        }
        if (!this.prepared) {
          return;
        }
        if (this.commands.length > 0) {
          let command = this.commands.shift();
          if (command) {
            command.func();
          }
          if (this.commands.length > 0) {
            setTimeout(() => {
              this.schedule();
            });
          }
        }
      }
    
      private checkCommand(selfName: string, oppositeName: string) {
        let index_to_remove = -1;
        let ignore_this_action = false;
        let index = this.commands.length - 1;
        while (index >= 0) {
          if (this.commands[index].name == selfName) {
            ignore_this_action = true;
            break;
          }
          if (this.commands[index].name == oppositeName) {
            index_to_remove = index;
            break;
          }
          index--;
        }
    
        let result : CheckPlayCommandResult = {
          ignore: ignore_this_action,
          index_to_remove: index_to_remove,
        };
        return result;
      }
    
      play() {
        let commandName = 'play';
        let checkResult = this.checkCommand(commandName, 'pause');
        if (checkResult.ignore) {
          console.log(`AVPlayer ${commandName} ignored.`);
          this.schedule();
          return;
        }
        if (checkResult.index_to_remove >= 0) {
          let removedCommand = this.commands.splice(checkResult.index_to_remove, 1);
          console.log(`AVPlayer ${JSON.stringify(removedCommand)} removed.`);
          return;
        }
        this.commands.push({ func: ()=>{
          console.info('AVPlayer.play()');
          this.avPlayer?.play();
        }, name: commandName});
        this.schedule();
      }
      pause() {
        let commandName = 'pause';
        let checkResult = this.checkCommand(commandName, 'play');
        console.log(`checkResult:${JSON.stringify(checkResult)}`);
        if (checkResult.ignore) {
          console.log(`AVPlayer ${commandName} ignored.`);
          this.schedule();
          return;
        }
        if (checkResult.index_to_remove >= 0) {
          let removedCommand = this.commands.splice(checkResult.index_to_remove, 1);
          console.log(`AVPlayer ${JSON.stringify(removedCommand)} removed.`);
          return;
        }
        this.commands.push({ func: ()=>{
          console.info('AVPlayer.pause()');
          this.avPlayer?.pause();
        }, name: commandName});
        this.schedule();
      }
      release() {
        this.commands.push({ func: ()=>{
          console.info('AVPlayer.release()');
          this.avPlayer?.release();
        }});
        this.schedule();
      }
      seek(time: number) {
        this.commands.push({ func: ()=>{
          console.info(`AVPlayer.seek(${time})`);
          this.avPlayer?.seek(time * 1000);
        }});
        this.schedule();
      }
      setVolume(volume: number) {
        this.commands.push({ func: ()=>{
          console.info(`AVPlayer.setVolume(${volume})`);
          this.avPlayer?.setVolume(volume);
        }});
        this.schedule();
      }
      setPlaybackRate(playbackRate: number) {
        let speed = media.PlaybackSpeed.SPEED_FORWARD_1_00_X;
        let delta = 0.05;
        playbackRate += delta;
        if (playbackRate < 1) {
          speed = media.PlaybackSpeed.SPEED_FORWARD_0_75_X;
        } else if (playbackRate < 1.25) {
          speed = media.PlaybackSpeed.SPEED_FORWARD_1_00_X;
        } else if (playbackRate < 1.5) {
          speed = media.PlaybackSpeed.SPEED_FORWARD_1_25_X;
        } else if (playbackRate < 2) {
          speed = media.PlaybackSpeed.SPEED_FORWARD_1_75_X;
        } else {
          speed = media.PlaybackSpeed.SPEED_FORWARD_2_00_X;
        }
        this.commands.push({ func: ()=>{
          console.info(`AVPlayer.setSpeed(${speed})`);
          this.avPlayer?.setSpeed(speed);
        }});
        this.schedule();
      }
    }
  • 前端页面示例。

    <html>
    <head>
        <title>视频托管测试html</title>
        <meta name="viewport" content="width=device-width">
    </head>
    <body>
    <div>
        <!-- 使用时需要自行替换视频链接 -->
        <video src='https://xxx.xxx/demo.mp4' style='width: 100%'></video>
    </div>
    </body>
    </html>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值