blob: 60e3a5c2d7dd4f2122dab698d50a7114f697579e [file] [log] [blame]
import {DownloadBuffer} from '../utils/download_buffer';
import {PlayMode, SwitchMode} from '../utils/enums';
import {LimitedSourceBuffer} from '../utils/limited_source_buffer';
import {Media} from '../utils/media';
import {Component, renderComponent} from './component';
import {DownloadBufferInfo} from './download_buffer_info';
import {SourceBufferInfo} from './source_buffer_info';
import {VideoInfo} from './video_info';
interface Props {
video?: string|string[];
audio?: string|string[];
switchMode?: SwitchMode;
}
/** A component that controls video playback. */
export class Player extends Component<Props> {
/** The videos to play. */
private readonly videos: Media[];
/** The audios to play. */
private readonly audios: Media[];
/** The <video> element. */
private videoEl!: HTMLVideoElement;
/** The video SourceBuffer */
private videoSourceBuffer!: SourceBuffer;
/** The audio SourceBuffer */
private audioSourceBuffer!: SourceBuffer;
/** max(videoSourceBuffer.writeHead - videoEl.currentTime) */
private maxVideoWriteHeadDistance!: number;
/** max(audioSourceBuffer.writeHead - videoEl.currentTime) */
private maxAudioWriteHeadDistance!: number;
/** The element displaying video download buffer info. */
private videoDownloadBufferInfo!: Element;
/** The element displaying audio download buffer info. */
private audioDownloadBufferInfo!: Element;
/** The element displaying video source buffer info. */
private videoSourceBufferInfo!: Element;
/** The element displaying audio source buffer info. */
private audioSourceBufferInfo!: Element;
/** The element displaying video info. */
private videoInfo!: Element;
/**
* Switch mode defines whether to create a new playback session or <video>
* element after a media finishes playing.
*/
private switchMode?: SwitchMode;
/** Play mode defines whether to play progressively or adaptively. */
private playMode?: PlayMode;
constructor(props: Props) {
super(props);
this.videos = convertToMediaArray(props.video);
this.audios = convertToMediaArray(props.audio);
this.maxVideoWriteHeadDistance = 0;
this.maxAudioWriteHeadDistance = 0;
}
/** @override */
render() {
return `
<div class="video-container"></div>
<div class="video-info"></div>
<div class="video-source-buffer-info"></div>
<div class="audio-source-buffer-info"></div>
<div class="video-download-buffer-info"></div>
<div class="audio-download-buffer-info"></div>
`;
}
/** @override */
async afterRender() {
this.videoInfo = this.el.querySelector('.video-info') as HTMLVideoElement;
this.videoEl = document.createElement('video');
this.videoEl.style.width = '1280px';
this.videoEl.style.height = '720px';
this.videoEl.addEventListener('timeupdate', () => {
this.renderVideoInfo();
});
this.videoEl.addEventListener('durationchange', (evt) => {
this.renderVideoInfo();
});
this.el.querySelector('.video-container')!.appendChild(this.videoEl);
this.videoSourceBufferInfo =
this.el.querySelector('.video-source-buffer-info')!;
this.audioSourceBufferInfo =
this.el.querySelector('.audio-source-buffer-info')!;
this.videoDownloadBufferInfo =
this.el.querySelector('.video-download-buffer-info')!;
this.audioDownloadBufferInfo =
this.el.querySelector('.audio-download-buffer-info')!;
this.play();
}
private renderVideoInfo() {
var h5vccAudioConnectors = '';
try {
h5vccAudioConnectors = this.videoEl.h5vccAudioConnectors;
} catch (error) {}
renderComponent(
VideoInfo, {
duration: this.videoEl.duration,
currentTime: this.videoEl.currentTime,
audioConnectors: h5vccAudioConnectors,
},
this.videoInfo);
if (this.videoSourceBuffer) {
this.maxVideoWriteHeadDistance =
Math.max(this.maxVideoWriteHeadDistance,
this.videoSourceBuffer.writeHead - this.videoEl.currentTime);
renderComponent(
SourceBufferInfo,
{name: 'Video', sourceBuffer: this.videoSourceBuffer,
maxWriteHeadDistance: this.maxVideoWriteHeadDistance},
this.videoSourceBufferInfo);
}
if (this.audioSourceBuffer) {
this.maxAudioWriteHeadDistance =
Math.max(this.maxAudioWriteHeadDistance,
this.audioSourceBuffer.writeHead - this.videoEl.currentTime);
renderComponent(
SourceBufferInfo,
{name: 'Audio', sourceBuffer: this.audioSourceBuffer,
maxWriteHeadDistance: this.maxAudioWriteHeadDistance},
this.audioSourceBufferInfo);
}
}
private async play() {
// Do not reorder methods.
await this.validateParams();
await this.initPlayMode();
await this.initSwitchMode(this.props.switchMode);
if (this.playMode === PlayMode.PROGRESSIVE) {
this.playProgressiveVideo();
} else {
this.playAdaptiveVideo();
}
}
private async initSwitchMode(override?: SwitchMode) {
// TODO: Add support for RELOAD and RECREATE_ELEMENT.
this.switchMode = SwitchMode.NORMAL;
}
private async initPlayMode() {
// Because `validateProgressive_` ensures progressive videos cannot be
// played together with adaptive audios/videos. We can decide whether to use
// progressive mode by checking the type of the first videos.
if (this.videos.length > 0 && await this.videos[0].isProgressive()) {
this.playMode = PlayMode.PROGRESSIVE;
} else {
this.playMode = PlayMode.ADAPTIVE;
}
}
/**
* Plays all videos as progressive videos, assuming only progressive mp4
* videos are provided.
*/
private playProgressiveVideo(videoIndex = 0) {
const currentMedia = this.videos[videoIndex];
if (!currentMedia) {
return;
}
this.videoEl.src = currentMedia.url;
const handleVideoEnd = () => {
this.videoEl.removeEventListener('ended', handleVideoEnd);
this.playProgressiveVideo(videoIndex++);
};
this.videoEl.addEventListener('ended', handleVideoEnd);
this.videoEl.play();
}
/**
* Plays all videos as adaptive videos.
* TODO: dynamically calculate the source buffer MIME.
*/
private playAdaptiveVideo() {
const ms = new MediaSource();
this.videoEl.src = URL.createObjectURL(ms);
ms.addEventListener('sourceopen', async () => {
if (this.videos.length > 0) {
const videoSourceBuffer =
ms.addSourceBuffer('video/mp4; codecs="avc1.640028"');
this.videoSourceBuffer = videoSourceBuffer;
const downloadBuffer = new DownloadBuffer(this.videos);
downloadBuffer.register((reportMap) => {
renderComponent(
DownloadBufferInfo, {mediaList: this.videos, reportMap},
this.videoDownloadBufferInfo);
});
new LimitedSourceBuffer(
this.videoEl, videoSourceBuffer, this.videos, downloadBuffer);
}
if (this.audios.length > 0) {
const audioSourceBuffer =
ms.addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');
this.audioSourceBuffer = audioSourceBuffer;
const downloadBuffer = new DownloadBuffer(this.audios);
downloadBuffer.register(
(reportMap) => {renderComponent(
DownloadBufferInfo, {mediaList: this.audios, reportMap},
this.audioDownloadBufferInfo)});
new LimitedSourceBuffer(
this.videoEl, audioSourceBuffer, this.audios, downloadBuffer);
}
});
this.videoEl.play();
}
private async validateParams() {
this.validateMediaExists();
await this.validateProgressive_();
}
/** Validates at least one video or audio is provided. */
private validateMediaExists() {
if (this.videos.length === 0 && this.audios.length === 0) {
throw new Error(
`No audio or video is specified. Please pass values to 'audio=' or ` +
`'video=' params.`);
}
}
/**
* Validates progressive videos are not played together with adaptive audios.
*/
async validateProgressive_() {
let progressiveVideosCount = 0;
for (const video of this.videos) {
if (await video.isProgressive()) {
progressiveVideosCount++;
}
}
if (progressiveVideosCount > 0 &&
(progressiveVideosCount !== this.videos.length ||
this.audios.length > 0)) {
throw new Error(
'Progressive video[s] cannot be played together with adaptive ' +
'video[s] and audio[s]');
}
}
}
/** Converts filenames to a list of media. */
function convertToMediaArray(filenames?: string|string[]): Media[] {
if (!filenames) {
return [];
}
if (!Array.isArray(filenames)) {
return [new Media(filenames)];
}
return filenames.map(filename => new Media(filename));
}