/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import appMediaPlaybackController from "../components/appMediaPlaybackController"; import { IS_APPLE_MOBILE, IS_MOBILE } from "../environment/userAgent"; import { IS_TOUCH_SUPPORTED } from "../environment/touchSupport"; import { onMediaLoad } from "../helpers/files"; import cancelEvent from "../helpers/dom/cancelEvent"; import ListenerSetter from "../helpers/listenerSetter"; import ButtonMenu from "../components/buttonMenu"; import { ButtonMenuToggleHandler } from "../components/buttonMenuToggle"; import rootScope from "./rootScope"; import ControlsHover from "../helpers/dom/controlsHover"; import { addFullScreenListener, cancelFullScreen, isFullScreen, requestFullScreen } from "../helpers/dom/fullScreen"; import toHHMMSS from "../helpers/string/toHHMMSS"; import MediaProgressLine from "../components/mediaProgressLine"; import VolumeSelector from "../components/volumeSelector"; export default class VideoPlayer extends ControlsHover { private static PLAYBACK_RATES = [0.5, 1, 1.5, 2]; private static PLAYBACK_RATES_ICONS = ['playback_05', 'playback_1x', 'playback_15', 'playback_2x']; protected video: HTMLVideoElement; protected wrapper: HTMLDivElement; protected progress: MediaProgressLine; protected skin: 'default'; protected listenerSetter: ListenerSetter; protected playbackRateButton: HTMLElement; protected pipButton: HTMLElement; /* protected videoParent: HTMLElement; protected videoWhichChild: number; */ protected onPlaybackRackMenuToggle?: (open: boolean) => void; protected onPip?: (pip: boolean) => void; constructor({video, play = false, streamable = false, duration, onPlaybackRackMenuToggle, onPip}: { video: HTMLVideoElement, play?: boolean, streamable?: boolean, duration?: number, onPlaybackRackMenuToggle?: VideoPlayer['onPlaybackRackMenuToggle'], onPip: VideoPlayer['onPip'] }) { super(); this.video = video; this.wrapper = document.createElement('div'); this.wrapper.classList.add('ckin__player'); this.onPlaybackRackMenuToggle = onPlaybackRackMenuToggle; this.onPip = onPip; this.listenerSetter = new ListenerSetter(); this.setup({ element: this.wrapper, listenerSetter: this.listenerSetter, canHideControls: () => { return !this.video.paused && (!this.playbackRateButton || !this.playbackRateButton.classList.contains('menu-open')); }, showOnLeaveToClassName: 'media-viewer-caption', ignoreClickClassName: 'ckin__controls' }); video.parentNode.insertBefore(this.wrapper, video); this.wrapper.appendChild(video); this.skin = 'default'; this.stylePlayer(duration); this.setBtnMenuToggle(); if(this.skin === 'default') { const controls = this.wrapper.querySelector('.default__controls.ckin__controls') as HTMLDivElement; this.progress = new MediaProgressLine(video, streamable); controls.prepend(this.progress.container); } if(play/* && video.paused */) { const promise = video.play(); promise.catch((err: Error) => { if(err.name === 'NotAllowedError') { video.muted = true; video.autoplay = true; video.play(); } }).finally(() => { // due to autoplay, play will not call this.wrapper.classList.toggle('is-playing', !this.video.paused); }); //(this.wrapper.querySelector('.toggle') as HTMLButtonElement).click(); } } private stylePlayer(initDuration: number) { const {wrapper, video, skin, listenerSetter} = this; wrapper.classList.add(skin); const html = this.buildControls(); wrapper.insertAdjacentHTML('beforeend', html); let timeDuration: HTMLElement; if(skin === 'default') { this.playbackRateButton = this.wrapper.querySelector('.playback-rate') as HTMLElement; this.pipButton = this.wrapper.querySelector('.pip') as HTMLElement; const toggle = wrapper.querySelectorAll('.toggle') as NodeListOf; const fullScreenButton = wrapper.querySelector('.fullscreen') as HTMLElement; const timeElapsed = wrapper.querySelector('#time-elapsed'); timeDuration = wrapper.querySelector('#time-duration') as HTMLElement; timeDuration.innerHTML = toHHMMSS(video.duration | 0); const volumeSelector = new VolumeSelector(listenerSetter); const leftControls = wrapper.querySelector('.left-controls'); volumeSelector.btn.classList.remove('btn-icon'); leftControls.insertBefore(volumeSelector.btn, timeElapsed.parentElement); Array.from(toggle).forEach((button) => { listenerSetter.add(button)('click', () => { this.togglePlay(); }); }); if(this.pipButton) { listenerSetter.add(this.pipButton)('click', () => { this.video.requestPictureInPicture(); }); const onPip = (pip: boolean) => { this.wrapper.style.visibility = pip ? 'hidden': ''; this.onPip(pip); }; listenerSetter.add(video)('enterpictureinpicture', () => { onPip(true); }); listenerSetter.add(video)('leavepictureinpicture', () => { onPip(false); }); } if(!IS_TOUCH_SUPPORTED) { listenerSetter.add(video)('click', () => { this.togglePlay(); }); listenerSetter.add(document)('keydown', (e: KeyboardEvent) => { if(rootScope.overlaysActive > 1 || document.pictureInPictureElement) { // forward popup is active, etc return; } const {key, code} = e; let good = true; if(code === 'KeyF') { this.toggleFullScreen(); } else if(code === 'KeyM') { appMediaPlaybackController.muted = !appMediaPlaybackController.muted; } else if(code === 'Space') { this.togglePlay(); } else if(e.altKey && (code === 'Equal' || code === 'Minus')) { const add = code === 'Equal' ? 1 : -1; const playbackRate = appMediaPlaybackController.playbackRate; const idx = VideoPlayer.PLAYBACK_RATES.indexOf(playbackRate); const nextIdx = idx + add; if(nextIdx >= 0 && nextIdx < VideoPlayer.PLAYBACK_RATES.length) { appMediaPlaybackController.playbackRate = VideoPlayer.PLAYBACK_RATES[nextIdx]; } } else if(wrapper.classList.contains('ckin__fullscreen') && (key === 'ArrowLeft' || key === 'ArrowRight')) { if(key === 'ArrowLeft') appMediaPlaybackController.seekBackward({action: 'seekbackward'}); else appMediaPlaybackController.seekForward({action: 'seekforward'}); } else { good = false; } if(good) { cancelEvent(e); return false; } }); } listenerSetter.add(video)('dblclick', () => { if(!IS_TOUCH_SUPPORTED) { this.toggleFullScreen(); } }); listenerSetter.add(fullScreenButton)('click', () => { this.toggleFullScreen(); }); addFullScreenListener(wrapper, this.onFullScreen.bind(this, fullScreenButton), listenerSetter); listenerSetter.add(video)('timeupdate', () => { timeElapsed.innerHTML = toHHMMSS(video.currentTime | 0); }); listenerSetter.add(video)('play', () => { wrapper.classList.add('played'); if(!IS_TOUCH_SUPPORTED) { listenerSetter.add(video)('play', () => { this.hideControls(true); }); } }, {once: true}); listenerSetter.add(video)('pause', () => { this.showControls(false); }); listenerSetter.add(rootScope)('media_playback_params', () => { this.setPlaybackRateIcon(); }); } listenerSetter.add(video)('play', () => { wrapper.classList.add('is-playing'); }); listenerSetter.add(video)('pause', () => { wrapper.classList.remove('is-playing'); }); if(video.duration || initDuration) { timeDuration.innerHTML = toHHMMSS(Math.round(video.duration || initDuration)); } else { onMediaLoad(video).then(() => { timeDuration.innerHTML = toHHMMSS(Math.round(video.duration)); }); } } protected togglePlay() { this.video[this.video.paused ? 'play' : 'pause'](); } private buildControls() { const skin = this.skin; if(skin === 'default') { return `
/
${!IS_MOBILE && document.pictureInPictureEnabled ? `` : ''}
`; } } protected setBtnMenuToggle() { const buttons: Parameters[0] = VideoPlayer.PLAYBACK_RATES.map((rate, idx) => { return { // icon: VideoPlayer.PLAYBACK_RATES_ICONS[idx], regularText: rate + 'x', onClick: () => { appMediaPlaybackController.playbackRate = rate; } }; }); const btnMenu = ButtonMenu(buttons); btnMenu.classList.add('top-left'); ButtonMenuToggleHandler( this.playbackRateButton, this.onPlaybackRackMenuToggle ? () => { this.onPlaybackRackMenuToggle(true); } : undefined, undefined, this.onPlaybackRackMenuToggle ? () => { this.onPlaybackRackMenuToggle(false); } : undefined ); this.playbackRateButton.append(btnMenu); this.setPlaybackRateIcon(); } protected setPlaybackRateIcon() { const playbackRateButton = this.playbackRateButton; VideoPlayer.PLAYBACK_RATES_ICONS.forEach((className) => { className = 'tgico-' + className; playbackRateButton.classList.remove(className); }); let idx = VideoPlayer.PLAYBACK_RATES.indexOf(appMediaPlaybackController.playbackRate); if(idx === -1) idx = VideoPlayer.PLAYBACK_RATES.indexOf(1); playbackRateButton.classList.add('tgico-' + VideoPlayer.PLAYBACK_RATES_ICONS[idx]); } protected toggleFullScreen() { const player = this.wrapper; // * https://caniuse.com/#feat=fullscreen if(IS_APPLE_MOBILE) { const video = this.video as any; video.webkitEnterFullscreen(); video.enterFullscreen(); return; } if(!isFullScreen()) { /* const videoParent = this.video.parentElement; const videoWhichChild = whichChild(this.video); const needVideoRemount = videoParent !== player; if(needVideoRemount) { this.videoParent = videoParent; this.videoWhichChild = videoWhichChild; player.prepend(this.video); } */ requestFullScreen(player); } else { /* if(this.videoParent) { const {videoWhichChild, videoParent} = this; if(!videoWhichChild) { videoParent.prepend(this.video); } else { videoParent.insertBefore(this.video, videoParent.children[videoWhichChild]); } this.videoParent = null; this.videoWhichChild = -1; } */ cancelFullScreen(); } } protected onFullScreen(fullScreenButton: HTMLElement) { const isFull = isFullScreen(); this.wrapper.classList.toggle('ckin__fullscreen', isFull); if(!isFull) { fullScreenButton.classList.remove('tgico-smallscreen'); fullScreenButton.classList.add('tgico-fullscreen'); fullScreenButton.setAttribute('title', 'Full Screen'); } else { fullScreenButton.classList.remove('tgico-fullscreen'); fullScreenButton.classList.add('tgico-smallscreen'); fullScreenButton.setAttribute('title', 'Exit Full Screen'); } } public cleanup() { super.cleanup(); this.listenerSetter.removeAll(); this.progress.removeListeners(); this.onPlaybackRackMenuToggle = this.onPip = undefined; } }