diff --git a/bun.lockb b/bun.lockb index fc84b78..3789102 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/playback/package.json b/packages/playback/package.json index 271f8fe..a62fd70 100644 --- a/packages/playback/package.json +++ b/packages/playback/package.json @@ -9,6 +9,10 @@ "test": "bunx jest", "test:coverage": "bunx jest --coverage=true" }, + "dependencies": { + "@videojs/hls-parser": "*", + "@videojs/dash-parser": "*" + }, "exports": { "./player": { "types": "./dist/player.d.ts", diff --git a/packages/playback/src/lib/configuration.ts b/packages/playback/src/lib/configuration.ts deleted file mode 100644 index f1f65dd..0000000 --- a/packages/playback/src/lib/configuration.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface PlayerConfiguration {} - -const defaultConfiguration: PlayerConfiguration = {}; - -export const getDefaultPlayerConfiguration = (): PlayerConfiguration => structuredClone(defaultConfiguration); diff --git a/packages/playback/src/lib/errors.ts b/packages/playback/src/lib/errors.ts deleted file mode 100644 index 9b04003..0000000 --- a/packages/playback/src/lib/errors.ts +++ /dev/null @@ -1,74 +0,0 @@ -export enum ErrorCategory { - Pipeline = 1, - Network, -} - -export enum ErrorCode { - // pipeline - NoSupportedPipeline = 1000, - // network - RequestInterceptor = 2000, - ResponseInterceptor, - NetworkRequestAborted, - FetchError, - BadStatus, - Timeout, -} - -export default abstract class PlayerError { - public abstract readonly category: ErrorCategory; - public abstract readonly code: ErrorCode; - public abstract readonly critical: boolean; -} - -abstract class PipelineError extends PlayerError { - public readonly category = ErrorCategory.Pipeline; -} - -export class NoSupportedPipelineError extends PipelineError { - public readonly code = ErrorCode.NoSupportedPipeline; - public readonly critical = true; -} - -abstract class NetworkError extends PlayerError { - public readonly category = ErrorCategory.Network; - public readonly critical = false; -} - -export class RequestInterceptorNetworkError extends NetworkError { - public readonly code = ErrorCode.RequestInterceptor; -} - -export class ResponseInterceptorNetworkError extends NetworkError { - public readonly code = ErrorCode.ResponseInterceptor; -} - -export class RequestAbortedNetworkError extends NetworkError { - public readonly code = ErrorCode.NetworkRequestAborted; -} - -export class TimeoutNetworkError extends NetworkError { - public readonly code = ErrorCode.Timeout; -} - -export class BadStatusNetworkError extends NetworkError { - public readonly code = ErrorCode.BadStatus; - public readonly response: Response; - - public constructor(response: Response) { - super(); - - this.response = response; - } -} - -export class FetchError extends NetworkError { - public readonly code = ErrorCode.FetchError; - public readonly fetchError: TypeError; - - public constructor(fetchError: TypeError) { - super(); - - this.fetchError = fetchError; - } -} diff --git a/packages/playback/src/lib/events.ts b/packages/playback/src/lib/events.ts deleted file mode 100644 index dfe6942..0000000 --- a/packages/playback/src/lib/events.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type PlayerError from './errors'; - -export const Events = { - EnterPictureInPictureMode: 'EnterPictureInPictureMode', - LeavePictureInPictureMode: 'LeavePictureInPictureMode', - Error: 'Error', -} as const; - -abstract class PlayerEvent { - public abstract readonly type: (typeof Events)[keyof typeof Events]; -} - -export class EnterPictureInPictureModeEvent extends PlayerEvent { - public readonly type = Events.EnterPictureInPictureMode; -} - -export class LeavePictureInPictureModeEvent extends PlayerEvent { - public readonly type = Events.LeavePictureInPictureMode; -} - -export class ErrorEvent extends PlayerEvent { - public readonly type = Events.Error; - public readonly error: PlayerError; - - public constructor(error: PlayerError) { - super(); - this.error = error; - } -} - -export interface EventToTypeMap { - [Events.EnterPictureInPictureMode]: EnterPictureInPictureModeEvent; - [Events.LeavePictureInPictureMode]: LeavePictureInPictureModeEvent; - [Events.Error]: ErrorEvent; -} diff --git a/packages/playback/src/lib/networkManager.ts b/packages/playback/src/lib/network/networkManager.ts similarity index 93% rename from packages/playback/src/lib/networkManager.ts rename to packages/playback/src/lib/network/networkManager.ts index 1c78b47..afe2f35 100644 --- a/packages/playback/src/lib/networkManager.ts +++ b/packages/playback/src/lib/network/networkManager.ts @@ -5,10 +5,10 @@ import { RequestInterceptorNetworkError, ResponseInterceptorNetworkError, TimeoutNetworkError, -} from './errors'; -import type { RetryWrapperOptions } from './utils/retryWrapper'; -import RetryWrapper from './utils/retryWrapper'; -import type Logger from './utils/logger'; +} from './networkManagerErrors'; +import type { RetryWrapperOptions } from '../utils/retryWrapper'; +import RetryWrapper from '../utils/retryWrapper'; +import type Logger from '../utils/logger'; export enum RequestType { InitSegment, @@ -40,14 +40,22 @@ interface NetworkRequestWithProgressiveResponse { done: Promise; } +interface NetworkManagerDependencies { + logger: Logger; +} + export default class NetworkManager { + public static create(dependencies: NetworkManagerDependencies): NetworkManager { + return new NetworkManager(dependencies); + } + private readonly requestInterceptors = new Map>(); private readonly responseHandlers = new Map>(); private readonly logger: Logger; - public constructor(logger: Logger) { - this.logger = logger.createSubLogger('NetworkManager'); + public constructor(dependencies: NetworkManagerDependencies) { + this.logger = dependencies.logger; } private add(type: RequestType, interceptor: T, interceptors: Map>): void { @@ -160,7 +168,7 @@ export default class NetworkManager { headersReceived .then((response) => this.applyResponseHandlers(type, response)) .catch((e) => { - this.logger.debug('Error cached from response handlers: ', e); + this.logger.debug('Error catched from response handlers: ', e); }); return { done, abort }; @@ -205,7 +213,7 @@ export default class NetworkManager { headersReceived .then((response) => this.applyResponseHandlers(type, response)) .catch((e) => { - this.logger.debug('Error cached from response handlers: ', e); + this.logger.debug('Error catched from response handlers: ', e); }); return { done, abort }; diff --git a/packages/playback/src/lib/network/networkManagerErrors.ts b/packages/playback/src/lib/network/networkManagerErrors.ts new file mode 100644 index 0000000..934cced --- /dev/null +++ b/packages/playback/src/lib/network/networkManagerErrors.ts @@ -0,0 +1,27 @@ +export class RequestInterceptorNetworkError extends Error {} + +export class ResponseInterceptorNetworkError extends Error {} + +export class RequestAbortedNetworkError extends Error {} + +export class TimeoutNetworkError extends Error {} + +export class BadStatusNetworkError extends Error { + public readonly response: Response; + + public constructor(response: Response) { + super(); + + this.response = response; + } +} + +export class FetchError extends Error { + public readonly fetchError: TypeError; + + public constructor(fetchError: TypeError) { + super(); + + this.fetchError = fetchError; + } +} diff --git a/packages/playback/src/lib/pipelines/basePipeline.ts b/packages/playback/src/lib/pipelines/basePipeline.ts index 6c9c9ce..b6d8e9c 100644 --- a/packages/playback/src/lib/pipelines/basePipeline.ts +++ b/packages/playback/src/lib/pipelines/basePipeline.ts @@ -1,4 +1,35 @@ +import type NetworkManager from '../network/networkManager'; +import type Logger from '../utils/logger'; + +interface PipelineDependencies { + logger: Logger; +} + export default abstract class Pipeline { - public abstract loadRemoteAsset(uri: string): void; + private readonly logger: Logger; + + public constructor(dependencies: PipelineDependencies) { + this.logger = dependencies.logger; + } + + protected mapProtocolToNetworkManager = new Map(); + + public loadRemoteAsset(uri: URL): void { + const networkManager = this.mapProtocolToNetworkManager.get(uri.protocol); + + if (!networkManager) { + // trigger error; + return; + } + + return this.loadRemoteAssetWithNetworkManager(uri, networkManager); + } + + public abstract loadRemoteAssetWithNetworkManager(uri: URL, networkManager: NetworkManager): void; + public abstract loadLocalAsset(asset: string | ArrayBuffer): void; + + public setMapProtocolToNetworkManager(map: Map): void { + this.mapProtocolToNetworkManager = map; + } } diff --git a/packages/playback/src/lib/pipelines/consts/events.ts b/packages/playback/src/lib/pipelines/consts/events.ts new file mode 100644 index 0000000..40b72fc --- /dev/null +++ b/packages/playback/src/lib/pipelines/consts/events.ts @@ -0,0 +1 @@ +export const PipelineEvents = {} as const; diff --git a/packages/playback/src/lib/pipelines/events/pipelineEventTypeToEventMap.ts b/packages/playback/src/lib/pipelines/events/pipelineEventTypeToEventMap.ts new file mode 100644 index 0000000..995fd92 --- /dev/null +++ b/packages/playback/src/lib/pipelines/events/pipelineEventTypeToEventMap.ts @@ -0,0 +1 @@ +export interface PipelinePlayerEventTypeToEventMap {} diff --git a/packages/playback/src/lib/pipelines/mse/dash/dashPipeline.ts b/packages/playback/src/lib/pipelines/mse/dash/dashPipeline.ts index b06ec4a..673c5f8 100644 --- a/packages/playback/src/lib/pipelines/mse/dash/dashPipeline.ts +++ b/packages/playback/src/lib/pipelines/mse/dash/dashPipeline.ts @@ -1,13 +1,26 @@ import MsePipeLine from '../msePipeline'; +import type NetworkManager from '../../../network/networkManager'; export default class DashPipeline extends MsePipeLine { // eslint-disable-next-line @typescript-eslint/no-unused-vars - public loadLocalAsset(asset: string | ArrayBuffer): void { - //TODO + public loadRemoteAssetWithNetworkManager(uri: URL, networkManager: NetworkManager): void { + // if (this.progressiveParser) { + // load and parse progressively + // } + // if (this.fullPlaylistParser) { + // load and parse sequentially + // } + //trigger error; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public loadRemoteAsset(uri: string): void { - //TODO + public loadLocalAsset(asset: string | ArrayBuffer): void { + // if (this.fullPlaylistParser) { + // just parse + // } + // if (this.progressiveParser) { + // push + // } + // trigger error; } } diff --git a/packages/playback/src/lib/pipelines/mse/hls/hlsPipeline.ts b/packages/playback/src/lib/pipelines/mse/hls/hlsPipeline.ts index f56fee3..fb37561 100644 --- a/packages/playback/src/lib/pipelines/mse/hls/hlsPipeline.ts +++ b/packages/playback/src/lib/pipelines/mse/hls/hlsPipeline.ts @@ -1,13 +1,42 @@ +import type { FullPlaylistParser, ProgressiveParser } from '@videojs/hls-parser'; import MsePipeLine from '../msePipeline'; +import type NetworkManager from '../../../network/networkManager'; export default class HlsPipeline extends MsePipeLine { + private progressiveParser: ProgressiveParser | null = null; + private fullPlaylistParser: FullPlaylistParser | null = null; + + public setProgressiveParser(parser: ProgressiveParser): void { + this.progressiveParser = parser; + } + + public setFullPlaylistParser(parser: FullPlaylistParser): void { + this.fullPlaylistParser = parser; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars - public loadLocalAsset(asset: string | ArrayBuffer): void { - //TODO + public loadRemoteAssetWithNetworkManager(uri: URL, networkManager: NetworkManager): void { + if (this.progressiveParser) { + // load and parse progressively + } + + if (this.fullPlaylistParser) { + // load and parse sequentially + } + + //trigger error; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public loadRemoteAsset(uri: string): void { - //TODO + public loadLocalAsset(asset: string | ArrayBuffer): void { + if (this.fullPlaylistParser) { + // just parse + } + + if (this.progressiveParser) { + // push + } + + // trigger error; } } diff --git a/packages/playback/src/lib/pipelines/mse/msePipeline.ts b/packages/playback/src/lib/pipelines/mse/msePipeline.ts index ed9683f..8a519b5 100644 --- a/packages/playback/src/lib/pipelines/mse/msePipeline.ts +++ b/packages/playback/src/lib/pipelines/mse/msePipeline.ts @@ -1,7 +1,8 @@ import Pipeline from '../basePipeline'; +import type NetworkManager from '../../network/networkManager'; export default abstract class MsePipeLine extends Pipeline { public abstract loadLocalAsset(asset: string | ArrayBuffer): void; - public abstract loadRemoteAsset(uri: string): void; + public abstract loadRemoteAssetWithNetworkManager(uri: URL, networkManager: NetworkManager): void; } diff --git a/packages/playback/src/lib/pipelines/native/nativePipeline.ts b/packages/playback/src/lib/pipelines/native/nativePipeline.ts index 5c0ef70..224c543 100644 --- a/packages/playback/src/lib/pipelines/native/nativePipeline.ts +++ b/packages/playback/src/lib/pipelines/native/nativePipeline.ts @@ -1,4 +1,5 @@ import Pipeline from '../basePipeline'; +import type NetworkManager from '../../network/networkManager'; export default class NativePipeline extends Pipeline { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -7,7 +8,5 @@ export default class NativePipeline extends Pipeline { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public loadRemoteAsset(uri: string): void { - //TODO - } + public loadRemoteAssetWithNetworkManager(uri: URL, networkManager: NetworkManager): void {} } diff --git a/packages/playback/src/lib/player/configuration/configuration.ts b/packages/playback/src/lib/player/configuration/configuration.ts new file mode 100644 index 0000000..074d217 --- /dev/null +++ b/packages/playback/src/lib/player/configuration/configuration.ts @@ -0,0 +1,24 @@ +// interface NetworkConfiguration { +// maxAttempts: number; +// delay: number; +// delayFactor: number; +// fuzzFactor: number; +// timeout: number; +// } +// +// interface StreamingConfiguration { +// network: NetworkConfiguration; +// } +// +// interface HlsConfiguration extends StreamingConfiguration {} +// +// interface DashConfiguration extends StreamingConfiguration {} +// +// export interface PlayerConfiguration { +// hls: HlsConfiguration; +// dash: DashConfiguration; +// } + +interface PlayerConfiguration {} + +export const createDefaultConfiguration = (): PlayerConfiguration => ({}); diff --git a/packages/playback/src/lib/player/consts/errors.ts b/packages/playback/src/lib/player/consts/errors.ts new file mode 100644 index 0000000..e8f5cac --- /dev/null +++ b/packages/playback/src/lib/player/consts/errors.ts @@ -0,0 +1,14 @@ +export const ErrorCategory = { + Pipeline: 'Pipeline', + Network: 'Network', +} as const; + +export const PipelineErrorCodes = { + NoSupportedPipeline: 'NoSupportedPipeline', +} as const; + +export const NetworkErrorCodes = { + NoNetworkManagerRegisteredForProtocol: 'NoNetworkManagerRegisteredForProtocol', +} as const; + +export type ErrorCode = keyof typeof PipelineErrorCodes | keyof typeof NetworkErrorCodes; diff --git a/packages/playback/src/lib/player/consts/events.ts b/packages/playback/src/lib/player/consts/events.ts new file mode 100644 index 0000000..6ab6d41 --- /dev/null +++ b/packages/playback/src/lib/player/consts/events.ts @@ -0,0 +1,8 @@ +export const Events = { + EnterPictureInPictureMode: 'EnterPictureInPictureMode', + LeavePictureInPictureMode: 'LeavePictureInPictureMode', + VolumeChanged: 'VolumeChanged', + LoggerLevelChanged: 'LoggerLevelChanged', + MutedStatusChanged: 'MutedStatusChanged', + Error: 'Error', +} as const; diff --git a/packages/playback/src/lib/player/errors/basePlayerError.ts b/packages/playback/src/lib/player/errors/basePlayerError.ts new file mode 100644 index 0000000..42d7f2e --- /dev/null +++ b/packages/playback/src/lib/player/errors/basePlayerError.ts @@ -0,0 +1,7 @@ +import type { ErrorCategory, ErrorCode } from '../consts/errors'; + +export default abstract class PlayerError { + public abstract readonly category: keyof typeof ErrorCategory; + public abstract readonly code: ErrorCode; + public abstract readonly critical: boolean; +} diff --git a/packages/playback/src/lib/player/errors/networkPlayerErrors.ts b/packages/playback/src/lib/player/errors/networkPlayerErrors.ts new file mode 100644 index 0000000..beb3267 --- /dev/null +++ b/packages/playback/src/lib/player/errors/networkPlayerErrors.ts @@ -0,0 +1,17 @@ +import PlayerError from './basePlayerError'; +import { ErrorCategory, NetworkErrorCodes } from '../consts/errors'; + +abstract class NetworkError extends PlayerError { + public readonly category = ErrorCategory.Network; +} + +export class NoNetworkManagerRegisteredForProtocolError extends NetworkError { + public readonly code = NetworkErrorCodes.NoNetworkManagerRegisteredForProtocol; + public readonly critical = true; + public readonly protocol: string; + + public constructor(protocol: string) { + super(); + this.protocol = protocol; + } +} diff --git a/packages/playback/src/lib/player/errors/pipelinePlayerErrors.ts b/packages/playback/src/lib/player/errors/pipelinePlayerErrors.ts new file mode 100644 index 0000000..17e9673 --- /dev/null +++ b/packages/playback/src/lib/player/errors/pipelinePlayerErrors.ts @@ -0,0 +1,11 @@ +import PlayerError from './basePlayerError'; +import { ErrorCategory, PipelineErrorCodes } from '../consts/errors'; + +abstract class PipelineError extends PlayerError { + public readonly category = ErrorCategory.Pipeline; +} + +export class NoSupportedPipelineError extends PipelineError { + public readonly code = PipelineErrorCodes.NoSupportedPipeline; + public readonly critical = true; +} diff --git a/packages/playback/src/lib/player/events/playerEventTypeToEventMap.ts b/packages/playback/src/lib/player/events/playerEventTypeToEventMap.ts new file mode 100644 index 0000000..be19acc --- /dev/null +++ b/packages/playback/src/lib/player/events/playerEventTypeToEventMap.ts @@ -0,0 +1,18 @@ +import type { Events } from '../consts/events'; +import type { + EnterPictureInPictureModeEvent, + LeavePictureInPictureModeEvent, + LoggerLevelChangedEvent, + MutedStatusChangedEvent, + VolumeChangedEvent, + ErrorEvent, +} from './playerEvents'; + +export interface PlayerEventTypeToEventMap { + [Events.LoggerLevelChanged]: LoggerLevelChangedEvent; + [Events.VolumeChanged]: VolumeChangedEvent; + [Events.MutedStatusChanged]: MutedStatusChangedEvent; + [Events.EnterPictureInPictureMode]: EnterPictureInPictureModeEvent; + [Events.LeavePictureInPictureMode]: LeavePictureInPictureModeEvent; + [Events.Error]: ErrorEvent; +} diff --git a/packages/playback/src/lib/player/events/playerEvents.ts b/packages/playback/src/lib/player/events/playerEvents.ts new file mode 100644 index 0000000..5a53a8a --- /dev/null +++ b/packages/playback/src/lib/player/events/playerEvents.ts @@ -0,0 +1,55 @@ +import { Events } from '../consts/events'; +import type { LoggerLevel } from '../../utils/logger'; +import type PlayerError from '../errors/basePlayerError'; + +abstract class PlayerEvent { + public abstract readonly type: keyof typeof Events; +} + +export class VolumeChangedEvent extends PlayerEvent { + public readonly type = Events.VolumeChanged; + public readonly volume: number; + + public constructor(volume: number) { + super(); + this.volume = volume; + } +} + +export class MutedStatusChangedEvent extends PlayerEvent { + public readonly type = Events.MutedStatusChanged; + public readonly isMuted: boolean; + + public constructor(isMuted: boolean) { + super(); + this.isMuted = isMuted; + } +} + +export class LoggerLevelChangedEvent extends PlayerEvent { + public readonly type = Events.LoggerLevelChanged; + public readonly level: LoggerLevel; + + public constructor(level: LoggerLevel) { + super(); + this.level = level; + } +} + +export class EnterPictureInPictureModeEvent extends PlayerEvent { + public readonly type = Events.EnterPictureInPictureMode; +} + +export class LeavePictureInPictureModeEvent extends PlayerEvent { + public readonly type = Events.LeavePictureInPictureMode; +} + +export class ErrorEvent extends PlayerEvent { + public readonly type = Events.Error; + public readonly error: PlayerError; + + public constructor(error: PlayerError) { + super(); + this.error = error; + } +} diff --git a/packages/playback/src/lib/player.ts b/packages/playback/src/lib/player/player.ts similarity index 51% rename from packages/playback/src/lib/player.ts rename to packages/playback/src/lib/player/player.ts index 46f893f..36490ba 100644 --- a/packages/playback/src/lib/player.ts +++ b/packages/playback/src/lib/player/player.ts @@ -1,14 +1,23 @@ -import type { PlayerConfiguration } from './configuration'; -import Logger, { LoggerLevel } from './utils/logger'; -import { getDefaultPlayerConfiguration } from './configuration'; -import EventEmitter from './utils/eventEmitter'; - -import { Events, EnterPictureInPictureModeEvent, LeavePictureInPictureModeEvent, ErrorEvent } from './events'; -import type { EventToTypeMap } from './events'; -import type Pipeline from './pipelines/basePipeline'; -import NativePipeline from './pipelines/native/nativePipeline'; -import { NoSupportedPipelineError } from './errors'; -import NetworkManager, { RequestType } from './networkManager'; +import type { PlayerConfiguration } from './configuration/configuration'; +import Logger, { LoggerLevel } from '../utils/logger'; +import { createDefaultConfiguration } from './configuration/configuration'; +import type { Callback } from '../utils/eventEmitter'; +import EventEmitter from '../utils/eventEmitter'; +import { Events } from './consts/events'; +import NetworkManager, { RequestType } from '../network/networkManager'; +import type Pipeline from '../pipelines/basePipeline'; +import PlayerTimeRange from '../utils/timeRanges'; +import { + EnterPictureInPictureModeEvent, + LeavePictureInPictureModeEvent, + LoggerLevelChangedEvent, + MutedStatusChangedEvent, + VolumeChangedEvent, + ErrorEvent, +} from './events/playerEvents'; +import NativePipeline from '../pipelines/native/nativePipeline'; +import { NoSupportedPipelineError } from './errors/pipelinePlayerErrors'; +import type { PlayerEventTypeToEventMap } from './events/playerEventTypeToEventMap'; enum PlaybackState { Playing = 'Playing', @@ -17,12 +26,6 @@ enum PlaybackState { Idle = 'Idle', } -interface PlayerTimeRange { - start: number; - end: number; - isInRange: (time: number) => boolean; -} - // TODO: text tracks interface PlayerTextTrack {} @@ -38,30 +41,68 @@ interface PlayerVideoTrack {} // TODO player stats interface PlayerStats {} -export default class Player { - private static readonly pipelinesMap = new Map(); +interface PlayerDependencies { + logger: Logger; + eventEmitter: EventEmitter; +} +export default class Player { public static readonly Events = Events; public static readonly RequestType = RequestType; public static readonly LoggerLevel = LoggerLevel; - public static registerPipeline(mimeType: string, pipeline: Pipeline): void { - Player.pipelinesMap.set(mimeType, pipeline); + public static createPlayer(): Player { + const logger = new Logger(console, 'Player'); + const networkManager = new NetworkManager({ logger: logger.createSubLogger('NetworkManager') }); + const eventEmitter = new EventEmitter(); + + const player = new Player({ logger, eventEmitter }); + player.registerNetworkManager('http', networkManager); + player.registerNetworkManager('https', networkManager); + + return player; } + private readonly logger: Logger; + private readonly eventEmitter: EventEmitter; + private videoElement: HTMLVideoElement | null = null; private pictureInPictureWindow: PictureInPictureWindow | null = null; - private configuration: PlayerConfiguration = getDefaultPlayerConfiguration(); + private configuration: PlayerConfiguration = createDefaultConfiguration(); private playbackState: PlaybackState = PlaybackState.Idle; - private readonly logger = new Logger(console, 'Player'); - private readonly eventEmitter = new EventEmitter(); - private readonly networkManager: NetworkManager = new NetworkManager(this.logger); + private readonly mimeTypeToPipelineMap = new Map(); + private readonly protocolToNetworkManagerMap = new Map(); + + public constructor(dependencies: PlayerDependencies) { + this.logger = dependencies.logger; + this.eventEmitter = dependencies.eventEmitter; + } + + public registerPipeline(mimeType: string, pipeline: Pipeline): void { + if (this.mimeTypeToPipelineMap.has(mimeType)) { + this.logger.warn(`Overriding existing pipeline for "${mimeType}" mimeType.`); + } - public getNetworkManager(): NetworkManager { - return this.networkManager; + this.mimeTypeToPipelineMap.set(mimeType, pipeline); + } + + public registerNetworkManager(protocol: string, networkManager: NetworkManager): void { + if (this.protocolToNetworkManagerMap.has(protocol)) { + this.logger.warn(`Overriding existing networkManager for "${protocol}" protocol.`); + } + + this.protocolToNetworkManagerMap.set(protocol, networkManager); + } + + public getPipelineForMimeType(mimeType: string): Pipeline | undefined { + return this.mimeTypeToPipelineMap.get(mimeType); + } + + public getNetworkManagerForProtocol(protocol: string): NetworkManager | undefined { + return this.protocolToNetworkManagerMap.get(protocol); } public getVideoElement(): HTMLVideoElement | null { @@ -81,75 +122,81 @@ export default class Player { } public getVolumeLevel(): number { - if (this.videoElement === null) { - this.warnAttempt('getVolumeLevel'); - return 0; - } - - return this.videoElement.volume; + return this.safeAttemptOnVideoElement('getVolumeLevel', (videoElement) => videoElement.volume, 0); } public getPlaybackRate(): number { - if (this.videoElement === null) { - this.warnAttempt('getPlaybackRate'); - return 0; - } - - return this.videoElement.playbackRate; + return this.safeAttemptOnVideoElement('getPlaybackRate', (videoElement) => videoElement.playbackRate, 0); } public getCurrentTime(): number { - if (this.videoElement === null) { - this.warnAttempt('getCurrentTime'); - return 0; - } - - return this.videoElement.currentTime; + return this.safeAttemptOnVideoElement('getCurrentTime', (videoElement) => videoElement.currentTime, 0); } public setPlaybackRate(rate: number): void { - if (this.videoElement === null) { - return this.warnAttempt('setPlaybackRate'); - } - - this.videoElement.playbackRate = rate; + return this.safeAttemptOnVideoElement( + 'setPlaybackRate', + (videoElement) => void (videoElement.playbackRate = rate), + undefined + ); } public setVolumeLevel(volumeLevel: number): void { - if (this.videoElement === null) { - return this.warnAttempt('setVolumeLevel'); - } - - if (volumeLevel > 1 || volumeLevel < 0) { - this.logger.warn('volume level should in range [0, 1]. received: ', volumeLevel); + let level: number; + + if (volumeLevel > 1) { + level = 1; + this.logger.warn(`Volume level should be in range [0, 1]. Received: ${volumeLevel}. Value is clamped to 1.`); + } else if (volumeLevel < 0) { + level = 0; + this.logger.warn(`Volume level should be in range [0, 1]. Received: ${volumeLevel}. Value is clamped to 0.`); + } else { + level = volumeLevel; } - this.videoElement.volume = volumeLevel; + return this.safeAttemptOnVideoElement( + 'setVolumeLevel', + (videoElement) => void (videoElement.volume = level), + undefined + ); } public seek(seekTarget: number): void { - if (this.videoElement === null) { - return this.warnAttempt('seek'); - } - - const isValidSeekTarget = this.getSeekableRanges().some((timeRange) => timeRange.isInRange(seekTarget)); - - if (!isValidSeekTarget) { - return this.logger.warn('provided seek target is out of available seekable time ranges'); - } - - this.videoElement.currentTime = seekTarget; - // TODO: should we interact with pipeline here? Or "seeking" event will be enough + return this.safeAttemptOnVideoElement( + 'seek', + (videoElement) => { + const seekableRanges = this.getSeekableRanges(); + const isValidSeekTarget = seekableRanges.some((timeRange) => timeRange.isInRangeInclusive(seekTarget)); + + if (!isValidSeekTarget) { + this.logger.warn( + `provided seek target (${seekTarget}) is out of available seekable time ranges: `, + seekableRanges + ); + return; + } + + videoElement.currentTime = seekTarget; + // TODO: should we interact with pipeline here? Or "seeking" event will be enough + }, + undefined + ); } public getSeekableRanges(): Array { - // TODO: should be from the current pipeline - return []; + return this.safeAttemptOnVideoElement( + 'getSeekableRanges', + (videoElement) => PlayerTimeRange.fromTimeRanges(videoElement.seekable), + [] as Array + ); } - public getBuffered(): Array { - // TODO: should be from the current pipeline - return []; + public getBufferedRanges(): Array { + return this.safeAttemptOnVideoElement( + 'getBufferedRanges', + (videoElement) => PlayerTimeRange.fromTimeRanges(videoElement.buffered), + [] as Array + ); } public getTextTracks(): Array { @@ -195,52 +242,83 @@ export default class Player { } public mute(): void { - if (this.videoElement === null) { - return this.warnAttempt('mute'); - } + return this.safeAttemptOnVideoElement( + 'mute', + (videoElement) => { + const isMuted = videoElement.muted; - this.videoElement.muted = true; + if (isMuted) { + //already muted + return; + } + + videoElement.muted = true; + this.eventEmitter.emit(Events.MutedStatusChanged, new MutedStatusChangedEvent(true)); + }, + undefined + ); } public unmute(): void { - if (this.videoElement === null) { - return this.warnAttempt('unmute'); - } + return this.safeAttemptOnVideoElement( + 'unmute', + (videoElement) => { + const isMuted = videoElement.muted; - this.videoElement.muted = false; + if (!isMuted) { + // already un-muted + return; + } + + videoElement.muted = false; + this.eventEmitter.emit(Events.MutedStatusChanged, new MutedStatusChangedEvent(false)); + }, + undefined + ); } public getIsMuted(): boolean { - if (this.videoElement === null) { - this.warnAttempt('getIsMuted'); - return false; - } - - return this.videoElement.muted; + return this.safeAttemptOnVideoElement('getIsMuted', (videoElement) => videoElement.muted, false); } public setLoggerLevel(level: LoggerLevel): void { - return this.logger.setLoggerLevel(level); + this.logger.setLoggerLevel(level); + this.eventEmitter.emit(Events.LoggerLevelChanged, new LoggerLevelChangedEvent(this.getLoggerLevel())); } - public readonly addEventListener = this.eventEmitter.on.bind(this.eventEmitter); + public addEventListener( + event: K, + callback: Callback + ): void { + return this.eventEmitter.on(event, callback); + } - public readonly removeEventListener = this.eventEmitter.off.bind(this.eventEmitter); + public once( + event: K, + callback: Callback + ): void { + return this.eventEmitter.once(event, callback); + } - public readonly removeAllEventListeners = this.eventEmitter.reset.bind(this.eventEmitter); + public removeEventListener( + event: K, + callback: Callback + ): void { + return this.eventEmitter.off(event, callback); + } - public readonly removeAllEventListenersForType = this.eventEmitter.offAllFor.bind(this.eventEmitter); + public removeAllEventListenersForType(event: K): void { + return this.eventEmitter.offAllFor(event); + } - public readonly once = this.eventEmitter.once.bind(this.eventEmitter); + public removeAllEventListeners(): void { + return this.eventEmitter.reset(); + } public readonly on = this.addEventListener; public readonly off = this.removeEventListener; - private warnAttempt(method: string): void { - this.logger.warn(`Attempt to call "${method}", but no video element attached. Call "attach" first.`); - } - public play(): void { if (this.videoElement === null) { return this.warnAttempt('play'); @@ -328,6 +406,7 @@ export default class Player { this.videoElement = videoElement; + this.videoElement.addEventListener('volumechange', this.handleVolumeChange); this.videoElement.addEventListener('leavepictureinpicture', this.handleLevePictureInPicture); this.videoElement.addEventListener('enterpictureinpicture', this.handleEnterPictureInPicture); } @@ -341,12 +420,18 @@ export default class Player { this.exitPictureInPicture(); } - this.videoElement.addEventListener('leavepictureinpicture', this.handleLevePictureInPicture); - this.videoElement.addEventListener('enterpictureinpicture', this.handleEnterPictureInPicture); + this.videoElement.removeEventListener('volumechange', this.handleVolumeChange); + this.videoElement.removeEventListener('leavepictureinpicture', this.handleLevePictureInPicture); + this.videoElement.removeEventListener('enterpictureinpicture', this.handleEnterPictureInPicture); this.videoElement = null; } + private readonly handleVolumeChange = (event: Event): void => { + const target = event.target as HTMLVideoElement; + this.eventEmitter.emit(Events.VolumeChanged, new VolumeChangedEvent(target.volume)); + }; + private readonly handleEnterPictureInPicture = (): void => { this.eventEmitter.emit(Events.EnterPictureInPictureMode, new EnterPictureInPictureModeEvent()); }; @@ -399,22 +484,41 @@ export default class Player { } public resetConfiguration(): void { - this.configuration = getDefaultPlayerConfiguration(); + this.configuration = createDefaultConfiguration(); } public dispose(): void { + this.detach(); + this.removeAllEventListeners(); // TODO } + public loadRemoteAsset(uri: URL, mimeType: string): void { + if (this.videoElement === null) { + return this.warnAttempt('loadRemoteAsset'); + } + + return this.load(mimeType, (pipeline) => pipeline.loadRemoteAsset(uri)); + } + + public loadLocalAsset(asset: string | ArrayBuffer, mimeType: string): void { + if (this.videoElement === null) { + return this.warnAttempt('loadLocalAsset'); + } + + return this.load(mimeType, (pipeline) => pipeline.loadLocalAsset(asset)); + } + private load(mimeType: string, pipelineHandler: (pipeline: Pipeline) => void): void { - const pipeline = Player.pipelinesMap.get(mimeType); + let pipeline = this.mimeTypeToPipelineMap.get(mimeType); - if (pipeline) { - return pipelineHandler(pipeline); + if (!pipeline && this.videoElement?.canPlayType(mimeType)) { + pipeline = new NativePipeline({ logger: this.logger.createSubLogger('NativePipeline') }); } - if (this.videoElement?.canPlayType(mimeType)) { - return pipelineHandler(new NativePipeline()); + if (pipeline) { + pipeline.setMapProtocolToNetworkManager(this.protocolToNetworkManagerMap); + return pipelineHandler(pipeline); } this.logger.warn('no supported pipelines found for ', mimeType); @@ -422,19 +526,20 @@ export default class Player { this.eventEmitter.emit(Events.Error, new ErrorEvent(new NoSupportedPipelineError())); } - public loadRemoteAsset(uri: string, mimeType: string): void { + private safeAttemptOnVideoElement( + methodName: string, + executor: (videoElement: HTMLVideoElement) => T, + fallback: T + ): T { if (this.videoElement === null) { - return this.warnAttempt('loadRemoteAsset'); + this.warnAttempt(methodName); + return fallback; } - return this.load(mimeType, (pipeline) => pipeline.loadRemoteAsset(uri)); + return executor(this.videoElement); } - public loadLocalAsset(asset: string | ArrayBuffer, mimeType: string): void { - if (this.videoElement === null) { - return this.warnAttempt('loadLocalAsset'); - } - - return this.load(mimeType, (pipeline) => pipeline.loadLocalAsset(asset)); + private warnAttempt(method: string): void { + this.logger.warn(`Attempt to call "${method}", but no video element attached. Call "attach" first.`); } } diff --git a/packages/playback/src/lib/utils/eventEmitter.ts b/packages/playback/src/lib/utils/eventEmitter.ts index 3465cc1..d815e91 100644 --- a/packages/playback/src/lib/utils/eventEmitter.ts +++ b/packages/playback/src/lib/utils/eventEmitter.ts @@ -1,4 +1,4 @@ -type Callback = (data: T) => void; +export type Callback = (data: T) => void; export default class EventEmitter { private events = new Map>>(); diff --git a/packages/playback/src/lib/utils/timeRanges.ts b/packages/playback/src/lib/utils/timeRanges.ts new file mode 100644 index 0000000..9ba4404 --- /dev/null +++ b/packages/playback/src/lib/utils/timeRanges.ts @@ -0,0 +1,47 @@ +export default class PlayerTimeRange { + private readonly rangeStart: number; + private readonly rangeEnd: number; + + public constructor(start: number, end: number) { + this.rangeStart = start; + this.rangeEnd = end; + } + + public get start(): number { + return this.rangeStart; + } + + public get end(): number { + return this.rangeEnd; + } + + public isInRangeInclusive(time: number): boolean { + return time >= this.rangeStart && time <= this.rangeEnd; + } + + public isInRangeExclusive(time: number): boolean { + return time > this.rangeStart && time < this.rangeEnd; + } + + // Additional Methods + public isInPast(time: number): boolean { + return time < this.rangeStart; + } + + public isInFuture(time: number): boolean { + return time > this.rangeEnd; + } + + public static fromTimeRanges(timeRanges: TimeRanges): Array { + const result = []; + + for (let i = 0; i < timeRanges.length; i++) { + const start = timeRanges.start(i); + const end = timeRanges.end(i); + + result.push(new PlayerTimeRange(start, end)); + } + + return result; + } +} diff --git a/packages/playback/src/player.ts b/packages/playback/src/player.ts index 54eb640..0d49bd2 100644 --- a/packages/playback/src/player.ts +++ b/packages/playback/src/player.ts @@ -1 +1 @@ -export { default as Player } from './lib/player'; +export { default as Player } from './lib/player/player'; diff --git a/packages/playback/test/player.test.ts b/packages/playback/test/player.test.ts new file mode 100644 index 0000000..c76b39d --- /dev/null +++ b/packages/playback/test/player.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { Player } from '../src/player'; +import Logger from '../src/lib/utils/logger'; +import NetworkManager from '../src/lib/network/networkManager'; +import type { PlayerEventTypeToEventMap } from '../src/lib/player/events/PlayerEventTypeToEventMap'; +import EventEmitter from '../src/lib/utils/eventEmitter'; +import { DashPipeline, HlsPipeline } from '../src/lib/pipelines/mse'; + +describe('Player', () => { + let logger: Logger; + let networkManager: NetworkManager; + let videoElement: HTMLVideoElement; + let dashPipeline: DashPipeline; + let hlsPipeline: HlsPipeline; + let eventEmitter: EventEmitter; + let player: Player; + + beforeEach(() => { + logger = new Logger(console, 'Player'); + networkManager = new NetworkManager(logger); + eventEmitter = new EventEmitter(); + videoElement = document.createElement('video'); + dashPipeline = new DashPipeline(); + hlsPipeline = new HlsPipeline(); + player = new Player({ logger, eventEmitter }); + }); + + describe('registerPipeline', () => { + it('should register a new pipeline', () => { + player.registerPipeline('application/dash+xml', dashPipeline); + player.registerPipeline('application/x-mpegurl', hlsPipeline); + expect(player.getPipelineForMimeType('application/dash+xml')).toBe(dashPipeline); + expect(player.getPipelineForMimeType('application/x-mpegurl')).toBe(hlsPipeline); + expect(player.getPipelineForMimeType('application/custom')).toBe(undefined); + }); + + it('should log warn if pipeline with provided mimetype already exists', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.registerPipeline('application/dash+xml', dashPipeline); + player.registerPipeline('application/dash+xml', dashPipeline); + expect(warnSpy).toHaveBeenNthCalledWith(1, 'Overriding existing pipeline for "application/dash+xml" mimeType.'); + }); + }); + + describe('registerNetworkManager', () => { + it('should register a new networkManager', () => { + player.registerNetworkManager('http', networkManager); + player.registerNetworkManager('https', networkManager); + expect(player.getNetworkManagerForProtocol('http')).toBe(networkManager); + expect(player.getNetworkManagerForProtocol('https')).toBe(networkManager); + expect(player.getNetworkManagerForProtocol('custom')).toBe(undefined); + }); + + it('should log warn if networkManager with provided protocol already exists', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.registerNetworkManager('http', networkManager); + player.registerNetworkManager('http', networkManager); + expect(warnSpy).toHaveBeenNthCalledWith(1, 'Overriding existing networkManager for "http" protocol.'); + }); + }); + + describe('loggerLevel', () => { + describe('setLoggerLevel', () => { + it('should set logger level', () => { + player.addEventListener(Player.Events.LoggerLevelChanged, (event) => { + expect(event.type).toBe(Player.Events.LoggerLevelChanged); + expect(event.level).toBe(Player.LoggerLevel.Info); + }); + + player.setLoggerLevel(Player.LoggerLevel.Info); + expect(player.getLoggerLevel()).toBe(Player.LoggerLevel.Info); + }); + }); + + describe('getLoggerLevel', () => { + it('should return default logger level', () => { + const level = player.getLoggerLevel(); + expect(level).toBe(Player.LoggerLevel.Debug); + }); + }); + }); + + describe('volume', () => { + describe('setVolumeLevel', () => { + it('should log attempt message if video element is not attached', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.setVolumeLevel(0.5); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Attempt to call "setVolumeLevel", but no video element attached. Call "attach" first.' + ); + }); + + it('should set volume level on the attached video element', () => { + player.addEventListener(Player.Events.VolumeChanged, (event) => { + expect(event.type).toBe(Player.Events.VolumeChanged); + expect(event.volume).toBe(0.5); + }); + + player.attach(videoElement); + player.setVolumeLevel(0.5); + expect(videoElement.volume).toBe(0.5); + }); + + it('should clamp value to 1 if provided value is more than 1', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.attach(videoElement); + player.setVolumeLevel(2); + expect(videoElement.volume).toBe(1); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Volume level should be in range [0, 1]. Received: 2. Value is clamped to 1.' + ); + }); + + it('should clamp value to 0 if provided value is more less than 0', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.attach(videoElement); + player.setVolumeLevel(-1); + expect(videoElement.volume).toBe(0); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Volume level should be in range [0, 1]. Received: -1. Value is clamped to 0.' + ); + }); + }); + + describe('getVolumeLevel', () => { + it('should log attempt message if video element is not attached and return fallback', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + const level = player.getVolumeLevel(); + expect(level).toBe(0); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Attempt to call "getVolumeLevel", but no video element attached. Call "attach" first.' + ); + }); + + it('should return default value', () => { + player.attach(videoElement); + const level = player.getVolumeLevel(); + expect(level).toBe(1); + }); + }); + }); + + describe('mute/unmute', () => { + describe('getIsMuted', () => { + it('should log attempt message if video element is not attached and return fallback', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + const isMuted = player.getIsMuted(); + expect(isMuted).toBe(false); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Attempt to call "getIsMuted", but no video element attached. Call "attach" first.' + ); + }); + + it('should return default value', () => { + player.attach(videoElement); + expect(player.getIsMuted()).toBe(false); + }); + }); + + describe('mute', () => { + it('should log attempt message if video element is not attached', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.mute(); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Attempt to call "mute", but no video element attached. Call "attach" first.' + ); + }); + + it('should mute player', () => { + player.addEventListener(Player.Events.MutedStatusChanged, (event) => { + expect(event.type).toBe(Player.Events.MutedStatusChanged); + expect(event.isMuted).toBe(true); + }); + player.attach(videoElement); + player.mute(); + expect(player.getIsMuted()).toBe(true); + }); + }); + + describe('unmute', () => { + it('should log attempt message if video element is not attached', () => { + const warnSpy = jest.spyOn(logger, 'warn'); + player.unmute(); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'Attempt to call "unmute", but no video element attached. Call "attach" first.' + ); + }); + + it('should unmute player', () => { + player.attach(videoElement); + player.mute(); + expect(player.getIsMuted()).toBe(true); + player.addEventListener(Player.Events.MutedStatusChanged, (event) => { + expect(event.type).toBe(Player.Events.MutedStatusChanged); + expect(event.isMuted).toBe(false); + }); + player.unmute(); + expect(player.getIsMuted()).toBe(false); + }); + }); + }); +}); diff --git a/packages/playback/test/utils/timeRanges.test.ts b/packages/playback/test/utils/timeRanges.test.ts new file mode 100644 index 0000000..0d7aa0a --- /dev/null +++ b/packages/playback/test/utils/timeRanges.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from '@jest/globals'; +import PlayerTimeRange from '../../src/lib/utils/timeRanges'; + +class MockTimeRange implements TimeRanges { + public constructor(private timeRangeArray: Array<{ start: number; end: number }>) {} + + public get length(): number { + return this.timeRangeArray.length; + } + + public start(index: number): number { + return this.timeRangeArray[index].start; + } + + public end(index: number): number { + return this.timeRangeArray[index].end; + } +} + +describe('PlayerTimeRange', () => { + it('Constructor should correctly set start and end values', () => { + const playerTimeRange = new PlayerTimeRange(10, 20); + expect(playerTimeRange.start).toEqual(10); + expect(playerTimeRange.end).toEqual(20); + }); + + it('isInRangeInclusive should correctly assess if time is within range inclusively', () => { + const playerTimeRange = new PlayerTimeRange(10, 20); + expect(playerTimeRange.isInRangeInclusive(15)).toBe(true); + expect(playerTimeRange.isInRangeInclusive(5)).toBe(false); + expect(playerTimeRange.isInRangeInclusive(10)).toBe(true); // checking edge case + expect(playerTimeRange.isInRangeInclusive(20)).toBe(true); // checking edge case + }); + + it('isInRangeExclusive should correctly assess if time is within range exclusively', () => { + const playerTimeRange = new PlayerTimeRange(10, 20); + expect(playerTimeRange.isInRangeExclusive(15)).toBe(true); + expect(playerTimeRange.isInRangeExclusive(10)).toBe(false); + }); + + it('isInPast should correctly assess if time is lesser than range start', () => { + const playerTimeRange = new PlayerTimeRange(10, 20); + expect(playerTimeRange.isInPast(5)).toBe(true); + expect(playerTimeRange.isInPast(15)).toBe(false); + }); + + it('isInFuture should correctly assess if time is greater than range end', () => { + const playerTimeRange = new PlayerTimeRange(10, 20); + expect(playerTimeRange.isInFuture(25)).toBe(true); + expect(playerTimeRange.isInFuture(15)).toBe(false); + }); + + it('fromTimeRanges should correctly create PlayerTimeRanges from TimeRanges', () => { + const mockTimeRange = new MockTimeRange([ + { start: 10, end: 20 }, + { start: 30, end: 40 }, + ]); + const playerTimeRanges = PlayerTimeRange.fromTimeRanges(mockTimeRange); + + expect(playerTimeRanges[0].start).toEqual(mockTimeRange.start(0)); + expect(playerTimeRanges[0].end).toEqual(mockTimeRange.end(0)); + expect(playerTimeRanges[1].start).toEqual(mockTimeRange.start(1)); + expect(playerTimeRanges[1].end).toEqual(mockTimeRange.end(1)); + }); +});