diff --git a/packages/playback/.size-limit.json b/packages/playback/.size-limit.json index a918f65..86201bb 100644 --- a/packages/playback/.size-limit.json +++ b/packages/playback/.size-limit.json @@ -1,26 +1,26 @@ [ { "path": "dist/player/core/cjs/index.min.js", - "limit": "1 s" + "limit": "800ms" }, { "path": "dist/player/core/es/index.min.js", - "limit": "1 s" + "limit": "800ms" }, { "path": "dist/player/core/iife/index.min.js", - "limit": "1 s" + "limit": "800ms" }, { "path": "dist/player-with-worker/core/cjs/index.min.js", - "limit": "1 s" + "limit": "800ms" }, { "path": "dist/player-with-worker/core/es/index.min.js", - "limit": "1 s" + "limit": "800ms" }, { "path": "dist/player-with-worker/core/iife/index.min.js", - "limit": "1 s" + "limit": "800ms" } ] diff --git a/packages/playback/src/lib/consts/text-tracks.ts b/packages/playback/src/lib/consts/text-tracks.ts new file mode 100644 index 0000000..c9a93b3 --- /dev/null +++ b/packages/playback/src/lib/consts/text-tracks.ts @@ -0,0 +1 @@ +export const Thumbnails = 'thumbnails'; diff --git a/packages/playback/src/lib/models/player-text-tracks.ts b/packages/playback/src/lib/models/player-text-tracks.ts new file mode 100644 index 0000000..b75bdb4 --- /dev/null +++ b/packages/playback/src/lib/models/player-text-tracks.ts @@ -0,0 +1,31 @@ +import type { IPlayerTextTrack } from '../types/text-track.declarations'; + +export class PlayerTextTrack implements IPlayerTextTrack { + public readonly id: string; + public readonly activeCues: TextTrackCueList | null; + public readonly cues: TextTrackCueList | null; + public readonly kind: TextTrackKind; + public readonly label: string; + public readonly language: string; + public readonly mode: string; + + public constructor(textTrack: TextTrack) { + this.id = textTrack.id; + this.activeCues = textTrack.activeCues; + this.cues = textTrack.cues; + this.kind = textTrack.kind as TextTrackKind; + this.label = textTrack.label; + this.language = textTrack.language; + this.mode = textTrack.mode; + } + + public static fromTextTracks(textTrackList: TextTrackList): Array { + const playerTextTracks = []; + for (let i = 0; i < textTrackList.length; i++) { + if (textTrackList[i].kind !== 'metadata') { + playerTextTracks.push(new PlayerTextTrack(textTrackList[i])); + } + } + return playerTextTracks; + } +} diff --git a/packages/playback/src/lib/models/player-thumbnail-tracks.ts b/packages/playback/src/lib/models/player-thumbnail-tracks.ts new file mode 100644 index 0000000..e87e5e4 --- /dev/null +++ b/packages/playback/src/lib/models/player-thumbnail-tracks.ts @@ -0,0 +1,22 @@ +import { Thumbnails } from '../consts/text-tracks'; +import type { IPlayerThumbnailTrack } from '../types/thumbnail-track.declarations'; +import { PlayerTextTrack } from './player-text-tracks'; + +export class PlayerThumbnailTrack extends PlayerTextTrack implements IPlayerThumbnailTrack { + public readonly isActive: boolean; + + public constructor(textTrack: TextTrack) { + super(textTrack); + this.isActive = this.mode !== 'disabled'; + } + + public static fromTextTracks(textTrackList: TextTrackList): Array { + const playerThumbnailTracks = []; + for (let i = 0; i < textTrackList.length; i++) { + if (textTrackList[i].label.startsWith(Thumbnails)) { + playerThumbnailTracks.push(new PlayerThumbnailTrack(textTrackList[i])); + } + } + return playerThumbnailTracks; + } +} diff --git a/packages/playback/src/lib/pipelines/base-pipeline.ts b/packages/playback/src/lib/pipelines/base-pipeline.ts index 7068af7..24cf459 100644 --- a/packages/playback/src/lib/pipelines/base-pipeline.ts +++ b/packages/playback/src/lib/pipelines/base-pipeline.ts @@ -1,6 +1,5 @@ import type { IPipeline, IPipelineDependencies } from '../types/pipeline.declarations'; import { PlayerTimeRange } from '../models/player-time-range'; -import type { PlaybackState } from '../consts/playback-state'; import type { IPlaybackStats } from '../types/playback-stats.declarations'; import type { INetworkManager } from '../types/network.declarations'; import type { IQualityLevel } from '../types/quality-level.declarations'; @@ -46,8 +45,6 @@ export abstract class BasePipeline implements IPipeline { public abstract getThumbnailTracks(): Array; - public abstract getPlaybackState(): PlaybackState; - public abstract start(): void; public getPlaybackStats(): IPlaybackStats { @@ -71,7 +68,9 @@ export abstract class BasePipeline implements IPipeline { } public play(): void { - void this.videoElement_.play(); + void this.videoElement_.play().catch((error) => { + this.logger_.error(error); + }); } public seek(seekTarget: number): boolean { diff --git a/packages/playback/src/lib/pipelines/native/native-pipeline.ts b/packages/playback/src/lib/pipelines/native/native-pipeline.ts index fee2237..14a1045 100644 --- a/packages/playback/src/lib/pipelines/native/native-pipeline.ts +++ b/packages/playback/src/lib/pipelines/native/native-pipeline.ts @@ -3,14 +3,20 @@ import type { IPipelineDependencies } from '../../types/pipeline.declarations'; import type { IPlayerAudioTrack } from '../../types/audio-track.declarations'; import { PlayerAudioTrack } from '../../models/player-audio-track'; import type { IQualityLevel } from '../../types/quality-level.declarations'; -import type { PlaybackState } from 'src/lib/consts/playback-state'; -import type { IPlayerTextTrack } from 'src/lib/types/text-track.declarations'; -import type { - IRemoteVttThumbnailTrackOptions, - IPlayerThumbnailTrack, -} from 'src/lib/types/thumbnail-track.declarations'; +import type { IPlayerTextTrack } from '../../types/text-track.declarations'; +import { + type IRemoteVttThumbnailTrackOptions, + type IPlayerThumbnailTrack, +} from '../../types/thumbnail-track.declarations'; +import { PlayerTextTrack } from '../../models/player-text-tracks'; +import { PlayerThumbnailTrack } from '../../models/player-thumbnail-tracks'; +import { Thumbnails } from '../../consts/text-tracks'; export class NativePipeline extends BasePipeline { + private constructor(dependencies: IPipelineDependencies) { + super(dependencies); + } + public static create(dependencies: IPipelineDependencies): NativePipeline { dependencies.logger = dependencies.logger.createSubLogger('NativePipeline'); @@ -18,28 +24,51 @@ export class NativePipeline extends BasePipeline { } public getTextTracks(): Array { - throw new Error('Method not implemented.'); + if (this.videoElement_.textTracks) { + return PlayerTextTrack.fromTextTracks(this.videoElement_.textTracks); + } + return []; } - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars public removeRemoteThumbnailTrack(id: string): boolean { - throw new Error('Method not implemented.'); + if (this.videoElement_.textTracks) { + const trackToRemove = this.videoElement_.textTracks.getTrackById(id); + + // disable native track since there is no Track element to remove. + if (trackToRemove && trackToRemove.label.startsWith(Thumbnails)) { + trackToRemove.mode = 'disabled'; + return true; + } + } + return false; } - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars public addRemoteVttThumbnailTrack(options: IRemoteVttThumbnailTrackOptions): boolean { - throw new Error('Method not implemented.'); + // TODO: Request and parse thumbnails from VTT file or manifest. + if (options.url) { + this.videoElement_.addTextTrack('metadata', Thumbnails); + return true; + } + return false; } - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars public selectThumbnailTrack(id: string): boolean { - throw new Error('Method not implemented.'); + if (this.videoElement_.textTracks) { + const trackToSelect = this.videoElement_.textTracks.getTrackById(id); + + if (trackToSelect && trackToSelect.label.startsWith(Thumbnails)) { + trackToSelect.mode = 'hidden'; + return true; + } + } + return false; } + public getThumbnailTracks(): Array { - throw new Error('Method not implemented.'); - } - public getPlaybackState(): PlaybackState { - throw new Error('Method not implemented.'); + if (this.videoElement_.textTracks) { + return PlayerThumbnailTrack.fromTextTracks(this.videoElement_.textTracks); + } + return []; } public getAudioTracks(): Array { @@ -95,4 +124,12 @@ export class NativePipeline extends BasePipeline { this.videoElement_.removeAttribute('src'); this.videoElement_.load(); } + + public play(): void { + super.play(); + } + + public pause(): void { + super.pause(); + } } diff --git a/packages/playback/src/lib/player/base-player.ts b/packages/playback/src/lib/player/base-player.ts index fb2b1cb..2e0ff62 100644 --- a/packages/playback/src/lib/player/base-player.ts +++ b/packages/playback/src/lib/player/base-player.ts @@ -400,6 +400,7 @@ export abstract class BasePlayer { this.activeVideoElement_.addEventListener('volumechange', this.handleVolumeChange_); this.activeVideoElement_.addEventListener('ratechange', this.handleRateChange_); this.activeVideoElement_.addEventListener('timeupdate', this.handleTimeUpdate_); + this.activeVideoElement_.addEventListener('waiting', this.handleWaiting_); // EME this.activeVideoElement_.addEventListener('encrypted', this.handleEncryptedEvent_); this.activeVideoElement_.addEventListener('waitingforkey', this.handleWaitingForKeyEvent_); @@ -428,6 +429,7 @@ export abstract class BasePlayer { this.activeVideoElement_.removeEventListener('volumechange', this.handleVolumeChange_); this.activeVideoElement_.removeEventListener('ratechange', this.handleRateChange_); this.activeVideoElement_.removeEventListener('timeupdate', this.handleTimeUpdate_); + this.activeVideoElement_.removeEventListener('waiting', this.handleWaiting_); // EME this.activeVideoElement_.removeEventListener('encrypted', this.handleEncryptedEvent_); this.activeVideoElement_.removeEventListener('waitingforkey', this.handleWaitingForKeyEvent_); @@ -575,6 +577,10 @@ export abstract class BasePlayer { this.eventEmitter_.emitEvent(new CurrentTimeChangedEvent(target.currentTime)); }; + protected readonly handleWaiting_ = (): void => { + this.transitionPlaybackState_(PlaybackState.Buffering); + }; + protected readonly handleEncryptedEvent_ = (event: MediaEncryptedEvent): void => { this.logger_.debug('received encrypted event', event); @@ -621,6 +627,7 @@ export abstract class BasePlayer { */ public play(): void { this.activeVideoElement_?.play(); + this.transitionPlaybackState_(PlaybackState.Playing); // TODO: pass command to loader/pipeline } @@ -629,6 +636,7 @@ export abstract class BasePlayer { */ public pause(): void { this.activeVideoElement_?.pause(); + this.transitionPlaybackState_(PlaybackState.Paused); // TODO: pass command to loader/pipeline } diff --git a/packages/playback/src/lib/types/pipeline.declarations.ts b/packages/playback/src/lib/types/pipeline.declarations.ts index d7cd2c7..5686df1 100644 --- a/packages/playback/src/lib/types/pipeline.declarations.ts +++ b/packages/playback/src/lib/types/pipeline.declarations.ts @@ -1,4 +1,3 @@ -import type { PlaybackState } from '../consts/playback-state'; import type { IPlaybackStats } from './playback-stats.declarations'; import type { ILogger } from './logger.declarations'; import type { INetworkManager } from './network.declarations'; @@ -47,7 +46,6 @@ export interface IPipelineFactoryConfiguration { export interface IPipeline { play(): void; pause(): void; - getPlaybackState(): PlaybackState; seek(seekTarget: number): boolean; getSeekableRanges(): Array; getBufferedRanges(): Array; diff --git a/packages/playback/src/lib/types/text-track.declarations.ts b/packages/playback/src/lib/types/text-track.declarations.ts index e70e982..b5c693a 100644 --- a/packages/playback/src/lib/types/text-track.declarations.ts +++ b/packages/playback/src/lib/types/text-track.declarations.ts @@ -1,3 +1,9 @@ export interface IPlayerTextTrack { - id: string; + readonly id: string; + readonly activeCues: TextTrackCueList | null; + readonly cues: TextTrackCueList | null; + readonly kind: TextTrackKind; + readonly label: string; + readonly language: string; + readonly mode: string; } diff --git a/packages/playback/src/lib/types/thumbnail-track.declarations.ts b/packages/playback/src/lib/types/thumbnail-track.declarations.ts index ee2aaa3..0f469c5 100644 --- a/packages/playback/src/lib/types/thumbnail-track.declarations.ts +++ b/packages/playback/src/lib/types/thumbnail-track.declarations.ts @@ -1,5 +1,6 @@ -export interface IPlayerThumbnailTrack { - id: string; +import type { IPlayerTextTrack } from './text-track.declarations'; + +export interface IPlayerThumbnailTrack extends IPlayerTextTrack { isActive: boolean; } diff --git a/packages/playback/test/lib/pipelines/native/native-pipeline.test.ts b/packages/playback/test/lib/pipelines/native/native-pipeline.test.ts new file mode 100644 index 0000000..5d43106 --- /dev/null +++ b/packages/playback/test/lib/pipelines/native/native-pipeline.test.ts @@ -0,0 +1,236 @@ +import type { + AudioTrack, + AudioTrackList, + IPipelineDependencies, + IRemoteVttThumbnailTrackOptions, +} from 'src/entry-points/api-reference'; +import { NativePipeline } from '../../../../src/lib/pipelines/native/native-pipeline'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('NativePipeline', () => { + let nativePipeline: NativePipeline; + let videoElement: HTMLVideoElement; + beforeEach(() => { + videoElement = document.createElement('video'); + nativePipeline = NativePipeline.create({ + logger: { + createSubLogger: (name) => { + name; + }, + }, + videoElement, + source: { + url: new URL('https://foo.bar.m3u8'), + }, + } as IPipelineDependencies); + + // text mocks and test utility. + window.HTMLMediaElement.prototype.addTextTrack = (kind: string, label?: string, language?: string): TextTrack => { + const textTrack = { + kind, + label, + language, + mode: 'hidden', + id: `id-${label}-${language}`, + } as TextTrack; + videoElement.textTracks[videoElement.textTracks.length] = textTrack; + return textTrack; + }; + videoElement.textTracks.getTrackById = (id: string): TextTrack | null => { + for (let i = 0; i < videoElement.textTracks.length; i++) { + if (videoElement.textTracks[i].id === id) { + return videoElement.textTracks[i]; + } + } + return null; + }; + window.HTMLMediaElement.prototype.play = (): Promise => { + return Promise.resolve(); + }; + }); + + describe('Basic Pipeline Operations', () => { + it('should create native pipeline', () => { + expect(nativePipeline).toBeInstanceOf(NativePipeline); + }); + + it('should get text tracks', () => { + let tracks = nativePipeline.getTextTracks(); + const labelOne = 'foo'; + const languageOne = 'en'; + const kind = 'captions'; + + expect(tracks).toEqual([]); + + // add one track + videoElement.addTextTrack(kind, labelOne, languageOne); + tracks = nativePipeline.getTextTracks(); + expect(tracks.length).toEqual(1); + expect(tracks[0].kind).toEqual(kind); + expect(tracks[0].label).toEqual(labelOne); + expect(tracks[0].language).toEqual(languageOne); + + // add more tracks + const labelTwo = 'bar'; + const languageTwo = 'sp'; + const kindTwo = 'subtitles'; + + videoElement.addTextTrack(kindTwo, labelTwo, languageTwo); + videoElement.addTextTrack('metadata', labelTwo, languageTwo); + + // should only return non-metadata tracks. + tracks = nativePipeline.getTextTracks(); + expect(tracks.length).toEqual(2); + expect(tracks[1].kind).toEqual(kindTwo); + expect(tracks[1].label).toEqual(labelTwo); + expect(tracks[1].language).toEqual(languageTwo); + }); + + it('should add, get, and remove existing thumbnail track', () => { + // no tracks. + expect(nativePipeline.getThumbnailTracks()).toEqual([]); + + // add thumbnail track. + let didAddTrack = nativePipeline.addRemoteVttThumbnailTrack({} as IRemoteVttThumbnailTrackOptions); + expect(didAddTrack).toBe(false); + didAddTrack = nativePipeline.addRemoteVttThumbnailTrack({ url: new URL('https://foo.bar') }); + expect(didAddTrack).toBe(true); + + // get thumbnail tracks. + const thumbnailTracksBeforeRemove = nativePipeline.getThumbnailTracks(); + expect(thumbnailTracksBeforeRemove.length).toEqual(1); + expect(thumbnailTracksBeforeRemove[0].mode).toEqual('hidden'); + + // remove wrong thumbnail track. + const wrongTrackId = 'id-foo-bar'; + let didRemoveTrack = nativePipeline.removeRemoteThumbnailTrack(wrongTrackId); + expect(didRemoveTrack).toBe(false); + + // remove thumbnail track. + const trackId = 'id-thumbnails-undefined'; + didRemoveTrack = nativePipeline.removeRemoteThumbnailTrack(trackId); + expect(didRemoveTrack).toBe(true); + + // native text tracks can only be disabled as there is no track element to remove. + const thumbnailTracksAfterRemove = nativePipeline.getThumbnailTracks(); + expect(thumbnailTracksAfterRemove.length).toEqual(1); + expect(thumbnailTracksAfterRemove[0].mode).toEqual('disabled'); + }); + + it('should select thumbnail track', () => { + // add thumbnail track. + const didAddTrack = nativePipeline.addRemoteVttThumbnailTrack({ url: new URL('https://bar.foo') }); + expect(didAddTrack).toBe(true); + + // select track + const trackId = 'id-thumbnails-undefined'; + let trackWasSelected = nativePipeline.selectThumbnailTrack(trackId); + expect(trackWasSelected).toBe(true); + + // should return false for wrong id + const wrongTrackId = 'foo'; + trackWasSelected = nativePipeline.selectThumbnailTrack(wrongTrackId); + expect(trackWasSelected).toBe(false); + }); + + // chrome does not support the audioTrack API. So need to mock everything. + it('should get audio tracks', () => { + const id = 'foo'; + const enabled = true; + const kind = 'main'; + const label = 'bar'; + const language = 'en'; + const audioTrack: AudioTrack = { + id, + enabled, + kind, + label, + language, + }; + + let nativeAudioTracks = nativePipeline.getAudioTracks(); + expect(nativeAudioTracks).toEqual([]); + + // add audio track + const audioTracks = { + length: 1, + [Symbol.iterator]: function* () { + yield audioTrack; + }, + } as unknown as AudioTrackList; + Object.defineProperty(videoElement, 'audioTracks', { value: audioTracks }); + if (videoElement.audioTracks) { + videoElement.audioTracks[0] = audioTrack; + } + nativeAudioTracks = nativePipeline.getAudioTracks(); + expect(nativeAudioTracks.length).toEqual(1); + expect(nativeAudioTracks).toBeDefined(); + expect(nativeAudioTracks[0].id).toEqual(id); + expect(nativeAudioTracks[0].isActive).toEqual(enabled); + expect(nativeAudioTracks[0].kind).toEqual(kind); + expect(nativeAudioTracks[0].label).toEqual(label); + expect(nativeAudioTracks[0].language).toEqual(language); + }); + + it('should select audio track', () => { + const id = 'foo'; + const audioTrack: AudioTrack = { + id, + enabled: false, + kind: 'alternate', + label: 'bar', + language: 'sp', + }; + // add audio track + const audioTracks = { + length: 1, + getTrackById: (id_: string) => { + // simple mock for one track + const isSameId = videoElement.audioTracks && videoElement.audioTracks[0].id === id_; + if (isSameId) { + return videoElement.audioTracks ? videoElement.audioTracks[0] : null; + } + return null; + }, + [Symbol.iterator]: function* () { + yield audioTrack; + }, + } as unknown as AudioTrackList; + Object.defineProperty(videoElement, 'audioTracks', { value: audioTracks }); + if (videoElement.audioTracks) { + videoElement.audioTracks[0] = audioTrack; + } + + let didSelectTrack = nativePipeline.selectAudioTrack('bar'); + expect(didSelectTrack).toBe(false); + + didSelectTrack = nativePipeline.selectAudioTrack(id); + expect(didSelectTrack).toBe(true); + }); + + it('should set src and state on start', () => { + const expectedSrc = 'https://foo.bar.m3u8/'; + expect(videoElement.src).toBe(''); + nativePipeline.start(); + expect(videoElement.src).toEqual(expectedSrc); + }); + + it('should remove src and state on dispose', () => { + nativePipeline.start(); + nativePipeline.dispose(); + expect(videoElement.src).toBe(''); + }); + + it('getQualityLevels should return empty array', () => { + expect(nativePipeline.getQualityLevels()).toEqual([]); + }); + + it('selectQualityLevel should return false', () => { + expect(nativePipeline.selectQualityLevel()).toBe(false); + }); + + it('selectAutoQualityLevel should return false', () => { + expect(nativePipeline.selectAutoQualityLevel()).toBe(false); + }); + }); +}); diff --git a/packages/playback/test/lib/player/player.test.ts b/packages/playback/test/lib/player/player.test.ts index a70d859..671466c 100644 --- a/packages/playback/test/lib/player/player.test.ts +++ b/packages/playback/test/lib/player/player.test.ts @@ -8,6 +8,7 @@ import { import type { PlayerEvent } from '../../../src/lib/events/base-player-event'; import { RequestType } from '../../../src/lib/consts/request-type'; import { ServiceLocator } from '../../../src/lib/service-locator'; +import { PlaybackState } from '../../../src/lib/consts/playback-state'; describe('Player spec', () => { let player: Player; @@ -251,4 +252,23 @@ describe('Player spec', () => { expect(expectedEvents).toEqual(actualEvents); }); }); + + describe('state', () => { + it('should transition to playing', () => { + player.play(); + expect(player.getPlaybackState()).toEqual(PlaybackState.Playing); + }); + + it('should transition to paused', () => { + player.pause(); + expect(player.getPlaybackState()).toEqual(PlaybackState.Paused); + }); + + it('should transition to buffering', () => { + // cast as any to call handleWaiting directly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (player as any).handleWaiting_(); + expect(player.getPlaybackState()).toEqual(PlaybackState.Buffering); + }); + }); });