tweb/src/lib/mediaPlayer.ts

421 lines
14 KiB
TypeScript
Raw Normal View History

2021-04-08 15:52:31 +02:00
/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
2022-08-04 08:49:54 +02:00
import appMediaPlaybackController from '../components/appMediaPlaybackController';
import {IS_APPLE_MOBILE, IS_MOBILE} from '../environment/userAgent';
import IS_TOUCH_SUPPORTED from '../environment/touchSupport';
import cancelEvent from '../helpers/dom/cancelEvent';
import ListenerSetter, {Listener} from '../helpers/listenerSetter';
2023-01-06 20:27:29 +01:00
import {ButtonMenuSync} from '../components/buttonMenu';
2022-08-04 08:49:54 +02:00
import {ButtonMenuToggleHandler} from '../components/buttonMenuToggle';
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';
import debounce from '../helpers/schedulers/debounce';
import overlayCounter from '../helpers/overlayCounter';
import onMediaLoad from '../helpers/onMediaLoad';
2022-08-09 17:35:11 +02:00
import {attachClickEvent} from '../helpers/dom/clickEvent';
2021-10-05 22:40:07 +02:00
2021-12-11 17:37:08 +01:00
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'];
2022-02-11 15:36:00 +01:00
protected video: HTMLVideoElement;
2021-12-11 17:37:08 +01:00
protected wrapper: HTMLDivElement;
protected progress: MediaProgressLine;
protected skin: 'default';
2020-04-25 03:17:50 +02:00
2021-12-11 17:37:08 +01:00
protected listenerSetter: ListenerSetter;
protected playbackRateButton: HTMLElement;
2022-04-15 20:04:56 +02:00
protected pipButton: HTMLElement;
2021-12-11 17:37:08 +01:00
/* protected videoParent: HTMLElement;
protected videoWhichChild: number; */
2021-08-21 00:30:41 +02:00
2022-02-11 15:36:00 +01:00
protected onPlaybackRackMenuToggle?: (open: boolean) => void;
2022-04-15 20:04:56 +02:00
protected onPip?: (pip: boolean) => void;
protected onPipClose?: () => void;
2022-02-11 15:36:00 +01:00
2023-03-06 15:56:03 +01:00
constructor({
video,
play = false,
streamable = false,
duration,
onPlaybackRackMenuToggle,
onPip,
onPipClose
}: {
2022-08-04 08:49:54 +02:00
video: HTMLVideoElement,
play?: boolean,
streamable?: boolean,
2022-02-11 15:36:00 +01:00
duration?: number,
2022-04-15 20:04:56 +02:00
onPlaybackRackMenuToggle?: VideoPlayer['onPlaybackRackMenuToggle'],
onPip?: VideoPlayer['onPip'],
onPipClose?: VideoPlayer['onPipClose']
2022-02-11 15:36:00 +01:00
}) {
2021-12-11 17:37:08 +01:00
super();
2021-08-11 15:39:49 +02:00
2022-02-11 15:36:00 +01:00
this.video = video;
2020-04-25 03:17:50 +02:00
this.wrapper = document.createElement('div');
this.wrapper.classList.add('ckin__player');
2022-02-11 15:36:00 +01:00
this.onPlaybackRackMenuToggle = onPlaybackRackMenuToggle;
2022-04-15 20:04:56 +02:00
this.onPip = onPip;
this.onPipClose = onPipClose;
2022-02-11 15:36:00 +01:00
this.listenerSetter = new ListenerSetter();
2021-12-11 17:37:08 +01:00
this.setup({
2022-08-04 08:49:54 +02:00
element: this.wrapper,
listenerSetter: this.listenerSetter,
2021-12-11 17:37:08 +01:00
canHideControls: () => {
return !this.video.paused && (!this.playbackRateButton || !this.playbackRateButton.classList.contains('menu-open'));
2021-12-11 17:37:08 +01:00
},
2022-02-11 15:36:00 +01:00
showOnLeaveToClassName: 'media-viewer-caption',
ignoreClickClassName: 'ckin__controls'
2021-12-11 17:37:08 +01:00
});
2020-04-25 03:17:50 +02:00
video.parentNode.insertBefore(this.wrapper, video);
this.wrapper.appendChild(video);
2021-07-20 00:24:29 +02:00
this.skin = 'default';
2020-04-25 03:17:50 +02:00
2021-02-01 03:09:18 +01:00
this.stylePlayer(duration);
this.setBtnMenuToggle();
2020-04-25 03:17:50 +02:00
if(this.skin === 'default') {
const controls = this.wrapper.querySelector('.default__controls.ckin__controls') as HTMLDivElement;
this.progress = new MediaProgressLine({
onSeekStart: () => {
this.wrapper.classList.add('is-seeking');
},
onSeekEnd: () => {
this.wrapper.classList.remove('is-seeking');
}
});
2023-03-06 15:56:03 +01:00
this.progress.setMedia({
media: video,
streamable,
duration
});
2020-04-25 03:17:50 +02:00
controls.prepend(this.progress.container);
}
if(play/* && video.paused */) {
const promise = video.play();
promise.catch((err: Error) => {
2021-02-04 01:30:23 +01:00
if(err.name === 'NotAllowedError') {
video.muted = true;
video.autoplay = true;
video.play();
}
2020-08-30 13:06:24 +02:00
}).finally(() => { // due to autoplay, play will not call
this.wrapper.classList.toggle('is-playing', !this.video.paused);
});
2020-04-25 03:17:50 +02:00
}
}
2021-02-01 03:09:18 +01:00
private stylePlayer(initDuration: number) {
2021-12-11 17:37:08 +01:00
const {wrapper, video, skin, listenerSetter} = this;
2020-04-25 03:17:50 +02:00
2021-12-11 17:37:08 +01:00
wrapper.classList.add(skin);
2022-08-04 08:49:54 +02:00
2020-08-26 18:14:23 +02:00
const html = this.buildControls();
2021-12-11 17:37:08 +01:00
wrapper.insertAdjacentHTML('beforeend', html);
2020-10-28 19:20:01 +01:00
let timeDuration: HTMLElement;
2022-08-04 08:49:54 +02:00
2020-04-25 03:17:50 +02:00
if(skin === 'default') {
this.playbackRateButton = this.wrapper.querySelector('.playback-rate') as HTMLElement;
2022-04-15 20:04:56 +02:00
this.pipButton = this.wrapper.querySelector('.pip') as HTMLElement;
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
const toggle = wrapper.querySelectorAll('.toggle') as NodeListOf<HTMLElement>;
const fullScreenButton = wrapper.querySelector('.fullscreen') as HTMLElement;
const timeElapsed = wrapper.querySelector('#time-elapsed');
timeDuration = wrapper.querySelector('#time-duration') as HTMLElement;
2022-07-26 17:24:29 +02:00
timeDuration.textContent = toHHMMSS(video.duration | 0);
2020-08-26 21:25:43 +02:00
2021-12-11 17:37:08 +01:00
const volumeSelector = new VolumeSelector(listenerSetter);
2020-08-26 21:25:43 +02:00
2021-12-11 17:37:08 +01:00
const leftControls = wrapper.querySelector('.left-controls');
2021-10-29 15:05:55 +02:00
volumeSelector.btn.classList.remove('btn-icon');
2021-10-05 22:40:07 +02:00
leftControls.insertBefore(volumeSelector.btn, timeElapsed.parentElement);
2020-08-26 21:25:43 +02:00
2020-04-25 03:17:50 +02:00
Array.from(toggle).forEach((button) => {
2022-07-26 07:22:46 +02:00
attachClickEvent(button, () => {
2020-04-25 03:17:50 +02:00
this.togglePlay();
2022-07-26 07:22:46 +02:00
}, {listenerSetter: this.listenerSetter});
2020-04-25 03:17:50 +02:00
});
2020-08-27 20:25:47 +02:00
2022-04-15 20:04:56 +02:00
if(this.pipButton) {
2022-07-26 07:22:46 +02:00
attachClickEvent(this.pipButton, () => {
2022-04-15 20:04:56 +02:00
this.video.requestPictureInPicture();
2022-07-26 07:22:46 +02:00
}, {listenerSetter: this.listenerSetter});
2022-04-15 20:04:56 +02:00
const onPip = (pip: boolean) => {
this.wrapper.style.visibility = pip ? 'hidden': '';
if(this.onPip) {
this.onPip(pip);
}
2022-04-15 20:04:56 +02:00
};
const debounceTime = 20;
const debouncedPip = debounce(onPip, debounceTime, false, true);
2022-04-15 20:04:56 +02:00
listenerSetter.add(video)('enterpictureinpicture', () => {
debouncedPip(true);
listenerSetter.add(video)('leavepictureinpicture', () => {
const onPause = () => {
clearTimeout(timeout);
if(this.onPipClose) {
this.onPipClose();
}
};
const listener = listenerSetter.add(video)('pause', onPause, {once: true}) as any as Listener;
const timeout = setTimeout(() => {
listenerSetter.remove(listener);
}, debounceTime);
}, {once: true});
2022-04-15 20:04:56 +02:00
});
listenerSetter.add(video)('leavepictureinpicture', () => {
debouncedPip(false);
2022-04-15 20:04:56 +02:00
});
}
2021-12-11 17:37:08 +01:00
if(!IS_TOUCH_SUPPORTED) {
2022-07-26 07:22:46 +02:00
attachClickEvent(video, () => {
2020-08-28 23:06:28 +02:00
this.togglePlay();
2022-07-26 07:22:46 +02:00
}, {listenerSetter: this.listenerSetter});
2021-12-11 17:37:08 +01:00
listenerSetter.add(document)('keydown', (e: KeyboardEvent) => {
if(overlayCounter.overlaysActive > 1 || document.pictureInPictureElement === video) { // forward popup is active, etc
2021-08-11 15:39:49 +02:00
return;
}
2021-12-01 13:46:58 +01:00
const {key, code} = e;
let good = true;
2021-12-01 13:46:58 +01:00
if(code === 'KeyF') {
2021-12-11 17:37:08 +01:00
this.toggleFullScreen();
2021-12-01 13:46:58 +01:00
} else if(code === 'KeyM') {
2021-10-05 22:40:07 +02:00
appMediaPlaybackController.muted = !appMediaPlaybackController.muted;
2021-12-01 13:46:58 +01:00
} 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];
}
2021-12-11 18:10:13 +01:00
} else if(wrapper.classList.contains('ckin__fullscreen') && (key === 'ArrowLeft' || key === 'ArrowRight')) {
if(key === 'ArrowLeft') appMediaPlaybackController.seekBackward({action: 'seekbackward'});
2021-10-28 13:46:48 +02:00
else appMediaPlaybackController.seekForward({action: 'seekforward'});
} else {
good = false;
}
if(good) {
cancelEvent(e);
return false;
}
});
}
2020-04-25 03:17:50 +02:00
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('dblclick', () => {
if(!IS_TOUCH_SUPPORTED) {
2021-12-11 17:37:08 +01:00
this.toggleFullScreen();
}
});
2022-07-26 07:22:46 +02:00
attachClickEvent(fullScreenButton, () => {
2021-12-11 17:37:08 +01:00
this.toggleFullScreen();
2022-07-26 07:22:46 +02:00
}, {listenerSetter: this.listenerSetter});
2020-04-25 03:17:50 +02:00
2021-12-11 17:37:08 +01:00
addFullScreenListener(wrapper, this.onFullScreen.bind(this, fullScreenButton), listenerSetter);
2020-10-28 19:20:01 +01:00
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('timeupdate', () => {
2022-07-26 17:24:29 +02:00
timeElapsed.textContent = toHHMMSS(video.currentTime | 0);
2020-10-28 19:20:01 +01:00
});
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('play', () => {
wrapper.classList.add('played');
2021-08-11 15:39:49 +02:00
2022-02-11 15:36:00 +01:00
if(!IS_TOUCH_SUPPORTED) {
listenerSetter.add(video)('play', () => {
this.hideControls(true);
});
}
2022-02-10 11:55:20 +01:00
}, {once: true});
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('pause', () => {
2021-08-21 00:30:41 +02:00
this.showControls(false);
2021-08-11 15:39:49 +02:00
});
listenerSetter.add(appMediaPlaybackController)('playbackParams', () => {
this.setPlaybackRateIcon();
});
2020-04-25 03:17:50 +02:00
}
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('play', () => {
wrapper.classList.add('is-playing');
});
2021-12-11 17:37:08 +01:00
listenerSetter.add(video)('pause', () => {
wrapper.classList.remove('is-playing');
});
2021-02-01 03:09:18 +01:00
if(video.duration || initDuration) {
2022-07-26 17:24:29 +02:00
timeDuration.textContent = toHHMMSS(Math.round(video.duration || initDuration));
2020-04-25 03:17:50 +02:00
} else {
onMediaLoad(video).then(() => {
2022-07-26 17:24:29 +02:00
timeDuration.textContent = toHHMMSS(Math.round(video.duration));
2020-04-25 03:17:50 +02:00
});
}
}
protected togglePlay(isPaused = this.video.paused) {
this.video[isPaused ? 'play' : 'pause']();
2020-04-25 03:17:50 +02:00
}
private buildControls() {
2020-08-26 18:14:23 +02:00
const skin = this.skin;
2020-04-25 03:17:50 +02:00
if(skin === 'default') {
2020-08-26 21:25:43 +02:00
return `
2020-08-30 13:06:24 +02:00
<button class="${skin}__button--big toggle tgico" title="Toggle Play"></button>
2020-08-26 18:14:23 +02:00
<div class="${skin}__gradient-bottom ckin__controls"></div>
<div class="${skin}__controls ckin__controls">
<div class="bottom-controls">
<div class="left-controls">
2021-07-19 21:53:52 +02:00
<button class="btn-icon ${skin}__button toggle tgico" title="Toggle Video"></button>
2020-08-26 18:14:23 +02:00
<div class="time">
<time id="time-elapsed">0:00</time>
<span> / </span>
<time id="time-duration">0:00</time>
</div>
</div>
<div class="right-controls">
<button class="btn-icon ${skin}__button btn-menu-toggle playback-rate night" title="Playback Rate"></button>
2022-04-15 20:04:56 +02:00
${!IS_MOBILE && document.pictureInPictureEnabled ? `<button class="btn-icon ${skin}__button pip tgico-pip" title="Picture-in-Picture"></button>` : ''}
2021-07-19 21:53:52 +02:00
<button class="btn-icon ${skin}__button fullscreen tgico-fullscreen" title="Full Screen"></button>
2020-08-26 18:14:23 +02:00
</div>
</div>
2020-08-26 21:25:43 +02:00
</div>`;
2020-04-25 03:17:50 +02:00
}
}
2021-07-18 20:11:26 +02:00
protected setBtnMenuToggle() {
2023-03-10 10:36:03 +01:00
const buttons = VideoPlayer.PLAYBACK_RATES.map((rate, idx) => {
const buttonOptions: Parameters<typeof ButtonMenuSync>[0]['buttons'][0] = {
// icon: VideoPlayer.PLAYBACK_RATES_ICONS[idx],
2022-08-04 08:49:54 +02:00
regularText: rate + 'x',
onClick: () => {
appMediaPlaybackController.playbackRate = rate;
}
2021-07-19 21:53:52 +02:00
};
2023-03-10 10:36:03 +01:00
return buttonOptions;
2021-07-18 20:11:26 +02:00
});
2023-01-06 20:27:29 +01:00
const btnMenu = ButtonMenuSync({buttons});
2021-07-18 20:11:26 +02:00
btnMenu.classList.add('top-left');
2023-01-06 20:27:29 +01:00
ButtonMenuToggleHandler({
el: this.playbackRateButton,
onOpen: this.onPlaybackRackMenuToggle ? () => {
2022-02-11 15:36:00 +01:00
this.onPlaybackRackMenuToggle(true);
2022-08-04 08:49:54 +02:00
} : undefined,
2023-01-06 20:27:29 +01:00
onClose: this.onPlaybackRackMenuToggle ? () => {
2022-02-11 15:36:00 +01:00
this.onPlaybackRackMenuToggle(false);
} : undefined
2023-01-06 20:27:29 +01:00
});
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]);
2021-07-18 20:11:26 +02:00
}
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
protected toggleFullScreen() {
2020-08-27 20:25:47 +02:00
const player = this.wrapper;
2020-09-01 18:44:53 +02:00
// * https://caniuse.com/#feat=fullscreen
if(IS_APPLE_MOBILE) {
2020-09-01 18:44:53 +02:00
const video = this.video as any;
video.webkitEnterFullscreen();
video.enterFullscreen();
return;
}
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
if(!isFullScreen()) {
2020-08-27 20:25:47 +02:00
/* const videoParent = this.video.parentElement;
const videoWhichChild = whichChild(this.video);
2021-02-04 01:30:23 +01:00
const needVideoRemount = videoParent !== player;
2020-08-27 20:25:47 +02:00
if(needVideoRemount) {
this.videoParent = videoParent;
this.videoWhichChild = videoWhichChild;
player.prepend(this.video);
} */
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
requestFullScreen(player);
2020-04-25 03:17:50 +02:00
} else {
2020-08-27 20:25:47 +02:00
/* 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;
} */
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
cancelFullScreen();
}
}
2022-08-04 08:49:54 +02:00
2021-12-11 17:37:08 +01:00
protected onFullScreen(fullScreenButton: HTMLElement) {
const isFull = isFullScreen();
this.wrapper.classList.toggle('ckin__fullscreen', isFull);
if(!isFull) {
2020-04-25 03:17:50 +02:00
fullScreenButton.classList.remove('tgico-smallscreen');
fullScreenButton.classList.add('tgico-fullscreen');
fullScreenButton.setAttribute('title', 'Full Screen');
2021-12-11 17:37:08 +01:00
} else {
fullScreenButton.classList.remove('tgico-fullscreen');
fullScreenButton.classList.add('tgico-smallscreen');
fullScreenButton.setAttribute('title', 'Exit Full Screen');
2020-04-25 03:17:50 +02:00
}
}
public setTimestamp(timestamp: number) {
this.video.currentTime = timestamp;
this.togglePlay(true);
}
2022-04-15 20:04:56 +02:00
public cleanup() {
2021-08-11 15:39:49 +02:00
super.cleanup();
this.listenerSetter.removeAll();
this.progress.removeListeners();
2022-04-15 20:04:56 +02:00
this.onPlaybackRackMenuToggle = this.onPip = undefined;
}
2020-04-25 03:17:50 +02:00
}