import { Component , h , ComponentInterface , Prop , State , Event , EventEmitter , Host , Watch , Listen , Element , Method } from '@stencil/core' ; import classNames from 'classnames' ; import { formatTime , calcDist , normalizeNumber , throttle , screenFn } from './utils' ; @ Component ( { tag : 'taro-video-core' , styleUrl : './style/index.scss' } ) export class Video implements ComponentInterface { private videoRef : HTMLVideoElement ; private controlsRef : HTMLTaroVideoControlElement ; private danmuRef : HTMLTaroVideoDanmuElement ; private toastProgressRef : HTMLDivElement ; private toastProgressTitleRef : HTMLDivElement ; private toastVolumeRef : HTMLDivElement ; private toastVolumeBarRef : HTMLDivElement ; private currentTime = 0 ; private lastClickedTime : number ; private lastTouchScreenX : number | undefined ; private lastTouchScreenY : number | undefined ; private isDraggingProgress = false ; private lastVolume : number ; private lastPercentage ; private nextPercentage ; private gestureType = 'none' ; @ Element ( ) el : HTMLTaroVideoCoreElement ; /**
     * 要播放视频的资源地址
     */ @ Prop ( ) src : string ; /**
     * 指定视频时长
     */ @ Prop ( ) duration : number ; /**
     * 是否显示默认播放控件（播放/暂停按钮、播放进度、时间）
     */ @ Prop ( ) controls = true ; /**
     * 是否自动播放
     */ @ Prop ( ) autoplay = false ; /**
     * 是否循环播放
     */ @ Prop ( ) loop = false ; /**
     * 是否静音播放
     */ @ Prop ( ) muted = false ; /**
     * 指定视频初始播放位置
     */ @ Prop ( ) initialTime = 0 ; /**
     * 视频封面的图片网络资源地址或云文件ID（2.3.0）。若 controls 属性值为 false 则设置 poster 无效
     */ @ Prop ( ) poster : string ; /**
     * 当视频大小与 video 容器大小不一致时，视频的表现形式
     */ @ Prop ( ) objectFit : 'contain' | 'fill' | 'cover' = 'contain' ; /**
     * 若不设置，宽度大于 240 时才会显示
     */ @ Prop ( ) showProgress = true ; /**
     * 是否显示全屏按钮
     */ @ Prop ( ) showFullscreenBtn = true ; /**
     * 是否显示视频底部控制栏的播放按钮
     */ @ Prop ( ) showPlayBtn = true ; /**
     * 是否显示视频中间的播放按钮
     */ @ Prop ( ) showCenterPlayBtn = true ; /**
     * 是否显示静音按钮
     */ @ Prop ( ) showMuteBtn = false ; /**
     * 弹幕列表
     */ @ Prop ( ) danmuList : [ ] ; /**
     * 是否显示弹幕按钮
     */ @ Prop ( ) danmuBtn = false ; /**
     * 是否展示弹幕
     */ @ Prop ( ) enableDanmu = false ; /**
     * 是否开启播放手势，即双击切换播放/暂停
     */ @ Prop ( ) enablePlayGesture = false ; /**
     * 是否开启控制进度的手势
     */ @ Prop ( ) enableProgressGesture = true ; /**
     * 在非全屏模式下，是否开启亮度与音量调节手势
     */ @ Prop ( ) vslideGesture = false ; /**
     * 在全屏模式下，是否开启亮度与音量调节手势
     */ @ Prop ( ) vslideGestureInFullscreen = true ; @ Prop ( ) nativeProps = { } ; @ State ( ) _duration : number ; @ State ( ) _enableDanmu = false ; @ State ( ) isPlaying = false ; @ State ( ) isFirst = true ; @ State ( ) isFullScreen = false ; @ State ( ) fullScreenTimestamp = new Date ( ) . getTime ( ) ; @ State ( ) isMute = false ; @ Event ( { eventName : 'play' } ) onPlay : EventEmitter ; @ Event ( { eventName : 'pause' } ) onPause : EventEmitter ; @ Event ( { eventName : 'ended' } ) onEnded : EventEmitter ; @ Event ( { eventName : 'timeupdate' } ) onTimeUpdate : EventEmitter ; @ Event ( { eventName : 'error' } ) onError : EventEmitter ; @ Event ( { eventName : 'fullscreenchange' } ) onFullScreenChange : EventEmitter ; @ Event ( { eventName : 'progress' } ) onProgress : EventEmitter ; @ Event ( { eventName : 'loadedmetadata' } ) onLoadedMetaData : EventEmitter ; componentWillLoad ( ) { this . _enableDanmu = this . enableDanmu ; } componentDidLoad ( ) { if ( this . initialTime ) { this . videoRef . currentTime = this . initialTime ; } this . danmuRef . sendDanmu ( this . danmuList ) ; if ( document . addEventListener ) { document . addEventListener ( screenFn . fullscreenchange , this . handleFullScreenChange ) ; } } componentDidRender ( ) { } disconnectedCallback ( ) { if ( document . removeEventListener ) { document . removeEventListener ( screenFn . fullscreenchange , this . handleFullScreenChange ) ; } } @ Watch ( 'enableDanmu' ) watchEnableDanmu ( newVal ) { this . _enableDanmu = newVal ; } analyseGesture = ( e ) => { const obj = { type : 'none' } ; const nowX = e . touches [ 0 ] . screenX ; const nowY = e . touches [ 0 ] . screenY ; const distX = nowX - ( this . lastTouchScreenX as number ) ; const distY = nowY - ( this . lastTouchScreenY as number ) ; const enableVslideGesture = this . isFullScreen ? this . vslideGestureInFullscreen : this . vslideGesture ; if ( this . gestureType === 'none' ) { const dist = calcDist ( distX , distY ) ; if ( dist < 10 ) return obj ; if ( Math . abs ( distY ) >= Math . abs ( distX ) ) { if ( enableVslideGesture ) { this . gestureType = 'adjustVolume' ; this . lastVolume = this . videoRef . volume ; } else { return obj ; } } else if ( Math . abs ( distY ) < Math . abs ( distX ) ) { if ( this . enableProgressGesture ) { this . gestureType = 'adjustProgress' ; this . lastPercentage = this . currentTime / ( this . duration ?? this . _duration ) ; } else { return obj ; } } } obj . type = this . gestureType ; obj . dataX = normalizeNumber ( distX / 200 ) ; obj . dataY = normalizeNumber ( distY / 200 ) ; return obj ; } ; @ Listen ( 'touchmove' , { target : 'document' } ) async onDocumentTouchMove ( e ) { if ( this . lastTouchScreenX === undefined || this . lastTouchScreenY === undefined ) return ; if ( await this . controlsRef . getIsDraggingProgressBall ( ) ) return ; const gestureObj = this . analyseGesture ( e ) ; if ( gestureObj . type === 'adjustVolume' ) { this . toastVolumeRef . style . visibility = 'visible' ; const nextVolume = Math . max ( Math . min ( this . lastVolume - gestureObj . dataY ! , 1 ) , 0 ) ; this . videoRef . volume = nextVolume ; this . toastVolumeBarRef . style . width = `${ nextVolume * 100 }%` ; } else if ( gestureObj . type === 'adjustProgress' ) { this . isDraggingProgress = true ; this . nextPercentage = Math . max ( Math . min ( this . lastPercentage + gestureObj . dataX , 1 ) , 0 ) ; if ( this . controls && this . showProgress ) { this . controlsRef . setProgressBall ( this . nextPercentage ) ; this . controlsRef . toggleVisibility ( true ) ; } const duration = this . duration || this . _duration ; this . toastProgressTitleRef . innerHTML = `${ formatTime ( this . nextPercentage * duration ) } / ${ formatTime ( duration ) }` ; this . toastProgressRef . style . visibility = 'visible' ; } } @ Listen ( 'touchend' , { target : 'document' } ) @ Listen ( 'touchcancel' , { target : 'document' } ) onDocumentTouchEnd ( ) { if ( this . gestureType === 'adjustVolume' ) { this . toastVolumeRef . style . visibility = 'hidden' ; } else if ( this . gestureType === 'adjustProgress' ) { this . toastProgressRef . style . visibility = 'hidden' ; } if ( this . isDraggingProgress ) { this . isDraggingProgress = false ; this . seek ( this . nextPercentage * ( this . duration ?? this . _duration ) ) ; } this . gestureType = 'none' ; this . lastTouchScreenX = undefined ; this . lastTouchScreenY = undefined ; } handlePlay = ( ) => { this . isPlaying = true ; this . isFirst = false ; this . controlsRef . toggleVisibility ( true ) ; this . onPlay . emit ( ) ; } ; handlePause = ( ) => { this . isPlaying = false ; this . controlsRef . toggleVisibility ( true ) ; this . onPause . emit ( ) ; } ; handleEnded = ( ) => { this . isFirst = true ; this . pause ( ) ; this . controlsRef . toggleVisibility ( ) ; this . onEnded . emit ( ) ; } ; handleTimeUpdate = throttle ( async ( e ) => { this . currentTime = this . videoRef . currentTime ; const duration = this . duration || this . _duration ; const isControlDragging = await this . controlsRef . getIsDraggingProgressBall ( ) ; if ( this . controls && this . showProgress ) { if ( ! isControlDragging && ! this . isDraggingProgress ) { this . controlsRef . setProgressBall ( this . currentTime / duration ) ; this . controlsRef . setCurrentTime ( this . currentTime ) ; } } this . danmuRef . tick ( this . currentTime ) ; this . onTimeUpdate . emit ( { duration : e . target ?. duration , currentTime : e . target ?. currentTime } ) ; if ( this . duration ) { if ( this . currentTime >= this . duration ) { this . seek ( 0 ) ; this . handleEnded ( ) ; } } } , 250 ) ; handleError = e => { this . onError . emit ( { errMsg : e . target ?. error ?. message } ) ; } ; handleDurationChange = ( ) => { this . _duration = this . videoRef . duration ; } ; handleProgress = ( ) => { this . onProgress . emit ( ) ; } ; handleLoadedMetaData = ( e ) => { const target = e . target as HTMLVideoElement ; this . onLoadedMetaData . emit ( { width : target . videoWidth , height : target . videoHeight , duration : target . duration } ) ; } ; /** 播放视频 */ @ Method ( ) async play ( ) { this . _play ( ) ; } _play = ( ) => this . videoRef . play ( ) ; /** 暂停视频 */ @ Method ( ) async pause ( ) { this . _pause ( ) ; } _pause = ( ) => this . videoRef . pause ( ) ; /** 停止视频 */ @ Method ( ) async stop ( ) { this . _stop ( ) ; } _stop = ( ) => { this . videoRef . pause ( ) ; this . _seek ( 0 ) ; } ; /** 跳转到指定位置 */ @ Method ( ) async seek ( position ) { this . _seek ( position ) ; } _seek = ( position ) => { this . videoRef . currentTime = position ; } ; /** 进入全屏。若有自定义内容需在全屏时展示，需将内容节点放置到 video 节点内。 */ @ Method ( ) async requestFullScreen ( ) { this . toggleFullScreen ( true ) ; } /** 退出全屏 */ @ Method ( ) async exitFullScreen ( ) { this . toggleFullScreen ( false ) ; } onTouchStartContainer = ( e ) => { this . lastTouchScreenX = e . touches [ 0 ] . screenX ; this . lastTouchScreenY = e . touches [ 0 ] . screenY ; } ; onClickContainer = ( ) => { if ( this . enablePlayGesture ) { const now = Date . now ( ) ; if ( now - this . lastClickedTime < 300 ) { this . isPlaying ? this . pause ( ) : this . play ( ) ; } this . lastClickedTime = now ; } this . controlsRef . toggleVisibility ( ) ; } ; onClickFullScreenBtn = ( e ) => { e . stopPropagation ( ) ; this . toggleFullScreen ( ) ; } ; handleFullScreenChange = e => { const timestamp = new Date ( ) . getTime ( ) ; if ( ! e . detail && this . isFullScreen && ! document [ screenFn . fullscreenElement ] && timestamp - this . fullScreenTimestamp > 100 ) { this . toggleFullScreen ( false ) ; } } ; toggleFullScreen = ( isFullScreen = ! this . isFullScreen ) => { this . isFullScreen = isFullScreen ; this . controlsRef . toggleVisibility ( true ) ; this . fullScreenTimestamp = new Date ( ) . getTime ( ) ; this . onFullScreenChange . emit ( { fullScreen : this . isFullScreen , direction : 'vertical' } ) ; if ( this . isFullScreen && ! document [ screenFn . fullscreenElement ] ) { setTimeout ( ( ) => { this . videoRef [ screenFn . requestFullscreen ] ( { navigationUI : 'show' } ) ; } , 0 ) ; } } ; toggleMute = ( e ) => { e . stopPropagation ( ) ; this . videoRef . muted = ! this . isMute ; this . controlsRef . toggleVisibility ( true ) ; this . isMute = ! this . isMute ; } ; toggleDanmu = ( e ) => { e . stopPropagation ( ) ; this . controlsRef . toggleVisibility ( true ) ; this . _enableDanmu = ! this . _enableDanmu ; } ; render ( ) { const { src , controls , autoplay , loop , muted , poster , objectFit , isFirst , isMute , isFullScreen , duration , _duration , showCenterPlayBtn , isPlaying , _enableDanmu , showMuteBtn , danmuBtn , showFullscreenBtn , nativeProps } = this ; const durationTime = formatTime ( duration || _duration || null ) ; return ( < Host class = { classNames ( 'taro-video-container' , { 'taro-video-type-fullscreen' : isFullScreen } ) } onTouchStart = { this . onTouchStartContainer } onClick = { this . onClickContainer } > < video class = 'taro-video-video' style = { { 'object-fit' : objectFit } } ref = { dom => { if ( dom ) { this . videoRef = dom as HTMLVideoElement ; } } } src = { src } autoplay = { autoplay } loop = { loop } muted = { muted } poster = { controls ? poster : undefined } playsinline webkit - playsinline onPlay = { this . handlePlay } onPause = { this . handlePause } onEnded = { this . handleEnded } onTimeUpdate = { this . handleTimeUpdate } onError = { this . handleError } onDurationChange = { this . handleDurationChange } onProgress = { this . handleProgress } onLoadedMetaData = { this . handleLoadedMetaData } { ... nativeProps } > 暂时不支持播放该视频 < /video> < taro - video - danmu ref = { dom => { if ( dom ) { this . danmuRef = dom as HTMLTaroVideoDanmuElement ; } } } enable = { _enableDanmu } / > { isFirst && showCenterPlayBtn && ! isPlaying && ( < div class = 'taro-video-cover' > < div class = 'taro-video-cover-play-button' onClick = { ( ) => this . play ( ) } /> < p class = 'taro-video-cover-duration' > { durationTime } < /p> < /div>)} < taro - video - control ref = { dom => { if ( dom ) { this . controlsRef = dom ; } } } controls = { controls } currentTime = { this . currentTime } duration = { this . duration || this . _duration || undefined } isPlaying = { this . isPlaying } pauseFunc = { this . _pause } playFunc = { this . _play } seekFunc = { this . _seek } showPlayBtn = { this . showPlayBtn } showProgress = { this . showProgress } > { showMuteBtn && ( < div class = { classNames ( 'taro-video-mute' , { 'taro-video-type-mute' : isMute } ) } onClick = { this . toggleMute } / > ) } { danmuBtn && ( < div class = { classNames ( 'taro-video-danmu-button' , { 'taro-video-danmu-button-active' : _enableDanmu } ) } onClick = { this . toggleDanmu } > 弹幕 < /div>)} { showFullscreenBtn && ( < div class = { classNames ( 'taro-video-fullscreen' , { 'taro-video-type-fullscreen' : isFullScreen } ) } onClick = { this . onClickFullScreenBtn } / > ) } < /taro-video-control> < div class = 'taro-video-toast taro-video-toast-volume' ref = { dom => { if ( dom ) { this . toastVolumeRef = dom ; } } } > < div class = 'taro-video-toast-title' > 音量 < /div> < div class = 'taro-video-toast-icon' / > < div class = 'taro-video-toast-value' > < div class = 'taro-video-toast-value-content' ref = { dom => { if ( dom ) { this . toastVolumeBarRef = dom ; } } } > < div class = 'taro-video-toast-volume-grids' > { Array ( 10 ) . fill ( 1 ) . map ( ( ) => ( < div class = 'taro-video-toast-volume-grids-item' / > ) ) } < /div> < /div> < /div> < /div> < div class = 'taro-video-toast taro-video-toast-progress' ref = { dom => { if ( dom ) { this . toastProgressRef = dom ; } } } > < div class = 'taro-video-toast-title' ref = { dom => { if ( dom ) { this . toastProgressTitleRef = dom ; } } } /> < /div> < /Host>); } }