diff --git a/docs/arch/001-native-strategy.md b/docs/arch/001-native-strategy.md index d1feb2c9..545cecb7 100644 --- a/docs/arch/001-native-strategy.md +++ b/docs/arch/001-native-strategy.md @@ -2,21 +2,23 @@ Originally Added: February 20th, 2019 -# Context +## Context -* As it stands, `bigscreen-player` requires using the `tal` device object - when in `talstrategy` - to obtain a media player for playback -* Since the `tal` device media player exists as a singleton, applications using `talstrategy` can only access one active media element. -* Unlike `msestrategy`, `talstrategy` also requires loading in the extra dependency (`tal` device) - and is therefore limited by the limitations of the `tal` device. +- As it stands, `bigscreen-player` requires using the `tal` device object - when in `talstrategy` - to obtain a media player for playback +- Since the `tal` device media player exists as a singleton, applications using `talstrategy` can only access one active media element. +- Unlike `msestrategy`, `talstrategy` also requires loading in the extra dependency (`tal` device) - and is therefore limited by the limitations of the `tal` device. -# Decision -* A new strategy type - `nativestrategy` - has been created which pulls the media player out of `tal` -* This is a refactor of the `tal` device media player code which allows further manipulation and control i.e. multiple video playback +## Decision +- A new strategy type - `nativestrategy` - has been created which pulls the media player out of `tal` +- This is a refactor of the `tal` device media player code which allows further manipulation and control i.e. multiple video playback + +## Status -# Status Approved -# Consequences -* Multiple active video instances can be controlled - * Preloading of content on different media elements is more achievable -* The successful migration of all media player code from `tal` will create much greater modularity for `bigscreen-player` - therefore removing any limitations introduced by the current coupling with `tal` \ No newline at end of file +## Consequences + +- Multiple active video instances can be controlled + - Preloading of content on different media elements is more achievable +- The successful migration of all media player code from `tal` will create much greater modularity for `bigscreen-player` - therefore removing any limitations introduced by the current coupling with `tal` diff --git a/docs/arch/002-sinon.md b/docs/arch/002-sinon.md index 02edd185..af4bb9c2 100644 --- a/docs/arch/002-sinon.md +++ b/docs/arch/002-sinon.md @@ -21,9 +21,9 @@ Accepted ## Consequences -* Using sinon makes it simpler to provide custom responses to network requests. -* Another third party library is now pulled into bigscreen-player as a dev dependency. +- Using sinon makes it simpler to provide custom responses to network requests. +- Another third party library is now pulled into bigscreen-player as a dev dependency. ## Further Reading -See https://sinonjs.org/ for more information +See for more information diff --git a/docs/arch/003-subtitles-polling-frequency.md b/docs/arch/003-subtitles-polling-frequency.md index b1ab377c..b1d626ab 100644 --- a/docs/arch/003-subtitles-polling-frequency.md +++ b/docs/arch/003-subtitles-polling-frequency.md @@ -4,7 +4,7 @@ Originally Added: April 7th, 2020 ## Context -Subtitles in Bigscreen Player are updated by checking for the next available subtitle using a set timeout of 750ms. +Subtitles in Bigscreen Player are updated by checking for the next available subtitle using a set timeout of 750ms. This number of 750ms was originally used with no record of why it was picked, and is also potentially too infrequent to keep the subtitles perfectly in sync with the audio and visual cues. There was a piece of work done to increase the polling rate to 250ms, however we found that this caused some slower devices to buffer due to the increased load on the devices memory. @@ -18,5 +18,5 @@ Accepted ## Consequences -* Synchronisation issues are negligible. -* It does not cause device performance to suffer. \ No newline at end of file +- Synchronisation issues are negligible. +- It does not cause device performance to suffer. diff --git a/docs/arch/004-time-representation.md b/docs/arch/004-time-representation.md new file mode 100644 index 00000000..b2b41966 --- /dev/null +++ b/docs/arch/004-time-representation.md @@ -0,0 +1,36 @@ +# 004 Removing `windowType`; changing time representation + +Originally added: 2025-02-03 + +## Status + +| Discussing | Approved | Superceded | +| ---------- | -------- | ---------- | +| | x | | + +## Context + +BigscreenPlayer supports DASH and HLS streaming. Each transfer format (aka streaming protocol) represents time in a stream in a different way. BigscreenPlayer normalised these times in versions prior to v9.0.0. This normalisation made multiple assumptions: + +- The timestamp in the media samples are encoded as UTC times (in seconds) for "sliding window" content i.e. streams with time shift. +- Streams with time shift never use a presentation time offset. + +What is more, metadata (i.e. the `windowType`) determined BigscreenPlayer's manifest parsing strategy from v7.1.0 and codified these assumptions. + +- How might we overhaul our time representation to support streams that don't comply with these assumptions? + +### Considered Options + +1. Expose time reported by the `MediaElement` directly. Provide functions to convert the time from the `MediaElement` into availability and media sample time. +2. Do not apply time correction based on `timeShiftBufferDepth` if `windowType === WindowTypes.GROWING` +3. Do not apply time correction based on `timeShiftBufferDepth` if SegmentTemplates in the MPD have `presentationTimeOffset` + +## Decision + +Chosen option: 1 + +This approach provides a lot of flexibility to consumers of BigscreenPlayer. It also simplifies time-related calculations such as failover, start time, and subtitle synchronisation. + +## Consequences + +A major version (v9.0.0) to remove window type and overhaul BigscreenPlayer's internals. diff --git a/docs/arch/005-remove-fake-seeking.md b/docs/arch/005-remove-fake-seeking.md new file mode 100644 index 00000000..2ddc6b14 --- /dev/null +++ b/docs/arch/005-remove-fake-seeking.md @@ -0,0 +1,34 @@ +# 005 Remove fake seeking from restartable strategy + +Originally added: 2025-02-04 + +## Status + +| Discussing | Approved | Superceded | +| ---------- | -------- | ---------- | +| | x | | + +## Context + +Native media players with the capability to start playback in a livestream from any (available) point in the stream are called "restartable" in BigscreenPlayer's jargon. Unlike "seekable" devices, "restartable" devices don't support in-stream navigation. In other words, seeking is not supported. + +BigscreenPlayer exploited this restart capability to implement "fake seeking" prior to v9.0.0. The restartable player effectively polyfilled the native media player's implementation of `MediaElement#currentTime` and `MediaElement#seekable`. This polyfill relied on the `windowType` metadata to make assumptions about the shape of the stream's seekable range. v9.0.0 deprecates `windowType`. + +- Should we continue to support fake seeking for native playback? +- How might we continue to support fake seeking? + +### Considered Options + +1. Remove fake seeking from restartable strategy +2. Poll the HLS manifest for time shift +3. Provide a "magic" time shift buffer depth for HLS streams + +## Decision + +Chosen option: 1 + +The effort to contine support for fake seeking on restartable devices is not justified by the small number of people that benefit from the continued support. + +## Consequences + +Viewers that use devices on the restartable strategy will no longer be able to pause or seek in-stream. diff --git a/docs/arch/006-detect-autoresume.md b/docs/arch/006-detect-autoresume.md new file mode 100644 index 00000000..d5003b99 --- /dev/null +++ b/docs/arch/006-detect-autoresume.md @@ -0,0 +1,36 @@ +# 006 Detect timeshift to enable auto-resume + +Originally added: 2025-02-04 + +## Status + +| Discussing | Approved | Superceded | +| ---------- | -------- | ---------- | +| | x | | + +## Context + +BigscreenPlayer's auto-resume feature prevents undefined behaviour when native players resume playback outside the seekable range. Auto-resume consists of two mechanisms: + +1. Playback is resumed before current time can drift outside of the seekable range +2. Pausing isn't possible when current time is close to the start of the seekable range + +Auto-resume is only relevant for streams with time shift. The presence of time shift was signalled through the `windowType === WindowTypes.SLIDING` parameter prior to v9.0.0. v9.0.0 deprecates `windowType`. + +DASH manifests explicitly encode the time shift of the stream through the `timeShiftBufferDepth`. On the other hand, time shift in HLS manifests is only detectable by refreshing the manifest. + +- How might we detect timeshift and enable the auto-resume feature for DASH and HLS streams? + +### Considered Options + +1. Poll the HLS manifest to check if the first segment changes +2. Poll the seekable range for changes to the start of the seekable range +3. Provide a "magic" time shift buffer depth for HLS streams + +## Decision + +Chosen option: 2 + +## Consequences + +The time it takes the `timeshiftdetector` to detect and signal timeshift depends on it's polling rate. Hence, there is a risk the user navigates outside of the seekable range in the span of time before the `timeshiftdetector` detects a sliding seekable range. diff --git a/docs/arch/007-estimate-hls-ast.md b/docs/arch/007-estimate-hls-ast.md new file mode 100644 index 00000000..c018027c --- /dev/null +++ b/docs/arch/007-estimate-hls-ast.md @@ -0,0 +1,44 @@ +# 007 Estimate HLS Availability Start Time + +Originally added: 2025-02-04 + +## Status + +| Discussing | Approved | Superceded | +| ---------- | -------- | ---------- | +| | x | | + +## Context + +BigscreenPlayer adds functions to convert between three timelines: + +1. Presentation time: Output by the MediaElement +2. Media sample time: Timestamps encoded in the current media +3. Availablity time: UTC times that denote time available. Only relevant for dynamic streams. + +BigscreenPlayer relies on metadata in the manifest to calculate each conversion. + +For DASH: + +- Presentation time <-> Media sample time relies on `presentationTimeOffset` +- Presentation time <-> Availability time relies on `availabilityStartTime` + +For HLS: + +- Presentation time <-> Media sample time relies on `programDateTime` +- Presentation time <-> Availability time relies on ??? + +HLS signals availability through the segment list. An HLS media player must refresh the segment list to track availability. Availability start time can be estimated as the difference between the current wallclock time and the duration of the stream so far. This estimate should also correct for any difference between the client and server's UTC wallclock time. + +### Considered Options + +1. Accept the conversion between availability and presentation time is broken for HLS streams. +2. Estimate availability start time for HLS streams. This requires clients provide the offset between the client and server's UTC wallclock time in order to synchronise the calculation. + +## Decision + +Chosen option: 1 + +## Consequences + +The conversion between presentation time and availability start time is erroneous for HLS. diff --git a/docs/arch/__template.md b/docs/arch/__template.md new file mode 100644 index 00000000..b100d7b3 --- /dev/null +++ b/docs/arch/__template.md @@ -0,0 +1,19 @@ +# 000 Title + +Originally added: + +## Status + +| Discussing | Approved | Superceded | +| ---------- | -------- | ---------- | +| | x | | + +## Context + +### [Considered Options] + +Optional + +## Decision + +## Consequences diff --git a/docs/tutorials/00-getting-started.md b/docs/tutorials/00-getting-started.md index 4fbf9c38..3d1e0e20 100644 --- a/docs/tutorials/00-getting-started.md +++ b/docs/tutorials/00-getting-started.md @@ -22,13 +22,13 @@ Configuration for bigscreen-player can be set using an object on the window: window.bigscreenPlayer ``` -You must provide a *playback strategy* to use BigscreenPlayer: +You must provide a _playback strategy_ to use BigscreenPlayer: ```javascript -window.bigscreenPlayer.playbackStrategy = 'msestrategy' // OR 'nativestrategy' OR 'basicstrategy' +window.bigscreenPlayer.playbackStrategy = "msestrategy" // OR 'nativestrategy' OR 'basicstrategy' ``` -The MSEStrategy uses DASH. It is most likely what you want. More detail in the [documentation on playback strategies](). You should also have a peek at the [documentation on settings and overrides](https://bbc.github.io/bigscreen-player/api/tutorial-02-settings-and-overrides.html) +The `msestrategy` uses Dash.js under the hood. It is likely to be what you want. You should read [the documentation on playback strategies](https://bbc.github.io/bigscreen-player/api/tutorial-01-playback-strategies.html) if you want to use a native media player from your browser. You should also have a peek at the [documentation on settings and overrides](https://bbc.github.io/bigscreen-player/api/tutorial-02-settings-and-overrides.html) ### Minimal Data @@ -37,9 +37,9 @@ You must provide a manifest and its MIME type. ```javascript const minimalData = { media: { - type: 'application/dash+xml', - urls: [{ url: 'https://example.com/video.mpd' }] - } + type: "application/dash+xml", + urls: [{ url: "https://example.com/video.mpd" }], + }, } ``` @@ -67,9 +67,7 @@ playbackElement.id = 'BigscreenPlayback' body.appendChild(playbackElement) -const enableSubtitles = false - -bigscreenPlayer.init(playbackElement, optionalData, WindowTypes.STATIC, enableSubtitles) +bigscreenPlayer.init(playbackElement, minimalData) ``` ## All Options @@ -79,45 +77,50 @@ The full set of options for BigscreenPlayer is: ```javascript const optionalData = { initialPlaybackTime: 0, // Time (in seconds) to begin playback from + enableSubtitles: false, media: { - type: 'application/dash+xml', + type: "application/dash+xml", kind: MediaKinds.VIDEO, // Can be VIDEO, or AUDIO urls: [ // Multiple urls offer the ability to fail-over to another CDN if required { - url: 'https://example.com/video.mpd', - cdn: 'origin' // For Debug Tool reference - }, { - url: 'https://failover.example.com/video.mpd', - cdn: 'failover' - } + url: "https://example.com/video.mpd", + cdn: "origin", // For Debug Tool reference + }, + { + url: "https://failover.example.com/video.mpd", + cdn: "failover", + }, ], - captions: [{ - url: 'https://example.com/captions/$segment$', // $segment$ required for replacement for live subtitle segments - segmentLength: 3.84, // Required to calculate live subtitle segment to fetch & live subtitle URL. - cdn: 'origin' // Displayed by Debug Tool - }, { - url: 'https://failover.example.com/captions/$segment$', - segmentLength: 3.84, - cdn: 'failover' - } + captions: [ + { + url: "https://example.com/captions/$segment$", // $segment$ required for replacement for live subtitle segments + segmentLength: 3.84, // Required to calculate live subtitle segment to fetch & live subtitle URL. + cdn: "origin", // Displayed by Debug Tool + }, + { + url: "https://failover.example.com/captions/$segment$", + segmentLength: 3.84, + cdn: "failover", + }, ], - captionsUrl: 'https://example.com/imsc-doc.xml', // NB This parameter is being deprecated in favour of the captions array shown above. + captionsUrl: "https://example.com/imsc-doc.xml", // NB This parameter is being deprecated in favour of the captions array shown above. subtitlesRequestTimeout: 5000, // Optional override for the XHR timeout on sidecar loaded subtitles subtitleCustomisation: { size: 0.75, - lineHeight: 1.10, - fontFamily: 'Arial', - backgroundColour: 'black' // (css colour, hex) + lineHeight: 1.1, + fontFamily: "Arial", + backgroundColour: "black", // (css colour, hex) }, - playerSettings: { // See settings documentation for more details + playerSettings: { + // See settings documentation for more details failoverResetTime: 60000, streaming: { buffer: { - bufferToKeep: 8 - } - } - } - } + bufferToKeep: 8, + }, + }, + }, + }, } ``` diff --git a/docs/tutorials/01-playback-strategies.md b/docs/tutorials/01-playback-strategies.md index 2a038837..fce65f39 100644 --- a/docs/tutorials/01-playback-strategies.md +++ b/docs/tutorials/01-playback-strategies.md @@ -6,17 +6,17 @@ There are three options available: - `nativestrategy` - `basicstrategy` -Your app should write this globally to the window before initialising Bigscreen Player. This enables only the required media player code to be loaded. For example, if MSE playback is not needed, the *dashjs* library does not have to be loaded. +Your app should write this to the `globalThis` object (i.e. the `window` on browsers) before initialising Bigscreen Player. This enables only the required media player code to be loaded. For example, if MSE playback is not needed, the _dashjs_ library does not have to be loaded. ```javascript -window.bigscreenPayer.playbackStrategy = 'msestrategy' // OR 'nativestrategy' OR 'basicstategy' +window.bigscreenPayer.playbackStrategy = "msestrategy" // OR 'nativestrategy' OR 'basicstategy' ``` The player will require in the correct strategy file at runtime. ## MSE Strategy -The MSE strategy utilises the open source [*dashjs*](https://github.com/Dash-Industry-Forum/dash.js/wiki) library. Dashjs handles much of the playback, and the strategy interacts with this to provide a consistent interface. No other dependencies are requried. +The MSE strategy utilises the open source [_dashjs_](https://github.com/Dash-Industry-Forum/dash.js/wiki) library. Dashjs handles much of the playback, and the strategy interacts with this to provide a consistent interface. No other dependencies are requried. ## Native Strategy @@ -28,11 +28,16 @@ We have migrated TAL media player implementations into the Native Strategy, so t - `SAMSUNG_STREAMING` - `SAMSUNG_STREAMING_2015` -This requires additional config, to select which implementation to use and indicate the device's live playback capability: +This requires additional config to select which media player implementation to use. ```javascript -window.bigscreenPlayer.liveSupport: 'seekable', // OR 'none' OR 'playable' OR 'restartable'; defaults to 'playable' -window.bigscreenPlayer.mediaPlayer: 'html5' // OR 'cehtml'; defaults to 'html5' +window.bigscreenPlayer.mediaPlayer: 'html5' +``` + +You must also indicate the device's live playback capability. There's more info in [the documentation on live-streaming](https://bbc.github.io/bigscreen-player/api/tutorial-live-streaming.html) + +```javascript +window.bigscreenPlayer.liveSupport = "seekable" ``` ## Basic Strategy diff --git a/docs/tutorials/Mocking Playback.md b/docs/tutorials/XX-mocking.md similarity index 96% rename from docs/tutorials/Mocking Playback.md rename to docs/tutorials/XX-mocking.md index 0758d80c..17b62bc9 100644 --- a/docs/tutorials/Mocking Playback.md +++ b/docs/tutorials/XX-mocking.md @@ -1,3 +1,5 @@ +THIS IS DEPRECATED + When writing tests for your application it may be useful to use the mocking functions provided. This creates a fake player with mocking hook functions to simulate real world scenarios. Bigscreen Player includes a test mode than can be triggered by calling `mock()` or `mockJasmine()`. @@ -67,9 +69,9 @@ Sets return value of `getMediaKind()` and `avType` for AV stats to `kind`. Set the seekable range to `newSeekableRange`. -* `newSeekableRange` - * `start` - seekable range start - * `end` - seekable range end +- `newSeekableRange` + - `start` - seekable range start + - `end` - seekable range end ### `setWindowType(type)` @@ -85,4 +87,4 @@ Triggers a non-fatal error which stalls playback. Will be dismissed when any cal ### `triggerErrorHandled()` -Makes Bigsceen Player handle an error - changes the URL if multiple were passed in, and the list hasn't already been exhausted, and resumes playback if Mock Bigsceen Player is automatically progressing. \ No newline at end of file +Makes Bigsceen Player handle an error - changes the URL if multiple were passed in, and the list hasn't already been exhausted, and resumes playback if Mock Bigsceen Player is automatically progressing. diff --git a/docs/tutorials/live-streaming.md b/docs/tutorials/live-streaming.md index da59e4ec..81d724e4 100644 --- a/docs/tutorials/live-streaming.md +++ b/docs/tutorials/live-streaming.md @@ -1,5 +1,4 @@ > This tutorial assumes you have read [Getting Started](https://bbc.github.io/bigscreen-player/api/tutorial-00-getting-started.html) -> ## Live Playback Capability @@ -11,10 +10,12 @@ window.bigscreenPlayer.liveSupport: 'playable' // default LiveSupport can be one of: -- `none` -- `playable` -- `restartable` -- `seekable` +- `none` -- Live playback will fail +- `playable` -- Can only play from the live point +- `restartable` -- Can start playback from any (available) point in the stream. Can't pause or seek. +- `seekable` -- Can start playback from any (available) point in the stream. Can pause and seek. + +Note! The `cehtml` player has only been tested with `liveSupport: playable`. Features such as seeking likely won't work as expected. ## Requirements for DASH diff --git a/docs/tutorials/seeking.md b/docs/tutorials/seeking.md new file mode 100644 index 00000000..e3c81206 --- /dev/null +++ b/docs/tutorials/seeking.md @@ -0,0 +1,52 @@ +A seek is initiated by `BigscreenPlayer#setCurrentTime()`. It can take a number or a number and a timeline. Each timeline is defined in the `Timeline` enum. + +BigscreenPlayer will signal a seek is in progress through the `isSeeking` property on the `WAITING` state change. + +## Setting a start point + +A call to `setCurrentTime()` does nothing until the stream is loaded with `BigscreenPlayer#init()`. You should provide an `initialPlaybackTime` in the initialisation object instead, like: + +```javascript +bigscreenPlayer.init(playbackElement, { + ...init, + initialPlaybackTime: 30, // a presentation time in seconds +}) +``` + +The `initialPlaybackTime` can also reference other timelines, just like `setCurrentTime()` + +```javascript +bigscreenPlayer.init(playbackElement, { + ...init, + initialPlaybackTime: { + seconds: 30, + timeline: Timeline.MEDIA_SAMPLE_TIME, + }, +}) +``` + +## Timelines + +The `Timeline` constant enumerates different reference points you can seek through a stream by. This section explains each timeline. + +### Presentation time + +The time output by the `MediaElement`. The zero point is determined by the stream and transfer format (aka streaming protocol). For example, for HLS `0` always refers to the start of the first segment in the stream on first load. + +Presentation time is output by `BigscreenPlayer#getCurrentTime()` and `BigscreenPlayer#getSeekableRange()`. The value provided to `setCurrentTime()` and `initialPlaybackTime` is treated as presentation time by default. + +### Media sample time + +The timestamps encoded in the media sample(s). + +For DASH the conversion between media sample time and presentation time relies on the `presentationTimeOffset` and `timescale` defined in the MPD. BigscreenPlayer assumes the presentation time offset (in seconds) works out as the same value for all representations in the MPD. + +For HLS the conversion between media sample time and presentation time relies on the `programDateTime` defined in the playlist. BigscreenPlayer assumes the `programDateTime` is associated with the first segment in the playlist. + +### Availability time + +The UTC time denoting the availability of the media. Only applies to dynamic streams. + +For DASH the conversion between availability time and presentation time relies on the `availabilityStartTime`. BigscreenPlayer assumes the stream doesn't define any `availabilityOffset`. + +For HLS the conversion is erroneous, and relies on `programDateTime`. See decision record `007-estimate-hls-ast`. diff --git a/docs/tutorials/tutorials.json b/docs/tutorials/tutorials.json index 66e65c00..9499d0f3 100644 --- a/docs/tutorials/tutorials.json +++ b/docs/tutorials/tutorials.json @@ -16,5 +16,11 @@ }, "live-streaming": { "title": "Live" + }, + "seeking": { + "title": "Seeking" + }, + "XX-mocking": { + "title": "Mocking Playback (deprecated)" } -} \ No newline at end of file +} diff --git a/eslint.config.js b/eslint.config.js index 8759c210..294e96ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,10 +5,11 @@ import tseslint from "typescript-eslint" import { sonarjs } from "./eslint.compat.js" const unsafe = [ + "src/playbackstrategy/modifiers/html5.js", + "src/playbackstrategy/modifiers/cehtml.js", "src/playbackstrategy/modifiers/samsungmaple.js", "src/playbackstrategy/modifiers/samsungstreaming.js", "src/playbackstrategy/modifiers/samsungstreaming2015.js", - "src/playbackstrategy/legacyplayeradapter.js", ] const namingConvention = [ @@ -192,6 +193,10 @@ export default tseslint.config( // Overrides for all tests files: ["**/*.test.{js,cjs,mjs,ts,cts,mts}"], rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/unbound-method": "off", "max-nested-callbacks": "off", "sonarjs/no-identical-functions": "off", "unicorn/consistent-function-scoping": "off", diff --git a/index.html b/index.html index 08689edb..e7ab8b68 100644 --- a/index.html +++ b/index.html @@ -22,11 +22,8 @@ playbackElement.style.height = "720px" playbackElement.style.width = "1280px" - let windowType = "staticWindow" - let enableSubtitles = false - let minimalData = { - initialPlaybackTime: 30, + initialPlaybackTime: { seconds: 30 }, media: { captions: [], type: "application/dash+xml", @@ -43,7 +40,7 @@ }, } - player.init(playbackElement, minimalData, windowType, enableSubtitles, { + player.init(playbackElement, minimalData, { onSuccess: function () { player.toggleDebug() }, diff --git a/src/bigscreenplayer.js b/src/bigscreenplayer.js index 20a86284..0136f8c3 100644 --- a/src/bigscreenplayer.js +++ b/src/bigscreenplayer.js @@ -4,12 +4,16 @@ import MediaState from "./models/mediastate" import PlayerComponent from "./playercomponent" import PauseTriggers from "./models/pausetriggers" -import DynamicWindowUtils from "./dynamicwindowutils" -import WindowTypes from "./models/windowtypes" +import { canPauseAndSeek } from "./dynamicwindowutils" import MockBigscreenPlayer from "./mockbigscreenplayer" import Plugins from "./plugins" import DebugTool from "./debugger/debugtool" -import SlidingWindowUtils from "./utils/timeutils" +import { + presentationTimeToMediaSampleTimeInSeconds, + mediaSampleTimeToPresentationTimeInSeconds, + presentationTimeToAvailabilityTimeInMilliseconds, + availabilityTimeToPresentationTimeInSeconds, +} from "./utils/timeutils" import callCallbacks from "./utils/callcallbacks" import MediaSources from "./mediasources" import Version from "./version" @@ -18,7 +22,9 @@ import ReadyHelper from "./readyhelper" import Subtitles from "./subtitles/subtitles" // TODO: Remove when this becomes a TypeScript file // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { InitData, InitCallbacks, SubtitlesCustomisationOptions } from "./types" +import { InitData, InitCallbacks, SubtitlesCustomisationOptions, PlaybackTime } from "./types" +import { ManifestType } from "./models/manifesttypes" +import { Timeline } from "./models/timeline" function BigscreenPlayer() { let stateChangeCallbacks = [] @@ -29,14 +35,12 @@ function BigscreenPlayer() { let playerReadyCallback let playerErrorCallback let mediaKind - let initialPlaybackTimeEpoch - let serverDate + let initialPlaybackTime let playerComponent let resizer let pauseTrigger let isSeeking = false let endOfStream - let windowType let mediaSources let playbackElement let readyHelper @@ -79,11 +83,12 @@ function BigscreenPlayer() { callCallbacks(stateChangeCallbacks, stateObject) } - if (evt.data.seekableRange) { - DebugTool.staticMetric("seekable-range", [ - deviceTimeToDate(evt.data.seekableRange.start).getTime(), - deviceTimeToDate(evt.data.seekableRange.end).getTime(), - ]) + if ( + evt.data.seekableRange && + typeof evt.data.seekableRange.start === "number" && + typeof evt.data.seekableRange.end === "number" + ) { + DebugTool.staticMetric("seekable-range", [evt.data.seekableRange.start, evt.data.seekableRange.end]) } if (evt.data.duration) { @@ -95,43 +100,26 @@ function BigscreenPlayer() { } } - function deviceTimeToDate(time) { - return getWindowStartTime() ? new Date(convertVideoTimeSecondsToEpochMs(time)) : new Date(time * 1000) - } - - function convertVideoTimeSecondsToEpochMs(seconds) { - return getWindowStartTime() ? getWindowStartTime() + seconds * 1000 : null - } + function bigscreenPlayerDataLoaded({ media, enableSubtitles }) { + const initialPresentationTime = + initialPlaybackTime == null ? undefined : convertPlaybackTimeToPresentationTimeInSeconds(initialPlaybackTime) - function bigscreenPlayerDataLoaded(bigscreenPlayerData, enableSubtitles) { - if (windowType !== WindowTypes.STATIC) { - serverDate = bigscreenPlayerData.serverDate - - initialPlaybackTimeEpoch = bigscreenPlayerData.initialPlaybackTime - // overwrite initialPlaybackTime with video time (it comes in as epoch time for a sliding/growing window) - bigscreenPlayerData.initialPlaybackTime = SlidingWindowUtils.convertToSeekableVideoTime( - bigscreenPlayerData.initialPlaybackTime, - mediaSources.time().windowStartTime - ) - } - - mediaKind = bigscreenPlayerData.media.kind endOfStream = - windowType !== WindowTypes.STATIC && - !bigscreenPlayerData.initialPlaybackTime && - bigscreenPlayerData.initialPlaybackTime !== 0 + mediaSources.time().manifestType === ManifestType.DYNAMIC && + !initialPresentationTime && + initialPresentationTime !== 0 - readyHelper = new ReadyHelper( - bigscreenPlayerData.initialPlaybackTime, - windowType, + readyHelper = ReadyHelper( + initialPresentationTime, + mediaSources.time().manifestType, PlayerComponent.getLiveSupport(), playerReadyCallback ) - playerComponent = new PlayerComponent( + + playerComponent = PlayerComponent( playbackElement, - bigscreenPlayerData, + { media, initialPlaybackTime: initialPresentationTime }, mediaSources, - windowType, mediaStateUpdateCallback, playerErrorCallback, callBroadcastMixADCallbacks @@ -141,18 +129,88 @@ function BigscreenPlayer() { playerComponent, enableSubtitles, playbackElement, - bigscreenPlayerData.media.subtitleCustomisation, + media.subtitleCustomisation, mediaSources, callSubtitlesCallbacks ) } - function getWindowStartTime() { - return mediaSources && mediaSources.time().windowStartTime + /** + * @typedef {Object} PlaybackTimeInit + * @property {number} seconds + * @property {Timeline} [timeline] + */ + + /** + * Normalise time input to the 'PlaybackTime' model, so the unit and timeline is explicit. + * @param {number | PlaybackTimeInit} init + * @returns {PlaybackTime} + */ + function createPlaybackTime(init) { + if (typeof init === "number") { + return { seconds: init, timeline: Timeline.PRESENTATION_TIME } + } + + if (init == null || typeof init !== "object" || typeof init.seconds !== "number") { + throw new TypeError("A numerical playback time must be provided") + } + + return { seconds: init.seconds, timeline: init.timeline ?? Timeline.PRESENTATION_TIME } + } + + function convertPlaybackTimeToPresentationTimeInSeconds(playbackTime) { + const { seconds, timeline } = playbackTime + + switch (timeline) { + case Timeline.PRESENTATION_TIME: + return seconds + case Timeline.MEDIA_SAMPLE_TIME: + return convertMediaSampleTimeToPresentationTimeInSeconds(seconds) + case Timeline.AVAILABILITY_TIME: + return convertAvailabilityTimeToPresentationTimeInSeconds(seconds * 1000) + default: + return seconds + } + } + + function convertPresentationTimeToMediaSampleTimeInSeconds(presentationTimeInSeconds) { + return mediaSources?.time() == null + ? null + : presentationTimeToMediaSampleTimeInSeconds( + presentationTimeInSeconds, + mediaSources.time().presentationTimeOffsetInMilliseconds + ) } - function getWindowEndTime() { - return mediaSources && mediaSources.time().windowEndTime + function convertMediaSampleTimeToPresentationTimeInSeconds(mediaSampleTimeInSeconds) { + return mediaSources?.time() == null + ? null + : mediaSampleTimeToPresentationTimeInSeconds( + mediaSampleTimeInSeconds, + mediaSources.time().presentationTimeOffsetInMilliseconds + ) + } + + function convertPresentationTimeToAvailabilityTimeInMilliseconds(presentationTimeInSeconds) { + return mediaSources?.time() == null || mediaSources?.time().manifestType === ManifestType.STATIC + ? null + : presentationTimeToAvailabilityTimeInMilliseconds( + presentationTimeInSeconds, + mediaSources.time().availabilityStartTimeInMilliseconds + ) + } + + function convertAvailabilityTimeToPresentationTimeInSeconds(availabilityTimeInMilliseconds) { + return mediaSources?.time() == null || mediaSources?.time().manifestType === ManifestType.STATIC + ? null + : availabilityTimeToPresentationTimeInSeconds( + availabilityTimeInMilliseconds, + mediaSources.time().availabilityStartTimeInMilliseconds + ) + } + + function getInitialPlaybackTime() { + return initialPlaybackTime } function toggleDebug() { @@ -182,6 +240,14 @@ function BigscreenPlayer() { return subtitles ? subtitles.available() : false } + function getTimeShiftBufferDepthInMilliseconds() { + return mediaSources.time()?.timeShiftBufferDepthInMilliseconds ?? null + } + + function getPresentationTimeOffsetInMilliseconds() { + return mediaSources.time()?.presentationTimeOffsetInMilliseconds ?? null + } + function callBroadcastMixADCallbacks(enabled) { callCallbacks(broadcastMixADCallbacks, { enabled }) } @@ -193,47 +259,44 @@ function BigscreenPlayer() { * @name init * @param {HTMLDivElement} playbackElement - The Div element where content elements should be rendered * @param {InitData} bigscreenPlayerData - * @param {WindowTypes} newWindowType - * @param {boolean} enableSubtitles - Enable subtitles on initialisation * @param {InitCallbacks} callbacks */ - init: (newPlaybackElement, bigscreenPlayerData, newWindowType, enableSubtitles, callbacks = {}) => { + init: (newPlaybackElement, bigscreenPlayerData, callbacks = {}) => { playbackElement = newPlaybackElement - resizer = Resizer() DebugTool.init() DebugTool.setRootElement(playbackElement) + resizer = Resizer() + + mediaKind = bigscreenPlayerData.media.kind + + if (bigscreenPlayerData.initialPlaybackTime || bigscreenPlayerData.initialPlaybackTime === 0) { + initialPlaybackTime = createPlaybackTime(bigscreenPlayerData.initialPlaybackTime) + } DebugTool.staticMetric("version", Version) - if (typeof bigscreenPlayerData.initialPlaybackTime === "number") { - DebugTool.staticMetric("initial-playback-time", bigscreenPlayerData.initialPlaybackTime) + if (initialPlaybackTime) { + const { seconds, timeline } = initialPlaybackTime + DebugTool.staticMetric("initial-playback-time", [seconds, timeline]) } + if (typeof window.bigscreenPlayer?.playbackStrategy === "string") { DebugTool.staticMetric("strategy", window.bigscreenPlayer && window.bigscreenPlayer.playbackStrategy) } - windowType = newWindowType - serverDate = bigscreenPlayerData.serverDate - - if (serverDate) { - DebugTool.warn("Passing in server date is deprecated. Use on manifest.") - } - playerReadyCallback = callbacks.onSuccess playerErrorCallback = callbacks.onError - const mediaSourceCallbacks = { - onSuccess: () => bigscreenPlayerDataLoaded(bigscreenPlayerData, enableSubtitles), - onError: (error) => { - if (callbacks.onError) { - callbacks.onError(error) - } - }, - } - mediaSources = MediaSources() - mediaSources.init(bigscreenPlayerData.media, serverDate, windowType, getLiveSupport(), mediaSourceCallbacks) + mediaSources + .init(bigscreenPlayerData.media) + .then(() => bigscreenPlayerDataLoaded(bigscreenPlayerData)) + .catch((reason) => { + if (typeof callbacks?.onError === "function") { + callbacks.onError(reason) + } + }) }, /** @@ -264,7 +327,6 @@ function BigscreenPlayer() { endOfStream = undefined mediaKind = undefined pauseTrigger = undefined - windowType = undefined resizer = undefined this.unregisterPlugin() DebugTool.tearDown() @@ -362,17 +424,25 @@ function BigscreenPlayer() { /** * Sets the current time of the media asset. * @function - * @param {Number} time - In seconds + * @param {number} seconds + * @param {Timeline} timeline */ - setCurrentTime(time) { - DebugTool.apicall("setCurrentTime", [time]) + setCurrentTime(seconds, timeline) { + const playbackTime = createPlaybackTime({ seconds, timeline }) + + DebugTool.apicall("setCurrentTime", [playbackTime.seconds.toFixed(3), playbackTime.timeline]) if (playerComponent) { // this flag must be set before calling into playerComponent.setCurrentTime - as this synchronously fires a WAITING event (when native strategy). isSeeking = true - playerComponent.setCurrentTime(time) + + const presentationTimeInSeconds = convertPlaybackTimeToPresentationTimeInSeconds(playbackTime) + + playerComponent.setCurrentTime(presentationTimeInSeconds) + endOfStream = - windowType !== WindowTypes.STATIC && Math.abs(this.getSeekableRange().end - time) < END_OF_STREAM_TOLERANCE + mediaSources.time().manifestType === ManifestType.DYNAMIC && + Math.abs(this.getSeekableRange().end - presentationTimeInSeconds) < END_OF_STREAM_TOLERANCE } }, @@ -409,19 +479,12 @@ function BigscreenPlayer() { */ getMediaKind: () => mediaKind, - /** - * Returns the current window type. - * @see {@link module:bigscreenplayer/models/windowtypes} - * @function - */ - getWindowType: () => windowType, - /** * Returns an object including the current start and end times. * @function - * @returns {Object} {start: Number, end: Number} + * @returns {Object | null} {start: Number, end: Number} */ - getSeekableRange: () => (playerComponent ? playerComponent.getSeekableRange() : {}), + getSeekableRange: () => playerComponent?.getSeekableRange() ?? null, /** * @function @@ -430,28 +493,11 @@ function BigscreenPlayer() { isPlayingAtLiveEdge() { return ( !!playerComponent && - windowType !== WindowTypes.STATIC && + mediaSources.time().manifestType === ManifestType.DYNAMIC && Math.abs(this.getSeekableRange().end - this.getCurrentTime()) < END_OF_STREAM_TOLERANCE ) }, - /** - * @function - * @return {Object} An object of the shape {windowStartTime: Number, windowEndTime: Number, initialPlaybackTime: Number, serverDate: Date} - */ - getLiveWindowData: () => { - if (windowType === WindowTypes.STATIC) { - return {} - } - - return { - windowStartTime: getWindowStartTime(), - windowEndTime: getWindowEndTime(), - initialPlaybackTime: initialPlaybackTimeEpoch, - serverDate, - } - }, - /** * @function * @returns the duration of the media asset. @@ -484,13 +530,13 @@ function BigscreenPlayer() { * @function * @param {*} opts * @param {boolean} opts.userPause - * @param {boolean} opts.disableAutoResume */ pause: (opts) => { DebugTool.apicall("pause") - pauseTrigger = opts && opts.userPause === false ? PauseTriggers.APP : PauseTriggers.USER - playerComponent.pause({ pauseTrigger, ...opts }) + pauseTrigger = opts?.userPause || opts?.userPause == null ? PauseTriggers.USER : PauseTriggers.APP + + playerComponent.pause() }, /** @@ -619,8 +665,8 @@ function BigscreenPlayer() { */ canSeek() { return ( - windowType === WindowTypes.STATIC || - DynamicWindowUtils.canSeek(getWindowStartTime(), getWindowEndTime(), getLiveSupport(), this.getSeekableRange()) + mediaSources.time().manifestType === ManifestType.STATIC || + canPauseAndSeek(getLiveSupport(), this.getSeekableRange()) ) }, @@ -628,9 +674,12 @@ function BigscreenPlayer() { * @function * @return Returns whether the current media asset is pausable. */ - canPause: () => - windowType === WindowTypes.STATIC || - DynamicWindowUtils.canPause(getWindowStartTime(), getWindowEndTime(), getLiveSupport()), + canPause() { + return ( + mediaSources.time().manifestType === ManifestType.STATIC || + canPauseAndSeek(getLiveSupport(), this.getSeekableRange()) + ) + }, /** * Return a mock for in place testing. @@ -685,27 +734,12 @@ function BigscreenPlayer() { */ getPlayerElement: () => playerComponent && playerComponent.getPlayerElement(), - /** - * @function - * @param {Number} epochTime - Unix Epoch based time in milliseconds. - * @return the time in seconds within the current sliding window. - */ - convertEpochMsToVideoTimeSeconds: (epochTime) => - getWindowStartTime() ? Math.floor((epochTime - getWindowStartTime()) / 1000) : null, - /** * @function * @return The runtime version of the library. */ getFrameworkVersion: () => Version, - /** - * @function - * @param {Number} time - Seconds - * @return the time in milliseconds within the current sliding window. - */ - convertVideoTimeSecondsToEpochMs, - /** * Toggle the visibility of the debug tool overlay. * @function @@ -724,14 +758,16 @@ function BigscreenPlayer() { */ setLogLevel: (level) => DebugTool.setLogLevel(level), getDebugLogs: () => DebugTool.getDebugLogs(), + convertPresentationTimeToMediaSampleTimeInSeconds, + convertMediaSampleTimeToPresentationTimeInSeconds, + convertPresentationTimeToAvailabilityTimeInMilliseconds, + convertAvailabilityTimeToPresentationTimeInSeconds, + getInitialPlaybackTime, + getTimeShiftBufferDepthInMilliseconds, + getPresentationTimeOffsetInMilliseconds, } } -/** - * @function - * @param {TALDevice} device - * @return the live support of the device. - */ function getLiveSupport() { return PlayerComponent.getLiveSupport() } diff --git a/src/bigscreenplayer.test.js b/src/bigscreenplayer.test.js index 5e722f81..e17af10f 100644 --- a/src/bigscreenplayer.test.js +++ b/src/bigscreenplayer.test.js @@ -1,36 +1,36 @@ -import MediaState from "./models/mediastate" -import WindowTypes from "./models/windowtypes" -import PauseTriggers from "./models/pausetriggers" -import Plugins from "./plugins" -import TransferFormats from "./models/transferformats" -import LiveSupport from "./models/livesupport" import BigscreenPlayer from "./bigscreenplayer" -import DebugTool from "./debugger/debugtool" +import { canPauseAndSeek } from "./dynamicwindowutils" import PlayerComponent from "./playercomponent" +import Plugins from "./plugins" +import ReadyHelper from "./readyhelper" +import Subtitles from "./subtitles/subtitles" +import DebugTool from "./debugger/debugtool" +import LiveSupport from "./models/livesupport" +import { ManifestType } from "./models/manifesttypes" +import { MediaKinds } from "./models/mediakinds" +import MediaState from "./models/mediastate" +import PauseTriggers from "./models/pausetriggers" +import { Timeline } from "./models/timeline" +import getError, { NoErrorThrownError } from "./testutils/geterror" let bigscreenPlayer let bigscreenPlayerData -let playbackElement -let manifestData -let successCallback -let errorCallback -let mockEventHook -let mediaSourcesCallbackErrorSpy +let dispatchMediaStateChange let mockPlayerComponentInstance -let noCallbacks = false -let forceMediaSourcesConstructionFailure = false const mockMediaSources = { - init: (media, serverDate, windowType, liveSupport, callbacks) => { - mediaSourcesCallbackErrorSpy = jest.spyOn(callbacks, "onError") - if (forceMediaSourcesConstructionFailure) { - callbacks.onError() - } else { - callbacks.onSuccess() - } - }, - time: () => manifestData.time, + init: jest.fn().mockResolvedValue(), tearDown: jest.fn(), + time: jest.fn().mockReturnValue({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }), +} + +const mockReadyHelper = { + callbackWhenReady: jest.fn(), } const mockSubtitlesInstance = { @@ -53,78 +53,52 @@ const mockResizer = { isResized: jest.fn(), } +jest.mock("./dynamicwindowutils") jest.mock("./mediasources", () => jest.fn(() => mockMediaSources)) jest.mock("./playercomponent") jest.mock("./plugins") -jest.mock("./debugger/debugtool") jest.mock("./resizer", () => jest.fn(() => mockResizer)) +jest.mock("./debugger/debugtool") jest.mock("./subtitles/subtitles", () => jest.fn(() => mockSubtitlesInstance)) - -function setupManifestData(options) { - manifestData = { - time: (options && options.time) || { - windowStartTime: 724000, - windowEndTime: 4324000, - correction: 0, - }, - } -} - -// options = subtitlesAvailable, windowType, windowStartTime, windowEndTime -function initialiseBigscreenPlayer(options = {}) { - const windowType = options.windowType || WindowTypes.STATIC - const subtitlesEnabled = options.subtitlesEnabled || false - - playbackElement = document.createElement("div") - playbackElement.id = "app" - - bigscreenPlayerData = { - media: { - codec: "codec", - urls: [{ url: "videoUrl", cdn: "cdn" }], - kind: options.mediaKind || "video", - type: "mimeType", - bitrate: "bitrate", - transferFormat: options.transferFormat, - }, - serverDate: options.serverDate, - initialPlaybackTime: options.initialPlaybackTime, - } - - if (options.windowStartTime && options.windowEndTime) { - manifestData.time = { - windowStartTime: options.windowStartTime, - windowEndTime: options.windowEndTime, +jest.mock("./readyhelper", () => + jest.fn((_a, _b, _c, onReady) => { + if (typeof onReady === "function") { + onReady() } - } - if (options.subtitlesAvailable) { - bigscreenPlayerData.media.captions = [ - { - url: "captions1", - segmentLength: 3.84, - }, - { - url: "captions2", - segmentLength: 3.84, - }, - ] - } + return mockReadyHelper + }) +) - let callbacks +function asyncInitialiseBigscreenPlayer(playbackEl, data, { noSuccessCallback = false, noErrorCallback = false } = {}) { + return new Promise((resolve, reject) => + bigscreenPlayer.init(playbackEl, data, { + onSuccess: noSuccessCallback ? null : resolve, + onError: noErrorCallback ? null : reject, + }) + ) +} - if (!noCallbacks) { - callbacks = { onSuccess: successCallback, onError: errorCallback } - } +function createPlaybackElement() { + const el = document.createElement("div") + el.id = "app" - bigscreenPlayer.init(playbackElement, { ...bigscreenPlayerData }, windowType, subtitlesEnabled, callbacks) + return el } describe("Bigscreen Player", () => { beforeEach(() => { + bigscreenPlayer?.tearDown() + bigscreenPlayer = undefined + jest.clearAllMocks() - setupManifestData() + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) mockPlayerComponentInstance = { play: jest.fn(), @@ -136,96 +110,271 @@ describe("Bigscreen Player", () => { getDuration: jest.fn(), getSeekableRange: jest.fn(), getPlayerElement: jest.fn(), - tearDown: jest.fn(), - getWindowStartTime: jest.fn(), - getWindowEndTime: jest.fn(), setPlaybackRate: jest.fn(), getPlaybackRate: jest.fn(), isBroadcastMixADAvailable: jest.fn(), isBroadcastMixADEnabled: jest.fn(), setBroadcastMixADOn: jest.fn(), setBroadcastMixADOff: jest.fn(), + tearDown: jest.fn(), } jest.spyOn(PlayerComponent, "getLiveSupport").mockReturnValue(LiveSupport.SEEKABLE) - PlayerComponent.mockImplementation((playbackElement, bigscreenPlayerData, mediaSources, windowType, callback) => { - mockEventHook = callback + PlayerComponent.mockImplementation((playbackElement, bigscreenPlayerData, mediaSources, callback) => { + dispatchMediaStateChange = callback return mockPlayerComponentInstance }) - successCallback = jest.fn() - errorCallback = jest.fn() - noCallbacks = false - bigscreenPlayer = BigscreenPlayer() - }) - afterEach(() => { - forceMediaSourcesConstructionFailure = false - bigscreenPlayer.tearDown() - bigscreenPlayer = undefined + bigscreenPlayerData = { + media: { + kind: "video", + type: "application/dash+xml", + transferFormat: "dash", + urls: [{ url: "mock://some.url/", cdn: "foo" }], + }, + } }) describe("init", () => { - it("should set endOfStream to true when playing live and no initial playback time is set", () => { - const callback = jest.fn() + it("doesn't require success or error callbacks", () => { + expect(() => bigscreenPlayer.init(createPlaybackElement(), bigscreenPlayerData)).not.toThrow() + }) - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) - bigscreenPlayer.registerForTimeUpdates(callback) + it("doesn't require a success callback", () => { + const onError = jest.fn() - mockEventHook({ data: { currentTime: 30 }, timeUpdate: true, isBufferingTimeoutError: false }) + expect(() => bigscreenPlayer.init(createPlaybackElement(), bigscreenPlayerData, { onError })).not.toThrow() - expect(callback).toHaveBeenCalledWith({ currentTime: 30, endOfStream: true }) + expect(onError).not.toHaveBeenCalled() }) - it("should set endOfStream to false when playing live and initialPlaybackTime is 0", () => { - const callback = jest.fn() + it("doesn't require an error callback", async () => { + const error = await getError(() => + asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData, { noErrorCallback: true }) + ) - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING, initialPlaybackTime: 0 }) + expect(error).toBeInstanceOf(NoErrorThrownError) + }) - bigscreenPlayer.registerForTimeUpdates(callback) + it("calls the error callback if manifest fails to load", async () => { + jest.mocked(mockMediaSources.init).mockRejectedValueOnce(new Error("Manifest failed to load")) - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true, isBufferingTimeoutError: false }) + const error = await getError(() => asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData)) - expect(callback).toHaveBeenCalledWith({ currentTime: 0, endOfStream: false }) + expect(error.message).toBe("Manifest failed to load") }) - it("should call the supplied error callback if manifest fails to load", () => { - forceMediaSourcesConstructionFailure = true - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + it("sets up ready helper", async () => { + bigscreenPlayerData.initialPlaybackTime = 365 - expect(mediaSourcesCallbackErrorSpy).toHaveBeenCalledTimes(1) - expect(errorCallback).toHaveBeenCalledTimes(1) - expect(successCallback).not.toHaveBeenCalled() + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(ReadyHelper)).toHaveBeenCalledTimes(1) + + expect(jest.mocked(ReadyHelper)).toHaveBeenCalledWith( + 365, + ManifestType.STATIC, + LiveSupport.SEEKABLE, + expect.any(Function) + ) }) - it("should not attempt to call onSuccess callback if one is not provided", () => { - noCallbacks = true - initialiseBigscreenPlayer() + it("sets up player component", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - expect(successCallback).not.toHaveBeenCalled() + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledTimes(1) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + bigscreenPlayerData, + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) }) - it("should not attempt to call onError callback if one is not provided", () => { - noCallbacks = true + it("sets up subtitles", async () => { + bigscreenPlayerData.enableSubtitles = true + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + expect(jest.mocked(Subtitles)).toHaveBeenCalledTimes(1) - expect(errorCallback).not.toHaveBeenCalled() + expect(jest.mocked(Subtitles)).toHaveBeenCalledWith( + expect.any(Object), + true, + expect.any(HTMLDivElement), + undefined, + expect.any(Object), + expect.any(Function) + ) }) - it("initialises the debugger", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.STATIC }) + it("initialises the debugger", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) expect(DebugTool.init).toHaveBeenCalledTimes(1) }) + + describe("handling initial playback time", () => { + it("treats initial playback time as a presentation time if it is passed in as a number", async () => { + bigscreenPlayerData.initialPlaybackTime = 100 + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: 100, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + + it("treats initial playback time as presentation time when timeline isn't specified", async () => { + bigscreenPlayerData.initialPlaybackTime = { + seconds: 100, + } + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: 100, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + + it("does not convert initial playback time if it is passed in as a presentation time", async () => { + bigscreenPlayerData.initialPlaybackTime = { + seconds: 100, + timeline: Timeline.PRESENTATION_TIME, + } + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: 100, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + + it("converts initial playback time to a presentation time if input is a media sample time", async () => { + bigscreenPlayerData.initialPlaybackTime = { + seconds: 100, + timeline: Timeline.MEDIA_SAMPLE_TIME, + } + + jest + .mocked(mockMediaSources.time) + .mockReturnValue({ manifestType: ManifestType.STATIC, presentationTimeOffsetInMilliseconds: 50000 }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: 50, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + + it("converts initial playback time to null if input is an availability time for a static stream", async () => { + bigscreenPlayerData.initialPlaybackTime = { + seconds: 100, + timeline: Timeline.AVAILABILITY_TIME, + } + + jest + .mocked(mockMediaSources.time) + .mockReturnValue({ manifestType: ManifestType.STATIC, availabilityStartTimeInMilliseconds: 0 }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: null, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + + it("converts initial playback time to a presentation time if input is an availability time for a dynamic stream", async () => { + bigscreenPlayerData.initialPlaybackTime = { + seconds: 1731045700, + timeline: Timeline.AVAILABILITY_TIME, + } + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + availabilityStartTimeInMilliseconds: 1731045600000, // Friday, 8 November 2024 06:00:00 + }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(jest.mocked(PlayerComponent)).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + initialPlaybackTime: 100, + }), + expect.any(Object), + expect.any(Function), + expect.any(Function), + expect.any(Function) + ) + }) + }) }) describe("tearDown", () => { - it("tears down the debugger", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.STATIC }) + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + it("tears down the player component", () => { + bigscreenPlayer.tearDown() + + expect(mockPlayerComponentInstance.tearDown).toHaveBeenCalledTimes(1) + }) + + it("tears down media sources", () => { + bigscreenPlayer.tearDown() + + expect(mockMediaSources.tearDown).toHaveBeenCalledTimes(1) + }) + + it("tears down subtitles", () => { + bigscreenPlayer.tearDown() + + expect(mockSubtitlesInstance.tearDown).toHaveBeenCalledTimes(1) + }) + + it("tears down the debugger", () => { bigscreenPlayer.tearDown() expect(DebugTool.tearDown).toHaveBeenCalledTimes(1) @@ -233,97 +382,127 @@ describe("Bigscreen Player", () => { }) describe("getPlayerElement", () => { - it("Should call through to getPlayerElement on the playback strategy", () => { - initialiseBigscreenPlayer() - + it("should get the current player element", async () => { const mockedVideo = document.createElement("video") mockPlayerComponentInstance.getPlayerElement.mockReturnValue(mockedVideo) + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + expect(bigscreenPlayer.getPlayerElement()).toBe(mockedVideo) }) }) - describe("registerForStateChanges", () => { - let callback + describe("listening for state changes", () => { + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + + it("returns a reference to the registered listener", () => { + const onStateChange = jest.fn() - beforeEach(() => { - callback = jest.fn() - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(callback) + const reference = bigscreenPlayer.registerForStateChanges(onStateChange) + + expect(reference).toBe(onStateChange) }) - it("should fire the callback when a state event comes back from the strategy", () => { - mockEventHook({ data: { state: MediaState.PLAYING } }) + it("should trigger a registered listener when a state event comes back", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) - expect(callback).toHaveBeenCalledWith({ state: MediaState.PLAYING, endOfStream: false }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) - callback.mockClear() + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.PLAYING, endOfStream: false }) + expect(onStateChange).toHaveBeenCalledTimes(1) - mockEventHook({ data: { state: MediaState.WAITING } }) + dispatchMediaStateChange({ data: { state: MediaState.WAITING } }) - expect(callback).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: false, endOfStream: false }) + expect(onStateChange).toHaveBeenNthCalledWith(2, { + state: MediaState.WAITING, + isSeeking: false, + endOfStream: false, + }) + expect(onStateChange).toHaveBeenCalledTimes(2) }) - it("should set the isPaused flag to true when waiting after a setCurrentTime", () => { - mockEventHook({ data: { state: MediaState.PLAYING } }) + it("reports isSeeking true when waiting after a setCurrentTime", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) - expect(callback).toHaveBeenCalledWith({ state: MediaState.PLAYING, endOfStream: false }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) - callback.mockClear() + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.PLAYING, endOfStream: false }) bigscreenPlayer.setCurrentTime(60) - mockEventHook({ data: { state: MediaState.WAITING } }) - expect(callback).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: true, endOfStream: false }) + dispatchMediaStateChange({ data: { state: MediaState.WAITING } }) + + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: true, endOfStream: false }) }) - it("should set clear the isPaused flag after a waiting event is fired", () => { - mockEventHook({ data: { state: MediaState.PLAYING } }) + it("clears isSeeking after a waiting event is fired", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) + + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) bigscreenPlayer.setCurrentTime(60) - mockEventHook({ data: { state: MediaState.WAITING } }) - expect(callback).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: true, endOfStream: false }) + dispatchMediaStateChange({ data: { state: MediaState.WAITING } }) - callback.mockClear() + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: true, endOfStream: false }) - mockEventHook({ data: { state: MediaState.WAITING } }) + dispatchMediaStateChange({ data: { state: MediaState.WAITING } }) - expect(callback).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: false, endOfStream: false }) + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.WAITING, isSeeking: false, endOfStream: false }) }) - it("should set the pause trigger to the one set when a pause event comes back from strategy", () => { + it("sets the pause trigger to user on a user pause", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) + bigscreenPlayer.pause() - mockEventHook({ data: { state: MediaState.PAUSED } }) + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) - expect(callback).toHaveBeenCalledWith({ + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.PAUSED, trigger: PauseTriggers.USER, endOfStream: false, }) }) - it("should set the pause trigger to device when a pause event comes back from strategy and a trigger is not set", () => { - mockEventHook({ data: { state: MediaState.PAUSED } }) + it("sets the pause trigger to device when a pause event comes back from strategy without a trigger", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) - expect(callback).toHaveBeenCalledWith({ + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) + + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.PAUSED, trigger: PauseTriggers.DEVICE, endOfStream: false, }) }) - it("should set isBufferingTimeoutError when a fatal error event comes back from strategy", () => { - mockEventHook({ + it("sets isBufferingTimeoutError when a fatal error event comes back from strategy", () => { + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) + + dispatchMediaStateChange({ data: { state: MediaState.FATAL_ERROR }, isBufferingTimeoutError: false, code: 1, message: "media-error-aborted", }) - expect(callback).toHaveBeenCalledWith({ + expect(onStateChange).toHaveBeenCalledWith({ state: MediaState.FATAL_ERROR, isBufferingTimeoutError: false, code: 1, @@ -331,81 +510,71 @@ describe("Bigscreen Player", () => { endOfStream: false, }) }) - - it("should return a reference to the callback passed in", () => { - const reference = bigscreenPlayer.registerForStateChanges(callback) - - expect(reference).toBe(callback) - }) }) describe("unregisterForStateChanges", () => { - it("should remove callback from stateChangeCallbacks", () => { + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + + it("no longer calls a listener once unregistered", () => { const listener1 = jest.fn() const listener2 = jest.fn() const listener3 = jest.fn() - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.registerForStateChanges(listener2) bigscreenPlayer.registerForStateChanges(listener3) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) bigscreenPlayer.unregisterForStateChanges(listener2) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledTimes(2) expect(listener2).toHaveBeenCalledTimes(1) expect(listener3).toHaveBeenCalledTimes(2) }) - it("should remove callback from stateChangeCallbacks when a callback removes itself", () => { + it("no longer calls a listener once unregistered by itself", () => { const listener1 = jest.fn() - const listener2 = jest.fn().mockImplementation(() => { - bigscreenPlayer.unregisterForStateChanges(listener2) - }) + const listener2 = jest.fn(() => bigscreenPlayer.unregisterForStateChanges(listener2)) const listener3 = jest.fn() - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.registerForStateChanges(listener2) bigscreenPlayer.registerForStateChanges(listener3) - mockEventHook({ data: { state: MediaState.PLAYING } }) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledTimes(2) expect(listener2).toHaveBeenCalledTimes(1) expect(listener3).toHaveBeenCalledTimes(2) }) - it("should remove callback from stateChangeCallbacks when a callback unregisters another handler last", () => { + it("no longer calls a listener once unregistered by a listener registered later", () => { const listener1 = jest.fn() const listener2 = jest.fn() - const listener3 = jest.fn().mockImplementation(() => { + const listener3 = jest.fn(() => { bigscreenPlayer.unregisterForStateChanges(listener1) bigscreenPlayer.unregisterForStateChanges(listener2) }) - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.registerForStateChanges(listener2) bigscreenPlayer.registerForStateChanges(listener3) - mockEventHook({ data: { state: MediaState.PLAYING } }) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledTimes(0) expect(listener2).toHaveBeenCalledTimes(0) expect(listener3).toHaveBeenCalledTimes(2) }) - it("should remove callback from stateChangeCallbacks when a callback unregisters another handler first", () => { + it("no longer calls a listener once unregistered by a listener registered earlier", () => { const listener2 = jest.fn() const listener3 = jest.fn() @@ -414,21 +583,19 @@ describe("Bigscreen Player", () => { bigscreenPlayer.unregisterForStateChanges(listener3) }) - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.registerForStateChanges(listener2) bigscreenPlayer.registerForStateChanges(listener3) - mockEventHook({ data: { state: MediaState.PLAYING } }) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledTimes(2) expect(listener2).toHaveBeenCalledTimes(1) expect(listener3).toHaveBeenCalledTimes(1) }) - it("should remove callbacks from stateChangeCallbacks when a callback unregisters multiple handlers in different places", () => { + it("no longer calls a listener unregistered by another listener", () => { const listener3 = jest.fn() const listener1 = jest.fn().mockImplementation(() => { @@ -441,15 +608,13 @@ describe("Bigscreen Player", () => { bigscreenPlayer.unregisterForStateChanges(listener4) }) - initialiseBigscreenPlayer() - bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.registerForStateChanges(listener2) bigscreenPlayer.registerForStateChanges(listener3) bigscreenPlayer.registerForStateChanges(listener4) - mockEventHook({ data: { state: MediaState.PLAYING } }) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledTimes(1) expect(listener2).toHaveBeenCalledTimes(0) @@ -457,189 +622,51 @@ describe("Bigscreen Player", () => { expect(listener4).toHaveBeenCalledTimes(1) }) - it("should only remove existing callbacks from stateChangeCallbacks", () => { - initialiseBigscreenPlayer() - + it("only unregisters existing callbacks", () => { const listener1 = jest.fn() const listener2 = jest.fn() bigscreenPlayer.registerForStateChanges(listener1) bigscreenPlayer.unregisterForStateChanges(listener2) - mockEventHook({ data: { state: MediaState.PLAYING } }) + dispatchMediaStateChange({ data: { state: MediaState.PLAYING } }) expect(listener1).toHaveBeenCalledWith({ state: MediaState.PLAYING, endOfStream: false }) }) }) - describe("player ready callback", () => { - describe("on state change event", () => { - it("should not be called when it is a fatal error", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { state: MediaState.FATAL_ERROR } }) - - expect(successCallback).not.toHaveBeenCalled() - }) - - it("should be called if playing VOD and event time is valid", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { state: MediaState.WAITING, currentTime: 0 } }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("should be called if playing VOD with an initial start time and event time is valid", () => { - initialiseBigscreenPlayer({ initialPlaybackTime: 20 }) - mockEventHook({ data: { state: MediaState.WAITING, currentTime: 0 } }) - - expect(successCallback).not.toHaveBeenCalled() - mockEventHook({ data: { state: MediaState.PLAYING, currentTime: 20 } }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("should be called if playing Live and event time is valid", () => { - const windowStartTime = 10 - const windowEndTime = 100 - - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { windowStartTime, windowEndTime }, - }) - - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) - - mockEventHook({ - data: { - state: MediaState.PLAYING, - currentTime: 10, - seekableRange: { - start: windowStartTime, - end: windowEndTime, - }, - }, - }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("after a valid state change should not be called on succesive valid state changes", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { state: MediaState.WAITING, currentTime: 0 } }) - - expect(successCallback).toHaveBeenCalledTimes(1) - successCallback.mockClear() - mockEventHook({ data: { state: MediaState.PLAYING, currentTime: 0 } }) - - expect(successCallback).not.toHaveBeenCalled() - }) - - it("after a valid state change should not be called on succesive valid time updates", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { state: MediaState.WAITING, currentTime: 0 } }) - - expect(successCallback).toHaveBeenCalledTimes(1) - successCallback.mockClear() - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - - expect(successCallback).not.toHaveBeenCalled() - }) + describe("listening for time updates", () => { + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) }) - describe("on time update", () => { - it("should be called if playing VOD and current time is valid", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("should be called if playing VOD with an initial start time and current time is valid", () => { - initialiseBigscreenPlayer({ initialPlaybackTime: 20 }) - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - - expect(successCallback).not.toHaveBeenCalled() - mockEventHook({ data: { currentTime: 20 }, timeUpdate: true }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("should be called if playing Live and current time is valid", () => { - const windowStartTime = 10 - const windowEndTime = 100 - - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { windowStartTime, windowEndTime }, - }) - - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) - - mockEventHook({ - data: { - currentTime: 10, - seekableRange: { - start: windowStartTime, - end: windowEndTime, - }, - }, - timeUpdate: true, - }) - - expect(successCallback).toHaveBeenCalledTimes(1) - }) - - it("after a valid time update should not be called on succesive valid time updates", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - - expect(successCallback).toHaveBeenCalledTimes(1) - successCallback.mockClear() - mockEventHook({ data: { currentTime: 2 }, timeUpdate: true }) - - expect(successCallback).not.toHaveBeenCalled() - }) - - it("after a valid time update should not be called on succesive valid state changes", () => { - initialiseBigscreenPlayer() - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - - expect(successCallback).toHaveBeenCalledTimes(1) - successCallback.mockClear() - mockEventHook({ data: { state: MediaState.PLAYING, currentTime: 2 } }) - - expect(successCallback).not.toHaveBeenCalled() - }) - }) - }) - - describe("registerForTimeUpdates", () => { it("should call the callback when we get a timeupdate event from the strategy", () => { - const callback = jest.fn() - initialiseBigscreenPlayer() - bigscreenPlayer.registerForTimeUpdates(callback) + const onTimeUpdate = jest.fn() - expect(callback).not.toHaveBeenCalled() + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) - mockEventHook({ data: { currentTime: 60 }, timeUpdate: true }) + expect(onTimeUpdate).not.toHaveBeenCalled() - expect(callback).toHaveBeenCalledWith({ currentTime: 60, endOfStream: false }) + dispatchMediaStateChange({ data: { currentTime: 60 }, timeUpdate: true }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 60, endOfStream: false }) }) it("returns a reference to the callback passed in", () => { - const callback = jest.fn() - initialiseBigscreenPlayer() + const onTimeUpdate = jest.fn() - const reference = bigscreenPlayer.registerForTimeUpdates(callback) + const reference = bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) - expect(reference).toBe(callback) + expect(reference).toBe(onTimeUpdate) }) }) describe("unregisterForTimeUpdates", () => { - it("should remove callback from timeUpdateCallbacks", () => { - initialiseBigscreenPlayer() + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + it("should remove callback from timeUpdateCallbacks", () => { const listener1 = jest.fn() const listener2 = jest.fn() const listener3 = jest.fn() @@ -648,11 +675,11 @@ describe("Bigscreen Player", () => { bigscreenPlayer.registerForTimeUpdates(listener2) bigscreenPlayer.registerForTimeUpdates(listener3) - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) + dispatchMediaStateChange({ data: { currentTime: 0 }, timeUpdate: true }) bigscreenPlayer.unregisterForTimeUpdates(listener2) - mockEventHook({ data: { currentTime: 1 }, timeUpdate: true }) + dispatchMediaStateChange({ data: { currentTime: 1 }, timeUpdate: true }) expect(listener1).toHaveBeenCalledTimes(2) expect(listener2).toHaveBeenCalledTimes(1) @@ -660,8 +687,6 @@ describe("Bigscreen Player", () => { }) it("should remove callback from timeUpdateCallbacks when a callback removes itself", () => { - initialiseBigscreenPlayer() - const listener1 = jest.fn() const listener2 = jest.fn().mockImplementation(() => { bigscreenPlayer.unregisterForTimeUpdates(listener2) @@ -672,8 +697,8 @@ describe("Bigscreen Player", () => { bigscreenPlayer.registerForTimeUpdates(listener2) bigscreenPlayer.registerForTimeUpdates(listener3) - mockEventHook({ data: { currentTime: 0 }, timeUpdate: true }) - mockEventHook({ data: { currentTime: 1 }, timeUpdate: true }) + dispatchMediaStateChange({ data: { currentTime: 0 }, timeUpdate: true }) + dispatchMediaStateChange({ data: { currentTime: 1 }, timeUpdate: true }) expect(listener1).toHaveBeenCalledTimes(2) expect(listener2).toHaveBeenCalledTimes(1) @@ -681,51 +706,54 @@ describe("Bigscreen Player", () => { }) it("should only remove existing callbacks from timeUpdateCallbacks", () => { - initialiseBigscreenPlayer() - const listener1 = jest.fn() const listener2 = jest.fn() bigscreenPlayer.registerForTimeUpdates(listener1) bigscreenPlayer.unregisterForTimeUpdates(listener2) - mockEventHook({ data: { currentTime: 60 }, timeUpdate: true }) + dispatchMediaStateChange({ data: { currentTime: 60 }, timeUpdate: true }) expect(listener1).toHaveBeenCalledWith({ currentTime: 60, endOfStream: false }) }) }) - describe("registerForSubtitleChanges", () => { + describe("listening for subtitle changes", () => { + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + it("should call the callback when subtitles are turned on/off", () => { - const callback = jest.fn() - initialiseBigscreenPlayer() - bigscreenPlayer.registerForSubtitleChanges(callback) + const onSubtitleChange = jest.fn() - expect(callback).not.toHaveBeenCalled() + bigscreenPlayer.registerForSubtitleChanges(onSubtitleChange) + + expect(onSubtitleChange).not.toHaveBeenCalled() bigscreenPlayer.setSubtitlesEnabled(true) - expect(callback).toHaveBeenCalledWith({ enabled: true }) + expect(onSubtitleChange).toHaveBeenCalledWith({ enabled: true }) bigscreenPlayer.setSubtitlesEnabled(false) - expect(callback).toHaveBeenCalledWith({ enabled: false }) + expect(onSubtitleChange).toHaveBeenCalledWith({ enabled: false }) }) it("returns a reference to the callback supplied", () => { - const callback = jest.fn() + const onSubtitleChange = jest.fn() - initialiseBigscreenPlayer() - const reference = bigscreenPlayer.registerForSubtitleChanges(callback) + const reference = bigscreenPlayer.registerForSubtitleChanges(onSubtitleChange) - expect(reference).toBe(callback) + expect(reference).toBe(onSubtitleChange) }) }) describe("unregisterForSubtitleChanges", () => { - it("should remove callback from subtitleCallbacks", () => { - initialiseBigscreenPlayer() + beforeEach(async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + }) + it("should remove callback from subtitleCallbacks", () => { const listener1 = jest.fn() const listener2 = jest.fn() const listener3 = jest.fn() @@ -746,12 +774,8 @@ describe("Bigscreen Player", () => { }) it("should remove callback from subtitleCallbacks when a callback removes itself", () => { - initialiseBigscreenPlayer() - const listener1 = jest.fn() - const listener2 = jest.fn().mockImplementation(() => { - bigscreenPlayer.unregisterForSubtitleChanges(listener2) - }) + const listener2 = jest.fn(() => bigscreenPlayer.unregisterForSubtitleChanges(listener2)) const listener3 = jest.fn() bigscreenPlayer.registerForSubtitleChanges(listener1) @@ -767,8 +791,6 @@ describe("Bigscreen Player", () => { }) it("should only remove existing callbacks from subtitleCallbacks", () => { - initialiseBigscreenPlayer() - const listener1 = jest.fn() const listener2 = jest.fn() @@ -782,8 +804,8 @@ describe("Bigscreen Player", () => { }) describe("setCurrentTime", () => { - it("should setCurrentTime on the strategy/playerComponent", () => { - initialiseBigscreenPlayer() + it("should setCurrentTime on the strategy/playerComponent", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.setCurrentTime(60) @@ -796,65 +818,327 @@ describe("Bigscreen Player", () => { expect(mockPlayerComponentInstance.setCurrentTime).not.toHaveBeenCalled() }) - it("should set endOfStream to true when seeking to the end of a simulcast", () => { - const windowStartTime = 10 - const windowEndTime = 100 + it("converts a media sample time to presentation time", async () => { + mockMediaSources.time.mockReturnValue({ presentationTimeOffsetInMilliseconds: 7200000 }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { windowStartTime, windowEndTime }, + bigscreenPlayer.setCurrentTime(7250, Timeline.MEDIA_SAMPLE_TIME) + + expect(mockPlayerComponentInstance.setCurrentTime).toHaveBeenCalledWith(50) + expect(mockPlayerComponentInstance.setCurrentTime).toHaveBeenCalledTimes(1) + }) + + it("converts an availability time to presentation time", async () => { + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + availabilityStartTimeInMilliseconds: 7200000, }) - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 105 }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + bigscreenPlayer.setCurrentTime(7230, Timeline.AVAILABILITY_TIME) - const onTimeUpdateStub = jest.fn() + expect(mockPlayerComponentInstance.setCurrentTime).toHaveBeenCalledWith(30) + expect(mockPlayerComponentInstance.setCurrentTime).toHaveBeenCalledTimes(1) + }) - const endOfStreamWindow = windowEndTime - 2 + it("throws an error on a non-numerical value", () => { + expect(() => bigscreenPlayer.setCurrentTime(null)).toThrow(TypeError) + }) + }) - bigscreenPlayer.registerForTimeUpdates(onTimeUpdateStub) + describe("converting presentation time to media sample time", () => { + it("returns null before initialisation", () => { + expect(bigscreenPlayer.convertPresentationTimeToMediaSampleTimeInSeconds(60)).toBeNull() + }) - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: windowStartTime, end: windowEndTime }) + it("returns null until MediaSources load", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest.mocked(mockMediaSources.time).mockReturnValue(null) + + expect(bigscreenPlayer.convertPresentationTimeToMediaSampleTimeInSeconds(60)).toBeNull() + }) - mockPlayerComponentInstance.getCurrentTime.mockReturnValue(endOfStreamWindow) + it("returns a number", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest.mocked(mockMediaSources.time).mockReturnValue({ presentationTimeOffsetInMilliseconds: 7200000 }) + + expect(bigscreenPlayer.convertPresentationTimeToMediaSampleTimeInSeconds(60)).toBe(7260) + }) + }) + + describe("converting media sample time to presentation time", () => { + it("returns null before initialisation", () => { + expect(bigscreenPlayer.convertMediaSampleTimeToPresentationTimeInSeconds(60)).toBeNull() + }) - bigscreenPlayer.setCurrentTime(endOfStreamWindow) + it("returns null until MediaSources load", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - mockEventHook({ data: { currentTime: endOfStreamWindow }, timeUpdate: true }) + jest.mocked(mockMediaSources.time).mockReturnValue(null) - expect(onTimeUpdateStub).toHaveBeenCalledWith({ currentTime: endOfStreamWindow, endOfStream: true }) + expect(bigscreenPlayer.convertMediaSampleTimeToPresentationTimeInSeconds(60)).toBeNull() }) - it("should set endOfStream to false when seeking into a simulcast", () => { - const windowStartTime = 10 - const windowEndTime = 100 + it("returns a number", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest.mocked(mockMediaSources.time).mockReturnValue({ presentationTimeOffsetInMilliseconds: 7200000 }) + + expect(bigscreenPlayer.convertMediaSampleTimeToPresentationTimeInSeconds(7260)).toBe(60) + }) + }) + + describe("converting presentation time to availability time", () => { + it("returns null before initialisation", () => { + expect(bigscreenPlayer.convertPresentationTimeToAvailabilityTimeInMilliseconds(60)).toBeNull() + }) + + it("returns null until MediaSources load", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest.mocked(mockMediaSources.time).mockReturnValue(null) + + expect(bigscreenPlayer.convertPresentationTimeToAvailabilityTimeInMilliseconds(60)).toBeNull() + }) + + it("returns null for a static stream", async () => { + jest.mocked(mockMediaSources.time).mockReturnValue({ manifestType: ManifestType.STATIC }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { windowStartTime, windowEndTime }, + expect(bigscreenPlayer.convertPresentationTimeToAvailabilityTimeInMilliseconds(60)).toBeNull() + }) + + it("returns a number", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest + .mocked(mockMediaSources.time) + .mockReturnValue({ manifestType: ManifestType.DYNAMIC, availabilityStartTimeInMilliseconds: 7200000 }) + + expect(bigscreenPlayer.convertPresentationTimeToAvailabilityTimeInMilliseconds(60)).toBe(7260000) + }) + }) + + describe("converting availability time to presentation time", () => { + it("returns null before initialisation", () => { + expect(bigscreenPlayer.convertAvailabilityTimeToPresentationTimeInSeconds(60)).toBeNull() + }) + + it("returns null until MediaSources load", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest.mocked(mockMediaSources.time).mockReturnValue(null) + + expect(bigscreenPlayer.convertAvailabilityTimeToPresentationTimeInSeconds(60)).toBeNull() + }) + + it("returns null for a static stream", async () => { + jest.mocked(mockMediaSources.time).mockReturnValue({ manifestType: ManifestType.STATIC }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + expect(bigscreenPlayer.convertAvailabilityTimeToPresentationTimeInSeconds(60)).toBeNull() + }) + + it("returns a number", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + jest + .mocked(mockMediaSources.time) + .mockReturnValue({ manifestType: ManifestType.DYNAMIC, availabilityStartTimeInMilliseconds: 7200000 }) + + expect(bigscreenPlayer.convertAvailabilityTimeToPresentationTimeInSeconds(7260000)).toBe(60) + }) + }) + + describe("reporting end of stream", () => { + it("reports endOfStream true on initialisation when playing live and no initial playback time is set", async () => { + const onTimeUpdate = jest.fn() + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, }) - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - const callback = jest.fn() - bigscreenPlayer.registerForTimeUpdates(callback) + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) - const middleOfStreamWindow = windowEndTime / 2 + dispatchMediaStateChange({ data: { currentTime: 30 }, timeUpdate: true, isBufferingTimeoutError: false }) - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: windowStartTime, end: windowEndTime }) + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 30, endOfStream: true }) + }) + + it("reports endOfStream false on initialisation when playing live and initialPlaybackTime is 0", async () => { + const onTimeUpdate = jest.fn() - mockPlayerComponentInstance.getCurrentTime.mockReturnValue(middleOfStreamWindow) + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) - bigscreenPlayer.setCurrentTime(middleOfStreamWindow) + bigscreenPlayerData.initialPlaybackTime = 0 - mockEventHook({ data: { currentTime: middleOfStreamWindow }, timeUpdate: true }) + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - expect(callback).toHaveBeenCalledWith({ currentTime: middleOfStreamWindow, endOfStream: false }) + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + dispatchMediaStateChange({ data: { currentTime: 0 }, timeUpdate: true, isBufferingTimeoutError: false }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 0, endOfStream: false }) + }) + + it("reports endOfStream false on initialisation when playing live and initialPlaybackTime is set in the middle of the stream", async () => { + const onTimeUpdate = jest.fn() + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) + + bigscreenPlayerData.initialPlaybackTime = 100000 + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + dispatchMediaStateChange({ data: { currentTime: 100000 }, timeUpdate: true, isBufferingTimeoutError: false }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 100000, endOfStream: false }) + }) + + it("reports endOfStream false on initialisation when playing live and initialPlaybackTime is set to the live edge time", async () => { + const onTimeUpdate = jest.fn() + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) + + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 105 }) + bigscreenPlayerData.initialPlaybackTime = 105 + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + dispatchMediaStateChange({ data: { currentTime: 105 }, timeUpdate: true, isBufferingTimeoutError: false }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 105, endOfStream: false }) + }) + + it("reports endOfStream true on state changes when seeking to the end of a dynamic stream", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + const onTimeUpdate = jest.fn() + + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 7200 }) + mockPlayerComponentInstance.getCurrentTime.mockReturnValue(7198) + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) + + bigscreenPlayer.setCurrentTime(7198) + + dispatchMediaStateChange({ data: { currentTime: 7198 }, timeUpdate: true }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 7198, endOfStream: true }) + }) + + it("reports endOfStream false on state changes when seeking into the middle of a dynamic stream", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + const onTimeUpdate = jest.fn() + + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 7200 }) + + mockPlayerComponentInstance.getCurrentTime.mockReturnValue(3600) + + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514440000, + timeShiftBufferDepthInMilliseconds: 0, + }) + + bigscreenPlayer.setCurrentTime(3600) + + dispatchMediaStateChange({ data: { currentTime: 3600 }, timeUpdate: true }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 3600, endOfStream: false }) + }) + + it("reports endOfStream false on state changes following a pause, even when at live edge", async () => { + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + const onStateChange = jest.fn() + + bigscreenPlayer.registerForStateChanges(onStateChange) + + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 7200 }) + mockPlayerComponentInstance.getCurrentTime.mockReturnValue(7198) + + bigscreenPlayer.pause() + + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) + + expect(onStateChange).toHaveBeenCalledWith({ + state: MediaState.PAUSED, + endOfStream: false, + trigger: PauseTriggers.USER, + }) + }) + + it("reports endOfStream false for any static stream", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + const onTimeUpdate = jest.fn() + + bigscreenPlayer.registerForTimeUpdates(onTimeUpdate) + + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 7200 }) + + bigscreenPlayer.setCurrentTime(7200) + + dispatchMediaStateChange({ data: { currentTime: 7200 }, timeUpdate: true }) + + expect(onTimeUpdate).toHaveBeenCalledWith({ currentTime: 7200, endOfStream: false }) }) }) - describe("Playback Rate", () => { - it("should setPlaybackRate on the strategy/playerComponent", () => { - initialiseBigscreenPlayer() + describe("playback rate", () => { + it("should setPlaybackRate on the strategy/playerComponent", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.setPlaybackRate(2) @@ -867,14 +1151,14 @@ describe("Bigscreen Player", () => { expect(mockPlayerComponentInstance.setPlaybackRate).not.toHaveBeenCalled() }) - it("should call through to get the playback rate when requested", () => { - initialiseBigscreenPlayer() + it("should call through to get the playback rate when requested", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + mockPlayerComponentInstance.getPlaybackRate.mockReturnValue(1.5) - const rate = bigscreenPlayer.getPlaybackRate() + expect(bigscreenPlayer.getPlaybackRate()).toBe(1.5) - expect(mockPlayerComponentInstance.getPlaybackRate).toHaveBeenCalled() - expect(rate).toBe(1.5) + expect(mockPlayerComponentInstance.getPlaybackRate).toHaveBeenCalledTimes(1) }) it("should not get playback rate if playerComponent is not initialised", () => { @@ -885,8 +1169,8 @@ describe("Bigscreen Player", () => { }) describe("getCurrentTime", () => { - it("should return the current time from the strategy", () => { - initialiseBigscreenPlayer() + it("should return the current time from the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.getCurrentTime.mockReturnValue(10) @@ -899,24 +1183,18 @@ describe("Bigscreen Player", () => { }) describe("getMediaKind", () => { - it("should return the media kind", () => { - initialiseBigscreenPlayer({ mediaKind: "audio" }) - - expect(bigscreenPlayer.getMediaKind()).toBe("audio") - }) - }) + it.each([MediaKinds.VIDEO, MediaKinds.AUDIO])("should return the media kind %s", async (kind) => { + bigscreenPlayerData.media.kind = kind - describe("getWindowType", () => { - it("should return the window type", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - expect(bigscreenPlayer.getWindowType()).toBe(WindowTypes.SLIDING) + expect(bigscreenPlayer.getMediaKind()).toBe(kind) }) }) describe("getSeekableRange", () => { - it("should return the seekable range from the strategy", () => { - initialiseBigscreenPlayer() + it("should return the seekable range from the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 10 }) @@ -924,14 +1202,24 @@ describe("Bigscreen Player", () => { expect(bigscreenPlayer.getSeekableRange().end).toBe(10) }) - it("should return an empty object when bigscreen player has not been initialised", () => { - expect(bigscreenPlayer.getSeekableRange()).toEqual({}) + it("should return null when bigscreen player has not been initialised", () => { + expect(bigscreenPlayer.getSeekableRange()).toBeNull() }) }) describe("isAtLiveEdge", () => { - it("should return false when playing on demand content", () => { - initialiseBigscreenPlayer() + it("should return false when playing on demand content", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + mockPlayerComponentInstance.getCurrentTime.mockReturnValue(100) + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 105 }) + + jest.mocked(mockMediaSources.time).mockReturnValueOnce({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) expect(bigscreenPlayer.isPlayingAtLiveEdge()).toBe(false) }) @@ -940,77 +1228,42 @@ describe("Bigscreen Player", () => { expect(bigscreenPlayer.isPlayingAtLiveEdge()).toBe(false) }) - it("should return true when playing live and current time is within tolerance of seekable range end", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + it("should return true when playing live and current time is within tolerance of seekable range end", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.getCurrentTime.mockReturnValue(100) mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 105 }) + jest.mocked(mockMediaSources.time).mockReturnValueOnce({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, + }) + expect(bigscreenPlayer.isPlayingAtLiveEdge()).toBe(true) }) - it("should return false when playing live and current time is outside the tolerance of seekable range end", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.SLIDING }) + it("should return false when playing live and current time is outside the tolerance of seekable range end", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.getCurrentTime.mockReturnValue(95) mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 105 }) - expect(bigscreenPlayer.isPlayingAtLiveEdge()).toBe(false) - }) - }) - - describe("getLiveWindowData", () => { - it("should return undefined values when windowType is static", () => { - initialiseBigscreenPlayer({ windowType: WindowTypes.STATIC }) - - expect(bigscreenPlayer.getLiveWindowData()).toEqual({}) - }) - - it("should return liveWindowData when the windowType is sliding and manifest is loaded", () => { - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { - windowStartTime: 1, - windowEndTime: 2, - }, - }) - - const initialisationData = { - windowType: WindowTypes.SLIDING, - serverDate: new Date(), - initialPlaybackTime: Date.now(), - } - initialiseBigscreenPlayer(initialisationData) - - expect(bigscreenPlayer.getLiveWindowData()).toEqual({ - windowStartTime: 1, - windowEndTime: 2, - serverDate: initialisationData.serverDate, - initialPlaybackTime: initialisationData.initialPlaybackTime, + jest.mocked(mockMediaSources.time).mockReturnValueOnce({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 0, }) - }) - it("should return a subset of liveWindowData when the windowType is sliding and time block is provided", () => { - const initialisationData = { - windowType: WindowTypes.SLIDING, - windowStartTime: 1, - windowEndTime: 2, - initialPlaybackTime: Date.now(), - } - initialiseBigscreenPlayer(initialisationData) - - expect(bigscreenPlayer.getLiveWindowData()).toEqual({ - serverDate: undefined, - windowStartTime: 1, - windowEndTime: 2, - initialPlaybackTime: initialisationData.initialPlaybackTime, - }) + expect(bigscreenPlayer.isPlayingAtLiveEdge()).toBe(false) }) }) describe("getDuration", () => { - it("should get the duration from the strategy", () => { - initialiseBigscreenPlayer() + it("should get the duration from the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.getDuration.mockReturnValue(10) @@ -1023,8 +1276,8 @@ describe("Bigscreen Player", () => { }) describe("isPaused", () => { - it("should get the paused state from the strategy", () => { - initialiseBigscreenPlayer() + it("should get the paused state from the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.isPaused.mockReturnValue(true) @@ -1032,13 +1285,15 @@ describe("Bigscreen Player", () => { }) it("should return true if bigscreenPlayer has not been initialised", () => { + mockPlayerComponentInstance.isPaused.mockReturnValue(false) + expect(bigscreenPlayer.isPaused()).toBe(true) }) }) describe("isEnded", () => { - it("should get the ended state from the strategy", () => { - initialiseBigscreenPlayer() + it("should get the ended state from the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) mockPlayerComponentInstance.isEnded.mockReturnValue(true) @@ -1046,13 +1301,15 @@ describe("Bigscreen Player", () => { }) it("should return false if bigscreenPlayer has not been initialised", () => { + mockPlayerComponentInstance.isEnded.mockReturnValue(true) + expect(bigscreenPlayer.isEnded()).toBe(false) }) }) describe("play", () => { - it("should call play on the strategy", () => { - initialiseBigscreenPlayer() + it("should call play on the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.play() @@ -1061,54 +1318,61 @@ describe("Bigscreen Player", () => { }) describe("pause", () => { - it("should call pause on the strategy", () => { - const opts = { disableAutoResume: true } - - initialiseBigscreenPlayer() + it("should call pause on the strategy", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - bigscreenPlayer.pause(opts) + bigscreenPlayer.pause() - expect(mockPlayerComponentInstance.pause).toHaveBeenCalledWith( - expect.objectContaining({ disableAutoResume: true }) - ) + expect(mockPlayerComponentInstance.pause).toHaveBeenCalledTimes(1) }) - it("should set pauseTrigger to an app pause if user pause is false", () => { - const opts = { userPause: false } - - initialiseBigscreenPlayer() + it("should set pauseTrigger to an app pause if user pause is false", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - const callback = jest.fn() + const onStateChange = jest.fn() - bigscreenPlayer.registerForStateChanges(callback) + bigscreenPlayer.registerForStateChanges(onStateChange) - bigscreenPlayer.pause(opts) + bigscreenPlayer.pause({ userPause: false }) - mockEventHook({ data: { state: MediaState.PAUSED } }) + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) - expect(callback).toHaveBeenCalledWith(expect.objectContaining({ trigger: PauseTriggers.APP })) + expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ trigger: PauseTriggers.APP })) }) - it("should set pauseTrigger to a user pause if user pause is true", () => { - const opts = { userPause: true } + it("should set pauseTrigger to a user pause if user pause is true", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + + const onStateChange = jest.fn() - initialiseBigscreenPlayer() + bigscreenPlayer.registerForStateChanges(onStateChange) - const callback = jest.fn() + bigscreenPlayer.pause({ userPause: true }) - bigscreenPlayer.registerForStateChanges(callback) + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) - bigscreenPlayer.pause(opts) + expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ trigger: PauseTriggers.USER })) + }) + + it("should set pauseTrigger to a user pause if user pause is not defined", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - mockEventHook({ data: { state: MediaState.PAUSED } }) + const onStateChange = jest.fn() - expect(callback).toHaveBeenCalledWith(expect.objectContaining({ trigger: PauseTriggers.USER })) + bigscreenPlayer.registerForStateChanges(onStateChange) + + bigscreenPlayer.pause() + + dispatchMediaStateChange({ data: { state: MediaState.PAUSED } }) + + expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ trigger: PauseTriggers.USER })) }) }) describe("setSubtitlesEnabled", () => { - it("should turn subtitles on/off when a value is passed in", () => { - initialiseBigscreenPlayer() + it("should turn subtitles on/off when a value is passed in", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.setSubtitlesEnabled(true) expect(mockSubtitlesInstance.enable).toHaveBeenCalledTimes(1) @@ -1118,22 +1382,25 @@ describe("Bigscreen Player", () => { expect(mockSubtitlesInstance.disable).toHaveBeenCalledTimes(1) }) - it("should show subtitles when called with true", () => { - initialiseBigscreenPlayer() + it("should show subtitles when called with true", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.setSubtitlesEnabled(true) expect(mockSubtitlesInstance.show).toHaveBeenCalledTimes(1) }) - it("should hide subtitleswhen called with false", () => { - initialiseBigscreenPlayer() + it("should hide subtitles when called with false", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.setSubtitlesEnabled(false) expect(mockSubtitlesInstance.hide).toHaveBeenCalledTimes(1) }) - it("should not show subtitles when resized", () => { - initialiseBigscreenPlayer() + it("should not show subtitles when resized", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + mockResizer.isResized.mockReturnValue(true) bigscreenPlayer.setSubtitlesEnabled(true) @@ -1141,8 +1408,9 @@ describe("Bigscreen Player", () => { expect(mockSubtitlesInstance.show).not.toHaveBeenCalled() }) - it("should not hide subtitles when resized", () => { - initialiseBigscreenPlayer() + it("should not hide subtitles when resized", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + mockResizer.isResized.mockReturnValue(true) bigscreenPlayer.setSubtitlesEnabled(true) @@ -1152,8 +1420,8 @@ describe("Bigscreen Player", () => { }) describe("isSubtitlesEnabled", () => { - it("calls through to Subtitles enabled when called", () => { - initialiseBigscreenPlayer() + it("calls through to Subtitles enabled when called", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.isSubtitlesEnabled() @@ -1162,8 +1430,8 @@ describe("Bigscreen Player", () => { }) describe("isSubtitlesAvailable", () => { - it("calls through to Subtitles available when called", () => { - initialiseBigscreenPlayer() + it("calls through to Subtitles available when called", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.isSubtitlesAvailable() @@ -1172,8 +1440,9 @@ describe("Bigscreen Player", () => { }) describe("customiseSubtitles", () => { - it("passes through custom styles to Subtitles customise", () => { - initialiseBigscreenPlayer() + it("passes through custom styles to Subtitles customise", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + const customStyleObj = { size: 0.7 } bigscreenPlayer.customiseSubtitles(customStyleObj) @@ -1182,8 +1451,9 @@ describe("Bigscreen Player", () => { }) describe("renderSubtitleExample", () => { - it("calls Subtitles renderExample with correct values", () => { - initialiseBigscreenPlayer() + it("calls Subtitles renderExample with correct values", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + const exampleUrl = "" const customStyleObj = { size: 0.7 } const safePosititon = { left: 30, top: 0 } @@ -1194,8 +1464,9 @@ describe("Bigscreen Player", () => { }) describe("clearSubtitleExample", () => { - it("calls Subtitles clearExample", () => { - initialiseBigscreenPlayer() + it("calls Subtitles clearExample", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.clearSubtitleExample() expect(mockSubtitlesInstance.clearExample).toHaveBeenCalledTimes(1) @@ -1203,8 +1474,9 @@ describe("Bigscreen Player", () => { }) describe("setTransportControlsPosition", () => { - it("should call through to Subtitles setPosition function", () => { - initialiseBigscreenPlayer() + it("should call through to Subtitles setPosition function", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.setTransportControlsPosition() expect(mockSubtitlesInstance.setPosition).toHaveBeenCalledTimes(1) @@ -1212,15 +1484,18 @@ describe("Bigscreen Player", () => { }) describe("resize", () => { - it("calls resizer with correct values", () => { - initialiseBigscreenPlayer() + it("calls resizer with correct values", async () => { + const playbackElement = createPlaybackElement() + await asyncInitialiseBigscreenPlayer(playbackElement, bigscreenPlayerData) + bigscreenPlayer.resize(10, 10, 160, 90, 100) expect(mockResizer.resize).toHaveBeenCalledWith(playbackElement, 10, 10, 160, 90, 100) }) - it("hides subtitles when resized", () => { - initialiseBigscreenPlayer() + it("hides subtitles when resized", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.resize(10, 10, 160, 90, 100) expect(mockSubtitlesInstance.hide).toHaveBeenCalledTimes(1) @@ -1228,26 +1503,30 @@ describe("Bigscreen Player", () => { }) describe("clearResize", () => { - it("calls resizers clear function", () => { - initialiseBigscreenPlayer() + it("calls resizers clear function", async () => { + const playbackElement = createPlaybackElement() + await asyncInitialiseBigscreenPlayer(playbackElement, bigscreenPlayerData) + bigscreenPlayer.clearResize() expect(mockResizer.clear).toHaveBeenCalledWith(playbackElement) }) - it("shows subtitles if subtitles are enabled", () => { + it("shows subtitles if subtitles are enabled", async () => { mockSubtitlesInstance.enabled.mockReturnValue(true) - initialiseBigscreenPlayer() + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.clearResize() expect(mockSubtitlesInstance.show).toHaveBeenCalledTimes(1) }) - it("hides subtitles if subtitles are disabled", () => { + it("hides subtitles if subtitles are disabled", async () => { mockSubtitlesInstance.enabled.mockReturnValue(false) - initialiseBigscreenPlayer() + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.clearResize() expect(mockSubtitlesInstance.hide).toHaveBeenCalledTimes(1) @@ -1255,8 +1534,9 @@ describe("Bigscreen Player", () => { }) describe("setBroadcastMixADEnabled", () => { - it("should turn broadcastMixAD on/off when a value is passed in", () => { - initialiseBigscreenPlayer() + it("should turn broadcastMixAD on/off when a value is passed in", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) + bigscreenPlayer.setBroadcastMixADEnabled(true) expect(mockPlayerComponentInstance.setBroadcastMixADOn).toHaveBeenCalledTimes(1) @@ -1270,8 +1550,8 @@ describe("Bigscreen Player", () => { }) describe("isBroadcastMixADEnabled", () => { - it("calls through to playercomponent enabled when called", () => { - initialiseBigscreenPlayer() + it("calls through to playercomponent enabled when called", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.isBroadcastMixADEnabled() @@ -1280,8 +1560,8 @@ describe("Bigscreen Player", () => { }) describe("isBroadcastMixADAvailable", () => { - it("calls through to playercomponent available when called", () => { - initialiseBigscreenPlayer() + it("calls through to playercomponent available when called", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) bigscreenPlayer.isBroadcastMixADAvailable() @@ -1289,174 +1569,73 @@ describe("Bigscreen Player", () => { }) }) - describe("canSeek", () => { - it("should return true when in VOD playback", () => { - initialiseBigscreenPlayer() - - expect(bigscreenPlayer.canSeek()).toBe(true) - }) - - describe("live", () => { - it("should return true when it can seek", () => { - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 60 }) - - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) - - expect(bigscreenPlayer.canSeek()).toBe(true) - }) - - it("should return false when seekable range is infinite", () => { - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: Infinity }) - - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) - - expect(bigscreenPlayer.canSeek()).toBe(false) - }) - - it("should return false when window length less than four minutes", () => { - setupManifestData({ - transferFormat: "dash", - time: { - windowStartTime: 0, - windowEndTime: 239999, - correction: 0, - }, - }) - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 60 }) - - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) - - expect(bigscreenPlayer.canSeek()).toBe(false) - }) - - it("should return false when device does not support seeking", () => { - mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 60 }) - - jest.spyOn(PlayerComponent, "getLiveSupport").mockReturnValue(LiveSupport.PLAYABLE) - - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) - - expect(bigscreenPlayer.canSeek()).toBe(false) - }) - }) - }) - describe("canPause", () => { - it("VOD should return true", () => { - initialiseBigscreenPlayer() + it("should return true for on demand streams", async () => { + jest.mocked(mockMediaSources.time).mockReturnValue({ manifestType: ManifestType.STATIC }) + + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) expect(bigscreenPlayer.canPause()).toBe(true) }) - describe("LIVE", () => { - it("should return true when it can pause", () => { - jest.spyOn(PlayerComponent, "getLiveSupport").mockReturnValue(LiveSupport.RESTARTABLE) - - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) + it("should call through to DynamicWindowUtils with correct arguments and return it's value for live streams", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - expect(bigscreenPlayer.canPause()).toBe(true) + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 7200000, }) - it("should be false when window length less than four minutes", () => { - setupManifestData({ - transferFormat: TransferFormats.DASH, - time: { - windowStartTime: 0, - windowEndTime: 239999, - correction: 0, - }, - }) - jest.spyOn(PlayerComponent, "getLiveSupport").mockReturnValue(LiveSupport.RESTARTABLE) + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 60 }) - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) + jest.mocked(canPauseAndSeek).mockReturnValueOnce(true) - expect(bigscreenPlayer.canPause()).toBe(false) - }) + expect(bigscreenPlayer.canPause()).toBe(true) - it("should return false when device does not support pausing", () => { - jest.spyOn(PlayerComponent, "getLiveSupport").mockReturnValue(LiveSupport.PLAYABLE) + expect(canPauseAndSeek).toHaveBeenCalledWith(LiveSupport.SEEKABLE, { start: 0, end: 60 }) - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) + jest.mocked(canPauseAndSeek).mockReturnValueOnce(false) - expect(bigscreenPlayer.canPause()).toBe(false) - }) + expect(bigscreenPlayer.canPause()).toBe(false) + + expect(canPauseAndSeek).toHaveBeenCalledWith(LiveSupport.SEEKABLE, { start: 0, end: 60 }) }) }) - describe("convertVideoTimeSecondsToEpochMs", () => { - it("converts video time to epoch time when windowStartTime is supplied", () => { - setupManifestData({ - time: { - windowStartTime: 4200, - windowEndTime: 150000000, - }, - }) + describe("canSeek", () => { + it("should return true for on demand streams", async () => { + jest.mocked(mockMediaSources.time).mockReturnValue({ manifestType: ManifestType.STATIC }) - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - expect(bigscreenPlayer.convertVideoTimeSecondsToEpochMs(1000)).toBe(4200 + 1000000) + expect(bigscreenPlayer.canSeek()).toBe(true) }) - it("does not convert video time to epoch time when windowStartTime is not supplied", () => { - setupManifestData({ - time: { - windowStartTime: undefined, - windowEndTime: undefined, - }, - }) + it("should call through to DynamicWindowUtils with correct arguments and return it's value for live streams", async () => { + await asyncInitialiseBigscreenPlayer(createPlaybackElement(), bigscreenPlayerData) - initialiseBigscreenPlayer() + jest.mocked(mockMediaSources.time).mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731514400000, + availabilityStartTimeInMilliseconds: 1731514400000, + timeShiftBufferDepthInMilliseconds: 7200000, + }) - expect(bigscreenPlayer.convertVideoTimeSecondsToEpochMs(1000)).toBeNull() - }) - }) + mockPlayerComponentInstance.getSeekableRange.mockReturnValue({ start: 0, end: 60 }) - describe("covertEpochMsToVideoTimeSeconds", () => { - it("converts epoch time to video time when windowStartTime is available", () => { - // windowStartTime - 16 January 2019 12:00:00 - // windowEndTime - 16 January 2019 14:00:00 - setupManifestData({ - time: { - windowStartTime: 1547640000000, - windowEndTime: 1547647200000, - }, - }) + jest.mocked(canPauseAndSeek).mockReturnValueOnce(true) - initialiseBigscreenPlayer({ - windowType: WindowTypes.SLIDING, - }) + expect(bigscreenPlayer.canSeek()).toBe(true) - // Time to convert - 16 January 2019 13:00:00 - one hour (3600 seconds) - expect(bigscreenPlayer.convertEpochMsToVideoTimeSeconds(1547643600000)).toBe(3600) - }) + expect(canPauseAndSeek).toHaveBeenCalledWith(LiveSupport.SEEKABLE, { start: 0, end: 60 }) - it("does not convert epoch time to video time when windowStartTime is not available", () => { - setupManifestData({ - time: { - windowStartTime: undefined, - windowEndTime: undefined, - }, - }) + jest.mocked(canPauseAndSeek).mockReturnValueOnce(false) - initialiseBigscreenPlayer() + expect(bigscreenPlayer.canSeek()).toBe(false) - expect(bigscreenPlayer.convertEpochMsToVideoTimeSeconds(1547643600000)).toBeNull() + expect(canPauseAndSeek).toHaveBeenCalledWith(LiveSupport.SEEKABLE, { start: 0, end: 60 }) }) }) @@ -1466,7 +1645,6 @@ describe("Bigscreen Player", () => { onError: jest.fn(), } - initialiseBigscreenPlayer() bigscreenPlayer.registerPlugin(mockPlugin) expect(Plugins.registerPlugin).toHaveBeenCalledWith(mockPlugin) @@ -1479,8 +1657,6 @@ describe("Bigscreen Player", () => { onError: jest.fn(), } - initialiseBigscreenPlayer() - bigscreenPlayer.unregisterPlugin(mockPlugin) expect(Plugins.unregisterPlugin).toHaveBeenCalledWith(mockPlugin) @@ -1488,8 +1664,9 @@ describe("Bigscreen Player", () => { }) describe("getDebugLogs", () => { - it('should call "retrieve" on the DebugTool', () => { + it("should retrieve logs from DebugTool", () => { bigscreenPlayer.getDebugLogs() + expect(DebugTool.getDebugLogs).toHaveBeenCalledTimes(1) }) }) diff --git a/src/debugger/chronicle.ts b/src/debugger/chronicle.ts index 52060231..001a6ddc 100644 --- a/src/debugger/chronicle.ts +++ b/src/debugger/chronicle.ts @@ -1,6 +1,9 @@ import { MediaState } from "../models/mediastate" import getValues from "../utils/get-values" import { MediaKinds } from "../models/mediakinds" +import { Timeline } from "../models/timeline" +import { TransferFormat } from "../models/transferformats" +import { TimeInfo } from "../manifest/manifestparser" export enum EntryCategory { METRIC = "metric", @@ -40,7 +43,7 @@ type CDNsAvailable = CreateMetric<"cdns-available", string[]> type CurrentUrl = CreateMetric<"current-url", string> type Duration = CreateMetric<"duration", number> type FramesDropped = CreateMetric<"frames-dropped", number> -type InitialPlaybackTime = CreateMetric<"initial-playback-time", number> +type InitialPlaybackTime = CreateMetric<"initial-playback-time", [time: number, timeline: Timeline]> type MediaElementEnded = CreateMetric<"ended", HTMLMediaElement["ended"]> type MediaElementPaused = CreateMetric<"paused", HTMLMediaElement["paused"]> type MediaElementPlaybackRate = CreateMetric<"playback-rate", HTMLMediaElement["playbackRate"]> @@ -88,12 +91,13 @@ type CreateTrace type BufferedRanges = CreateTrace<"buffered-ranges", { kind: MediaKinds; buffered: [start: number, end: number][] }> -type Error = CreateTrace<"error", { name?: string; message: string }> +type Error = CreateTrace<"error", { name: string; message: string }> type Event = CreateTrace<"event", { eventType: string; eventTarget: string }> type Gap = CreateTrace<"gap", { from: number; to: number }> type QuotaExceeded = CreateTrace<"quota-exceeded", { bufferLevel: number; time: number }> type SessionStart = CreateTrace<"session-start", number> type SessionEnd = CreateTrace<"session-end", number> +type SourceLoaded = CreateTrace<"source-loaded", TimeInfo & { transferFormat: TransferFormat }> type StateChange = CreateTrace<"state-change", MediaState> export type Trace = @@ -105,6 +109,7 @@ export type Trace = | QuotaExceeded | SessionStart | SessionEnd + | SourceLoaded | StateChange export type TraceKind = Trace["kind"] diff --git a/src/debugger/debugtool.test.ts b/src/debugger/debugtool.test.ts index a9af86ed..a2fdc97e 100644 --- a/src/debugger/debugtool.test.ts +++ b/src/debugger/debugtool.test.ts @@ -1,3 +1,4 @@ +import { ManifestType, TransferFormat } from "../main" import DebugTool, { LogLevels } from "./debugtool" import DebugViewController from "./debugviewcontroller" @@ -103,7 +104,7 @@ describe("Debug Tool", () => { }) }) -describe("Debug Tool", () => { +describe("initialised Debug Tool", () => { beforeEach(() => { DebugTool.init() }) @@ -149,7 +150,7 @@ describe("Debug Tool", () => { expect(DebugTool.getDebugLogs()).toEqual([ expect.objectContaining({ kind: "session-start" }), - expect.objectContaining({ kind: "error", data: { message: "something went wrong" } }), + expect.objectContaining({ kind: "error", data: { name: "Error", message: "something went wrong" } }), ]) }) @@ -240,6 +241,34 @@ describe("Debug Tool", () => { }) }) + describe("logging manifest loaded", () => { + it("appends the manifest loaded trace to the log", () => { + jest.advanceTimersByTime(1) + + DebugTool.sourceLoaded({ + manifestType: ManifestType.STATIC, + transferFormat: TransferFormat.DASH, + availabilityStartTimeInMilliseconds: 0, + presentationTimeOffsetInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) + + expect(DebugTool.getDebugLogs()).toEqual([ + expect.objectContaining({ kind: "session-start" }), + expect.objectContaining({ + kind: "source-loaded", + data: { + manifestType: ManifestType.STATIC, + transferFormat: TransferFormat.DASH, + availabilityStartTimeInMilliseconds: 0, + presentationTimeOffsetInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + }), + ]) + }) + }) + describe("show", () => { it("provides the chronicle so far to the view controller", () => { const mockViewController = getMockViewController() diff --git a/src/debugger/debugtool.ts b/src/debugger/debugtool.ts index 9db35272..f939bed8 100644 --- a/src/debugger/debugtool.ts +++ b/src/debugger/debugtool.ts @@ -1,7 +1,10 @@ import { MediaState } from "../models/mediastate" import { MediaKinds } from "../models/mediakinds" +import { TransferFormat } from "../models/transferformats" import Chronicle, { MetricForKind, MetricKind, TimestampedEntry, isTrace } from "./chronicle" import DebugViewController from "./debugviewcontroller" +import { TimeInfo } from "../manifest/manifestparser" +import isError from "../utils/iserror" export const LogLevels = { ERROR: 0, @@ -47,7 +50,7 @@ function shouldDisplayEntry(entry: TimestampedEntry): boolean { ) } -function DebugTool() { +function createDebugTool() { let chronicle = new Chronicle() let currentLogLevel: LogLevel = LogLevels.INFO let viewController = new DebugViewController() @@ -116,12 +119,9 @@ function DebugTool() { return } - const data = parts.length < 2 ? parts[0] : parts.join(" ") + const { name, message } = parts.length === 1 && isError(parts[0]) ? parts[0] : new Error(parts.join(" ")) - chronicle.trace( - "error", - typeof data === "object" && "message" in data ? { name: data.name, message: data.message } : { message: data } - ) + chronicle.trace("error", { name, message }) } function event(eventType: string, eventTarget = "unknown") { @@ -144,6 +144,10 @@ function DebugTool() { chronicle.info(parts.join(" ")) } + function sourceLoaded(sourceInfo: TimeInfo & { transferFormat: TransferFormat }) { + chronicle.trace("source-loaded", sourceInfo) + } + function statechange(value: MediaState) { chronicle.trace("state-change", value) } @@ -214,6 +218,7 @@ function DebugTool() { gap, quotaExceeded, info, + sourceLoaded, statechange, warn, dynamicMetric, @@ -225,6 +230,6 @@ function DebugTool() { } } -const DebugToolInstance = DebugTool() satisfies DebugTool +const DebugTool = createDebugTool() satisfies DebugTool -export default DebugToolInstance +export default DebugTool diff --git a/src/debugger/debugviewcontroller.test.ts b/src/debugger/debugviewcontroller.test.ts index 40b416ed..24adc280 100644 --- a/src/debugger/debugviewcontroller.test.ts +++ b/src/debugger/debugviewcontroller.test.ts @@ -39,8 +39,8 @@ describe("Debug View", () => { }) it.each([ - [0, 3600000, "00:00:00 - 01:00:00"], - [1518018558259, 1518019158259, "15:49:18 - 15:59:18"], + [0, 3600, "00:00:00 - 01:00:00"], + [1518018558, 1518019158, "15:49:18 - 15:59:18"], ])("converts a seekable range %i-%i in a metric into a human-readable string %s-%s", (start, end, expected) => { const controller = new ViewController() const chronicle = new Chronicle() diff --git a/src/debugger/debugviewcontroller.ts b/src/debugger/debugviewcontroller.ts index 10f7c86b..2da92016 100644 --- a/src/debugger/debugviewcontroller.ts +++ b/src/debugger/debugviewcontroller.ts @@ -80,15 +80,13 @@ class DebugViewController { return mediaStateMetrics.includes(kind) } - private mergeMediaState( - entry: Timestamped> - ): Timestamped { + private mergeMediaState(entry: Timestamped>): Timestamped { const prevData = this.latestMetricByKey["media-element-state"] == null ? {} : (this.latestMetricByKey["media-element-state"] as StaticEntryForKind<"media-element-state">).data - const { kind, data } = entry as TimestampedMetric + const { kind, data } = entry return { ...entry, @@ -223,6 +221,31 @@ class DebugViewController { return `Playback session started at ${new Date(data).toISOString().replace("T", " ")}` case "session-end": return `Playback session ended at ${new Date(data).toISOString().replace("T", " ")}` + case "source-loaded": { + const { + transferFormat, + manifestType, + availabilityStartTimeInMilliseconds, + presentationTimeOffsetInMilliseconds, + timeShiftBufferDepthInMilliseconds, + } = data + + let logMessage = `Loaded ${manifestType} ${transferFormat} source.` + + if (availabilityStartTimeInMilliseconds > 0) { + logMessage += ` AST: ${new Date(availabilityStartTimeInMilliseconds).toString()}` + } + + if (timeShiftBufferDepthInMilliseconds > 0) { + logMessage += ` Time shift [s]: ${timeShiftBufferDepthInMilliseconds / 1000}` + } + + if (presentationTimeOffsetInMilliseconds > 0) { + logMessage += ` PTO [s]: ${presentationTimeOffsetInMilliseconds / 1000}.` + } + + return logMessage + } case "quota-exceeded": { const { bufferLevel, time } = data return `Quota exceeded with buffer level ${bufferLevel} at chunk start time ${time}` @@ -280,7 +303,7 @@ class DebugViewController { if (kind === "seekable-range") { const [start, end] = data as MetricForKind<"seekable-range">["data"] - return `${formatDate(new Date(start))} - ${formatDate(new Date(end))}` + return `${formatDate(new Date(start * 1000))} - ${formatDate(new Date(end * 1000))}` } if (kind === "representation-audio" || kind === "representation-video") { @@ -289,6 +312,12 @@ class DebugViewController { return `${qualityIndex} (${bitrate} kbps)` } + if (kind === "initial-playback-time") { + const [seconds, timeline] = data + + return `${seconds}s ${timeline}` + } + return data.join(", ") } diff --git a/src/dynamicwindowutils.js b/src/dynamicwindowutils.js deleted file mode 100644 index 3dca580a..00000000 --- a/src/dynamicwindowutils.js +++ /dev/null @@ -1,79 +0,0 @@ -import LiveSupport from "./models/livesupport" -import DebugTool from "./debugger/debugtool" - -const AUTO_RESUME_WINDOW_START_CUSHION_SECONDS = 8 -const FOUR_MINUTES = 4 * 60 - -function convertMilliSecondsToSeconds(timeInMilis) { - return Math.floor(timeInMilis / 1000) -} - -function hasFiniteSeekableRange(seekableRange) { - let hasRange = true - try { - hasRange = seekableRange.end !== Infinity - } catch (_error) { - /* empty */ - } - return hasRange -} - -function canSeek(windowStart, windowEnd, liveSupport, seekableRange) { - return ( - supportsSeeking(liveSupport) && - initialWindowIsBigEnoughForSeeking(windowStart, windowEnd) && - hasFiniteSeekableRange(seekableRange) - ) -} - -function canPause(windowStart, windowEnd, liveSupport) { - return supportsPause(liveSupport) && initialWindowIsBigEnoughForSeeking(windowStart, windowEnd) -} - -function initialWindowIsBigEnoughForSeeking(windowStart, windowEnd) { - const start = convertMilliSecondsToSeconds(windowStart) - const end = convertMilliSecondsToSeconds(windowEnd) - return end - start > FOUR_MINUTES -} - -function supportsPause(liveSupport) { - return liveSupport === LiveSupport.SEEKABLE || liveSupport === LiveSupport.RESTARTABLE -} - -function supportsSeeking(liveSupport) { - return ( - liveSupport === LiveSupport.SEEKABLE || - (liveSupport === LiveSupport.RESTARTABLE && window.bigscreenPlayer.playbackStrategy === "nativestrategy") - ) -} - -function autoResumeAtStartOfRange( - currentTime, - seekableRange, - addEventCallback, - removeEventCallback, - checkNotPauseEvent, - resume -) { - const resumeTimeOut = Math.max(0, currentTime - seekableRange.start - AUTO_RESUME_WINDOW_START_CUSHION_SECONDS) - DebugTool.dynamicMetric("auto-resume", resumeTimeOut) - const autoResumeTimer = setTimeout(() => { - removeEventCallback(undefined, detectIfUnpaused) - resume() - }, resumeTimeOut * 1000) - - addEventCallback(undefined, detectIfUnpaused) - - function detectIfUnpaused(event) { - if (checkNotPauseEvent(event)) { - removeEventCallback(undefined, detectIfUnpaused) - clearTimeout(autoResumeTimer) - } - } -} - -export default { - autoResumeAtStartOfRange, - canPause, - canSeek, -} diff --git a/src/dynamicwindowutils.test.js b/src/dynamicwindowutils.test.js index 507f6da9..d88a43f8 100644 --- a/src/dynamicwindowutils.test.js +++ b/src/dynamicwindowutils.test.js @@ -1,9 +1,12 @@ -import DynamicWindowUtils from "./dynamicwindowutils" +import { autoResumeAtStartOfRange, canPauseAndSeek } from "./dynamicwindowutils" +import LiveSupport from "./models/livesupport" describe("autoResumeAtStartOfRange", () => { const currentTime = 20 + const seekableRange = { start: 0, + end: 7200, } let resume @@ -11,8 +14,16 @@ describe("autoResumeAtStartOfRange", () => { let removeEventCallback let checkNotPauseEvent - beforeEach(() => { + afterAll(() => { + jest.useRealTimers() + }) + + beforeAll(() => { jest.useFakeTimers() + }) + + beforeEach(() => { + jest.clearAllTimers() resume = jest.fn() addEventCallback = jest.fn() @@ -20,70 +31,67 @@ describe("autoResumeAtStartOfRange", () => { checkNotPauseEvent = jest.fn() }) - afterEach(() => { - jest.useRealTimers() - }) + it.each([ + [0, 7200, 20], + [3600, 10800, 3620], + ])( + "resumes play when the start of the seekable range (%d - %d) catches up to current time %d", + (seekableRangeStart, seekableRangeEnd, currentTime) => { + const seekableRange = { + start: seekableRangeStart, + end: seekableRangeEnd, + } - it("resumes play when the current time is equal to the start of the seekable range", () => { - DynamicWindowUtils.autoResumeAtStartOfRange( - currentTime, - seekableRange, - addEventCallback, - removeEventCallback, - undefined, - resume - ) + autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume) - jest.advanceTimersByTime(20000) + jest.advanceTimersByTime(20000) + + expect(addEventCallback).toHaveBeenCalledTimes(1) + expect(removeEventCallback).toHaveBeenCalledTimes(1) + expect(resume).toHaveBeenCalledTimes(1) + } + ) + + it("resumes play when the start of the seekable range is within a threshold of current time", () => { + autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume) + + jest.advanceTimersByTime(15000) expect(addEventCallback).toHaveBeenCalledTimes(1) expect(removeEventCallback).toHaveBeenCalledTimes(1) expect(resume).toHaveBeenCalledTimes(1) }) - it("resumes play when the current time at the start of the seekable range within a threshold", () => { - DynamicWindowUtils.autoResumeAtStartOfRange( - currentTime, - seekableRange, - addEventCallback, - removeEventCallback, - undefined, - resume - ) + it("resumes play when the start of the seekable range is at the threshold of current time", () => { + autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume) - jest.advanceTimersByTime(15000) + jest.advanceTimersByTime(12000) expect(addEventCallback).toHaveBeenCalledTimes(1) expect(removeEventCallback).toHaveBeenCalledTimes(1) expect(resume).toHaveBeenCalledTimes(1) }) - it("resumes play when the current time at the start of the seekable range at the threshold", () => { - DynamicWindowUtils.autoResumeAtStartOfRange( - currentTime, - seekableRange, - addEventCallback, - removeEventCallback, - undefined, - resume - ) + it("resumes play when the start of the time shift buffer is at the threshold of current time", () => { + const seekableRange = { start: 0, end: 7170 } - jest.advanceTimersByTime(12000) + autoResumeAtStartOfRange(30, seekableRange, addEventCallback, removeEventCallback, undefined, resume, 7200) expect(addEventCallback).toHaveBeenCalledTimes(1) - expect(removeEventCallback).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(40000) + + expect(resume).not.toHaveBeenCalled() + expect(removeEventCallback).not.toHaveBeenCalled() + + jest.advanceTimersByTime(20000) + expect(resume).toHaveBeenCalledTimes(1) + expect(removeEventCallback).toHaveBeenCalledTimes(1) }) - it("does not resume play when the current time is past the start of the seekable range plus the threshold", () => { - DynamicWindowUtils.autoResumeAtStartOfRange( - currentTime, - seekableRange, - addEventCallback, - removeEventCallback, - undefined, - resume - ) + it("does not resume play when the start of the seekable range has not caught up to current time", () => { + autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume) jest.advanceTimersByTime(10000) @@ -97,7 +105,7 @@ describe("autoResumeAtStartOfRange", () => { addEventCallback.mockImplementation((_, callback) => callback()) - DynamicWindowUtils.autoResumeAtStartOfRange( + autoResumeAtStartOfRange( currentTime, seekableRange, addEventCallback, @@ -117,7 +125,7 @@ describe("autoResumeAtStartOfRange", () => { addEventCallback.mockImplementation((_, callback) => callback()) - DynamicWindowUtils.autoResumeAtStartOfRange( + autoResumeAtStartOfRange( currentTime, seekableRange, addEventCallback, @@ -132,3 +140,25 @@ describe("autoResumeAtStartOfRange", () => { expect(resume).toHaveBeenCalledTimes(1) }) }) + +describe("canPause", () => { + it("can't pause no live support", () => { + expect(canPauseAndSeek(LiveSupport.NONE, { start: 0, end: 30 * 60 })).toBe(false) + }) + + it("can't pause playable", () => { + expect(canPauseAndSeek(LiveSupport.PLAYABLE, { start: 0, end: 30 * 60 })).toBe(false) + }) + + it("can't pause restartable", () => { + expect(canPauseAndSeek(LiveSupport.RESTARTABLE, { start: 0, end: 30 * 60 })).toBe(false) + }) + + it("can pause seekable", () => { + expect(canPauseAndSeek(LiveSupport.SEEKABLE, { start: 0, end: 30 * 60 })).toBe(true) + }) + + it("can't pause a seekable range less than 4 minutes", () => { + expect(canPauseAndSeek(LiveSupport.SEEKABLE, { start: 0, end: 3 * 60 })).toBe(false) + }) +}) diff --git a/src/dynamicwindowutils.ts b/src/dynamicwindowutils.ts new file mode 100644 index 00000000..6f9130fb --- /dev/null +++ b/src/dynamicwindowutils.ts @@ -0,0 +1,81 @@ +import LiveSupport from "./models/livesupport" +import DebugTool from "./debugger/debugtool" +import PlaybackStrategy from "./models/playbackstrategy" + +const AUTO_RESUME_WINDOW_START_CUSHION_SECONDS = 8 +const FOUR_MINUTES = 4 * 60 + +declare global { + interface Window { + bigscreenPlayer?: { + playbackStrategy: PlaybackStrategy + liveSupport?: LiveSupport + } + } +} + +type SeekableRange = { + start: number + end: number +} + +function isSeekableRange(obj: unknown): obj is SeekableRange { + return ( + obj != null && + typeof obj === "object" && + "start" in obj && + "end" in obj && + typeof obj.start === "number" && + typeof obj.end === "number" && + isFinite(obj.start) && + isFinite(obj.end) + ) +} + +function isSeekableRangeBigEnough({ start, end }: SeekableRange): boolean { + return end - start > FOUR_MINUTES +} + +export function canPauseAndSeek(liveSupport: LiveSupport, seekableRange: unknown): boolean { + return ( + liveSupport === LiveSupport.SEEKABLE && isSeekableRange(seekableRange) && isSeekableRangeBigEnough(seekableRange) + ) +} + +export function autoResumeAtStartOfRange( + currentTime: number, + seekableRange: SeekableRange, + addEventCallback: (thisArg: undefined, callback: (event: unknown) => void) => void, + removeEventCallback: (thisArg: undefined, callback: (event: unknown) => void) => void, + checkNotPauseEvent: (event: unknown) => boolean, + resume: () => void, + timeShiftBufferDepthInSeconds?: number +): void { + const { start, end } = seekableRange + + const duration = end - start + + const windowLengthInSeconds = + timeShiftBufferDepthInSeconds && duration < timeShiftBufferDepthInSeconds ? timeShiftBufferDepthInSeconds : duration + + const resumeTimeOut = Math.max( + 0, + windowLengthInSeconds - (end - currentTime) - AUTO_RESUME_WINDOW_START_CUSHION_SECONDS + ) + + DebugTool.dynamicMetric("auto-resume", resumeTimeOut) + + const autoResumeTimer = setTimeout(() => { + removeEventCallback(undefined, detectIfUnpaused) + resume() + }, resumeTimeOut * 1000) + + addEventCallback(undefined, detectIfUnpaused) + + function detectIfUnpaused(event: unknown) { + if (checkNotPauseEvent(event)) { + removeEventCallback(undefined, detectIfUnpaused) + clearTimeout(autoResumeTimer) + } + } +} diff --git a/src/main.ts b/src/main.ts index 4fe3532a..616f350f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,13 @@ export { default as BigscreenPlayer } from "./bigscreenplayer" export { default as MockBigscreenPlayer } from "./mockbigscreenplayer" export { default as LiveSupport } from "./models/livesupport" +export { ManifestType } from "./models/manifesttypes" export { default as MediaKinds } from "./models/mediakinds" export { default as MediaState } from "./models/mediastate" export { default as PauseTriggers } from "./models/pausetriggers" export { default as PlaybackStrategy } from "./models/playbackstrategy" -export { default as TransferFormat } from "./models/transferformats" +export { TransferFormat } from "./models/transferformats" +export { Timeline } from "./models/timeline" export { default as TransportControlPosition } from "./models/transportcontrolposition" export { default as WindowTypes } from "./models/windowtypes" export { default as DebugTool } from "./debugger/debugtool" diff --git a/src/manifest/manifestloader.js b/src/manifest/manifestloader.js deleted file mode 100644 index 2c0c73ad..00000000 --- a/src/manifest/manifestloader.js +++ /dev/null @@ -1,104 +0,0 @@ -import ManifestParser from "./manifestparser" -import TransferFormats from "../models/transferformats" -import LoadUrl from "../utils/loadurl" - -function retrieveDashManifest(url, { windowType, initialWallclockTime } = {}) { - return new Promise((resolveLoad, rejectLoad) => - LoadUrl(url, { - method: "GET", - headers: {}, - timeout: 10000, - onLoad: (responseXML) => resolveLoad(responseXML), - onError: () => rejectLoad(new Error("Network error: Unable to retrieve DASH manifest")), - }) - ) - .then((xml) => { - if (xml == null) { - throw new TypeError("Unable to retrieve DASH XML response") - } - - return ManifestParser.parse(xml, { initialWallclockTime, windowType, type: "mpd" }) - }) - .then((time) => ({ time, transferFormat: TransferFormats.DASH })) - .catch((error) => { - if (error.message.indexOf("DASH") !== -1) { - throw error - } - - throw new Error("Unable to retrieve DASH XML response") - }) -} - -function retrieveHLSManifest(url, { windowType } = {}) { - return new Promise((resolveLoad, rejectLoad) => - LoadUrl(url, { - method: "GET", - headers: {}, - timeout: 10000, - onLoad: (_, responseText) => resolveLoad(responseText), - onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS master playlist")), - }) - ).then((text) => { - if (!text || typeof text !== "string") { - throw new TypeError("Unable to retrieve HLS master playlist") - } - - let streamUrl = getStreamUrl(text) - - if (!streamUrl || typeof streamUrl !== "string") { - throw new TypeError("Unable to retrieve playlist url from HLS master playlist") - } - - if (streamUrl.indexOf("http") !== 0) { - const parts = url.split("/") - - parts.pop() - parts.push(streamUrl) - streamUrl = parts.join("/") - } - - return retrieveHLSLivePlaylist(streamUrl, { windowType }) - }) -} - -function retrieveHLSLivePlaylist(url, { windowType } = {}) { - return new Promise((resolveLoad, rejectLoad) => - LoadUrl(url, { - method: "GET", - headers: {}, - timeout: 10000, - onLoad: (_, responseText) => resolveLoad(responseText), - onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS live playlist")), - }) - ) - .then((text) => { - if (!text || typeof text !== "string") { - throw new TypeError("Unable to retrieve HLS live playlist") - } - - return ManifestParser.parse(text, { windowType, type: "m3u8" }) - }) - .then((time) => ({ time, transferFormat: TransferFormats.HLS })) -} - -function getStreamUrl(data) { - const match = /#EXT-X-STREAM-INF:.*[\n\r]+(.*)[\n\r]?/.exec(data) - - if (match) { - return match[1] - } -} - -export default { - load: (mediaUrl, { windowType, initialWallclockTime } = {}) => { - if (/\.mpd(\?.*)?$/.test(mediaUrl)) { - return retrieveDashManifest(mediaUrl, { windowType, initialWallclockTime }) - } - - if (/\.m3u8(\?.*)?$/.test(mediaUrl)) { - return retrieveHLSManifest(mediaUrl, { windowType, initialWallclockTime }) - } - - return Promise.reject(new Error("Invalid media url")) - }, -} diff --git a/src/manifest/manifestparser.js b/src/manifest/manifestparser.js deleted file mode 100644 index 574467d1..00000000 --- a/src/manifest/manifestparser.js +++ /dev/null @@ -1,193 +0,0 @@ -import TimeUtils from "./../utils/timeutils" -import DebugTool from "../debugger/debugtool" -import WindowTypes from "../models/windowtypes" -import Plugins from "../plugins" -import PluginEnums from "../pluginenums" -import LoadUrl from "../utils/loadurl" - -const parsingStrategyByManifestType = { - mpd: parseMPD, - m3u8: parseM3U8, -} - -const placeholders = { - windowStartTime: NaN, - windowEndTime: NaN, - presentationTimeOffsetSeconds: NaN, - timeCorrectionSeconds: NaN, -} - -const dashParsingStrategyByWindowType = { - [WindowTypes.GROWING]: parseGrowingMPD, - [WindowTypes.SLIDING]: parseSlidingMPD, - [WindowTypes.STATIC]: parseStaticMPD, -} - -function parseMPD(manifestEl, { windowType, initialWallclockTime } = {}) { - return new Promise((resolve) => { - const mpd = manifestEl.querySelector("MPD") - - const parse = dashParsingStrategyByWindowType[windowType] - - if (parse == null) { - throw new Error(`Could not find a DASH parsing strategy for window type ${windowType}`) - } - - return resolve(parse(mpd, initialWallclockTime)) - }).catch((error) => { - const errorWithCode = new Error(error.message ?? "manifest-dash-parse-error") - errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE - throw errorWithCode - }) -} - -function fetchWallclockTime(mpd, initialWallclockTime) { - // TODO: `serverDate`/`initialWallClockTime` is deprecated. Remove this. - // [tag:ServerDate] - if (initialWallclockTime) { - // console.warn("Deprecated") - return Promise.resolve(initialWallclockTime) - } - - return new Promise((resolveFetch, rejectFetch) => { - const timingResource = mpd.querySelector("UTCTiming")?.getAttribute("value") - - if (!timingResource || typeof timingResource !== "string") { - throw new TypeError("manifest-dash-timing-error") - } - - LoadUrl(timingResource, { - onLoad: (_, utcTimeString) => resolveFetch(Date.parse(utcTimeString)), - onError: () => rejectFetch(new Error("manifest-dash-timing-error")), - }) - }) -} - -function getSegmentTemplate(mpd) { - // Can be either audio or video data. - // It doesn't matter as we use the factor of x/timescale. This is the same for both. - const segmentTemplate = mpd.querySelector("SegmentTemplate") - - return { - duration: parseFloat(segmentTemplate.getAttribute("duration")), - timescale: parseFloat(segmentTemplate.getAttribute("timescale")), - presentationTimeOffset: parseFloat(segmentTemplate.getAttribute("presentationTimeOffset")), - } -} - -function parseStaticMPD(mpd) { - return new Promise((resolveParse) => { - const { presentationTimeOffset, timescale } = getSegmentTemplate(mpd) - - return resolveParse({ - presentationTimeOffsetSeconds: presentationTimeOffset / timescale, - }) - }) -} - -function parseSlidingMPD(mpd, initialWallclockTime) { - return fetchWallclockTime(mpd, initialWallclockTime).then((wallclockTime) => { - const { duration, timescale } = getSegmentTemplate(mpd) - const availabilityStartTime = mpd.getAttribute("availabilityStartTime") - const segmentLengthMillis = (1000 * duration) / timescale - - if (!availabilityStartTime || !segmentLengthMillis) { - throw new Error("manifest-dash-attributes-parse-error") - } - - const timeShiftBufferDepthMillis = 1000 * TimeUtils.durationToSeconds(mpd.getAttribute("timeShiftBufferDepth")) - const windowEndTime = wallclockTime - Date.parse(availabilityStartTime) - segmentLengthMillis - const windowStartTime = windowEndTime - timeShiftBufferDepthMillis - - return { - windowStartTime, - windowEndTime, - timeCorrectionSeconds: windowStartTime / 1000, - } - }) -} - -function parseGrowingMPD(mpd, initialWallclockTime) { - return fetchWallclockTime(mpd, initialWallclockTime).then((wallclockTime) => { - const { duration, timescale } = getSegmentTemplate(mpd) - const availabilityStartTime = mpd.getAttribute("availabilityStartTime") - const segmentLengthMillis = (1000 * duration) / timescale - - if (!availabilityStartTime || !segmentLengthMillis) { - throw new Error("manifest-dash-attributes-parse-error") - } - - return { - windowStartTime: Date.parse(availabilityStartTime), - windowEndTime: wallclockTime - segmentLengthMillis, - } - }) -} - -function parseM3U8(manifest, { windowType } = {}) { - return new Promise((resolve) => { - const programDateTime = getM3U8ProgramDateTime(manifest) - const duration = getM3U8WindowSizeInSeconds(manifest) - - if (!(programDateTime && duration)) { - throw new Error("manifest-hls-attributes-parse-error") - } - - if (windowType === WindowTypes.STATIC) { - return resolve({ - presentationTimeOffsetSeconds: programDateTime / 1000, - }) - } - - return resolve({ - windowStartTime: programDateTime, - windowEndTime: programDateTime + duration * 1000, - }) - }).catch((error) => { - const errorWithCode = new Error(error.message || "manifest-hls-parse-error") - errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE - throw errorWithCode - }) -} - -function getM3U8ProgramDateTime(data) { - const match = /^#EXT-X-PROGRAM-DATE-TIME:(.*)$/m.exec(data) - - if (match) { - const parsedDate = Date.parse(match[1]) - - if (!isNaN(parsedDate)) { - return parsedDate - } - } -} - -function getM3U8WindowSizeInSeconds(data) { - const regex = /#EXTINF:(\d+(?:\.\d+)?)/g - let matches = regex.exec(data) - let result = 0 - - while (matches) { - result += +matches[1] - matches = regex.exec(data) - } - - return Math.floor(result) -} - -function parse(manifest, { type, windowType, initialWallclockTime } = {}) { - const parseManifest = parsingStrategyByManifestType[type] - - return parseManifest(manifest, { windowType, initialWallclockTime }) - .then((values) => ({ ...placeholders, ...values })) - .catch((error) => { - DebugTool.error(error) - Plugins.interface.onManifestParseError({ code: error.code, message: error.message }) - - return { ...placeholders } - }) -} - -export default { - parse, -} diff --git a/src/manifest/manifestparser.test.js b/src/manifest/manifestparser.test.js deleted file mode 100644 index 61128801..00000000 --- a/src/manifest/manifestparser.test.js +++ /dev/null @@ -1,229 +0,0 @@ -import Plugins from "../plugins" -import WindowTypes from "../models/windowtypes" -import DashManifests, { appendTimingResource, setAvailabilityStartTime } from "./stubData/dashmanifests" -import HlsManifests from "./stubData/hlsmanifests" -import ManifestParser from "./manifestparser" -import LoadUrl from "../utils/loadurl" - -jest.mock("../utils/loadurl") - -describe("ManifestParser", () => { - beforeAll(() => { - // Mock the Date object - jest.useFakeTimers() - - jest.spyOn(Plugins.interface, "onManifestParseError") - - LoadUrl.mockImplementation((_, { onLoad }) => onLoad(null, new Date().toISOString())) - }) - - beforeEach(() => { - jest.clearAllMocks() - jest.clearAllTimers() - }) - - describe("parsing a DASH manifests", () => { - it("returns the time window for a manifest with a sliding window", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(DashManifests.SLIDING_WINDOW(), { - type: "mpd", - windowType: WindowTypes.SLIDING, - initialWallclockTime: new Date("2018-12-13T11:00:00.000000Z"), - }) - - // End time of the window is: - // provided time [millis] - availability start time [millis] - (segment.duration / segment.timescale) [millis] - // 1,544,698,800,000 - 60,000 - (1000 * 768 / 200) - expect(windowEndTime).toBe(1544698736160) - - // Start time of the window is: - // window.endtime [millis] - time shift buffer depth [millis] - expect(windowStartTime).toBe(1544691536160) - - // Time correction is: - // window.start_time [seconds] - expect(timeCorrectionSeconds).toBe(1544691536.16) - - expect(presentationTimeOffsetSeconds).toBeNaN() - }) - - it("returns the time window for a manifest with a growing window", async () => { - const manifest = DashManifests.GROWING_WINDOW() - - setAvailabilityStartTime(manifest, "2018-12-13T10:00:00.000Z") - - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(manifest, { - type: "mpd", - windowType: WindowTypes.GROWING, - initialWallclockTime: new Date("2018-12-13T11:00:00.000000Z"), - }) - - // End time of the window is: - // provided time [millis] - (segment.duration / segment.timescale) [millis] - expect(windowEndTime).toBe(1544698796160) - - // Start time of the window is: - // availability start time [millis] - expect(windowStartTime).toBe(1544695200000) - - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - }) - - it("returns the time window for a manifest with a static window", async () => { - const { presentationTimeOffsetSeconds, ...otherTimes } = await ManifestParser.parse( - DashManifests.STATIC_WINDOW(), - { - type: "mpd", - windowType: WindowTypes.STATIC, - } - ) - - // Presentation time offset is: - // segment.presentation_time_offset [seconds] / segment.timescale [sample/seconds] => [milliseconds] - expect(presentationTimeOffsetSeconds).toBe(1678431601.92) - - expect(Object.values(otherTimes)).toEqual([NaN, NaN, NaN]) - }) - - it("returns a fallback time window when the manifest has bad data in the attributes", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(DashManifests.BAD_ATTRIBUTES(), { - type: "mpd", - windowType: WindowTypes.GROWING, - initialWallclockTime: new Date("2018-12-13T11:00:00.000000Z"), - }) - - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - expect(presentationTimeOffsetSeconds).toBeNaN() - - expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() - }) - - it("returns a fallback time window when the manifest is malformed", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse("not an MPD", { - type: "mpd", - windowType: WindowTypes.STATIC, - initialWallclockTime: new Date("2018-12-13T11:00:00.000000Z"), - }) - - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - expect(presentationTimeOffsetSeconds).toBeNaN() - - expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() - }) - - it("fetches wallclock time from a timing resource for a manifest with a sliding window when a wallclock time is not provided", async () => { - jest.setSystemTime(new Date("1970-01-01T02:01:03.840Z")) - - const manifest = DashManifests.SLIDING_WINDOW() - - appendTimingResource(manifest, "https://time.some-cdn.com/?iso") - - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(manifest, { - type: "mpd", - windowType: WindowTypes.SLIDING, - }) - - expect(windowStartTime).toBe(new Date("1970-01-01T00:00:00Z").getTime()) - expect(windowEndTime).toBe(new Date("1970-01-01T02:00:00Z").getTime()) - expect(timeCorrectionSeconds).toBe(0) - - expect(presentationTimeOffsetSeconds).toBeNaN() - }) - - it("fetches wallclock time from a timing resource for a manifest with a growing window when a wallclock time is not provided", async () => { - const manifest = DashManifests.GROWING_WINDOW() - - appendTimingResource(manifest, "https://time.some-cdn.com/?iso") - setAvailabilityStartTime(manifest, "2018-12-13T11:00:00Z") - jest.setSystemTime(new Date("2018-12-13T12:45:03.840Z")) - - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(manifest, { - type: "mpd", - windowType: WindowTypes.GROWING, - }) - - expect(windowStartTime).toBe(new Date("2018-12-13T11:00:00").getTime()) - expect(windowEndTime).toBe(new Date("2018-12-13T12:45:00Z").getTime()) - - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - }) - - it.each([ - [WindowTypes.GROWING, DashManifests.GROWING_WINDOW()], - [WindowTypes.SLIDING, DashManifests.SLIDING_WINDOW()], - ])("emits error when a %s manifest does not include a timing resource", async (windowType, manifestEl) => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(manifestEl, { windowType, type: "mpd" }) - - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - - expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() - }) - }) - - describe("HLS m3u8", () => { - it("returns time window for sliding window hls manifest", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(HlsManifests.VALID_PROGRAM_DATETIME, { - type: "m3u8", - windowType: WindowTypes.SLIDING, - }) - - expect(windowStartTime).toBe(1436259310000) - expect(windowEndTime).toBe(1436259342000) - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - }) - - it("returns presentation time offset for static window hls manifest", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(HlsManifests.VALID_PROGRAM_DATETIME, { - type: "m3u8", - windowType: WindowTypes.STATIC, - }) - - expect(presentationTimeOffsetSeconds).toBe(1436259310) - expect(timeCorrectionSeconds).toBeNaN() - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - }) - - it("returns fallback data if manifest has an invalid start date", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse(HlsManifests.INVALID_PROGRAM_DATETIME, { type: "m3u8" }) - - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - - expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() - }) - - it("returns fallback data if hls manifest data is malformed", async () => { - const { windowStartTime, windowEndTime, presentationTimeOffsetSeconds, timeCorrectionSeconds } = - await ManifestParser.parse("not an valid manifest", { type: "m3u8" }) - - expect(windowStartTime).toBeNaN() - expect(windowEndTime).toBeNaN() - expect(presentationTimeOffsetSeconds).toBeNaN() - expect(timeCorrectionSeconds).toBeNaN() - - expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() - }) - }) -}) diff --git a/src/manifest/manifestparser.test.ts b/src/manifest/manifestparser.test.ts new file mode 100644 index 00000000..5d7dc26a --- /dev/null +++ b/src/manifest/manifestparser.test.ts @@ -0,0 +1,167 @@ +import Plugins from "../plugins" +import DashManifests from "./stubData/dashmanifests" +import HlsManifests from "./stubData/hlsmanifests" +import ManifestParser, { TimeInfo } from "./manifestparser" +import LoadUrl from "../utils/loadurl" +import { TransferFormat } from "../models/transferformats" +import { ManifestType } from "../models/manifesttypes" + +jest.mock("../utils/loadurl") + +describe("ManifestParser", () => { + beforeAll(() => { + // Mock the Date object + jest.useFakeTimers() + + jest.spyOn(Plugins.interface, "onManifestParseError") + + jest.mocked(LoadUrl).mockImplementation((_, { onLoad }) => onLoad?.(null, new Date().toISOString(), 200)) + }) + + beforeEach(() => { + jest.clearAllMocks() + jest.clearAllTimers() + }) + + describe("parsing a DASH manifest", () => { + it("returns a TimeInfo for a dynamic manifest with timeshift but no presentation time offset", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.TIMESHIFT_NO_PTO(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.DYNAMIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(7200000) // 2 hours + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(60000) // Thursday, 1 January 1970 00:01:00 + }) + + it("returns a TimeInfo for a dynamic manifest with timeshift and a presentation time offset", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.TIMESHIFT_PTO(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.DYNAMIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(1730936674560) // Wednesday, 6 November 2024 23:44:34.560 + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(21600000) // 6 hours + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(1730936714560) // Wednesday, 6 November 2024 23:45:14.560 + }) + + it("returns a TimeInfo for a dynamic manifest with a presentation time offset but no timeshift", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.PTO_NO_TIMESHIFT(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.DYNAMIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(1730936674560) // Wednesday, 6 November 2024 23:44:34.560 + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(1730936714560) // Wednesday, 6 November 2024 23:45:14.560 + }) + + it("returns a TimeInfo for a static manifest with no presentation time offset", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.STATIC_NO_PTO(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + }) + + it("returns a TimeInfo for a static manifest with a presentation time offset", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.STATIC_PTO(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(1730936674560) // Wednesday, 6 November 2024 23:44:34.560 + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + }) + + it("returns a TimeInfo with default values for a manifest with bad attributes", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: DashManifests.BAD_ATTRIBUTES(), + type: TransferFormat.DASH, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + + expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() + }) + }) + + describe("parsing a HLS manifest", () => { + it("returns a TimeInfo for a manifest with a valid program date time and no end list", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: HlsManifests.VALID_PROGRAM_DATETIME_NO_ENDLIST, + type: TransferFormat.HLS, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.DYNAMIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(1731052800000) // Friday, 8 November 2024 08:00:00 + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(1731052800000) // Friday, 8 November 2024 08:00:00 + }) + + it("returns a TimeInfo for an on demand manifest with an end list and no program date time", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: HlsManifests.NO_PROGRAM_DATETIME_ENDLIST, + type: TransferFormat.HLS, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + }) + + it("returns a TimeInfo for an on demand manifest with an end list and a valid program date time", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: HlsManifests.VALID_PROGRAM_DATETIME_AND_ENDLIST, + type: TransferFormat.HLS, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(1731045600000) // Friday, 8 November 2024 06:00:00 + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + }) + + it("returns a default TimeInfo if a program date time cannot be parsed", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: HlsManifests.INVALID_PROGRAM_DATETIME, + type: TransferFormat.HLS, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + + expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() + }) + + it("returns a default TimeInfo if manifest body is malformed", async () => { + const timeInfo: TimeInfo = await ManifestParser.parse({ + body: "malformed manifest body", + type: TransferFormat.HLS, + }) + + expect(timeInfo.manifestType).toEqual(ManifestType.STATIC) + expect(timeInfo.presentationTimeOffsetInMilliseconds).toBe(0) + expect(timeInfo.timeShiftBufferDepthInMilliseconds).toBe(0) + expect(timeInfo.availabilityStartTimeInMilliseconds).toBe(0) + + expect(Plugins.interface.onManifestParseError).toHaveBeenCalled() + }) + }) +}) diff --git a/src/manifest/manifestparser.ts b/src/manifest/manifestparser.ts new file mode 100644 index 00000000..190bff3f --- /dev/null +++ b/src/manifest/manifestparser.ts @@ -0,0 +1,154 @@ +import { durationToSeconds } from "../utils/timeutils" +import DebugTool from "../debugger/debugtool" +import Plugins from "../plugins" +import PluginEnums from "../pluginenums" +import { ManifestType } from "../models/manifesttypes" +import { TransferFormat, DASH, HLS } from "../models/transferformats" +import isError from "../utils/iserror" +import { ErrorWithCode } from "../models/errorcode" + +export type TimeInfo = { + manifestType: ManifestType + presentationTimeOffsetInMilliseconds: number + timeShiftBufferDepthInMilliseconds: number + availabilityStartTimeInMilliseconds: number +} + +function getMPDType(mpd: Element): ManifestType { + const type = mpd.getAttribute("type") + + if (type !== ManifestType.STATIC && type !== ManifestType.DYNAMIC) { + throw new TypeError(`MPD type attribute must be 'static' or 'dynamic'. Got ${type}`) + } + + return type as ManifestType +} + +function getMPDAvailabilityStartTimeInMilliseconds(mpd: Element): number { + return Date.parse(mpd.getAttribute("availabilityStartTime") ?? "") || 0 +} + +function getMPDTimeShiftBufferDepthInMilliseconds(mpd: Element): number { + return (durationToSeconds(mpd.getAttribute("timeShiftBufferDepth") ?? "") || 0) * 1000 +} + +function getMPDPresentationTimeOffsetInMilliseconds(mpd: Element): number { + // Can be either audio or video data. It doesn't matter as we use the factor of x/timescale. This is the same for both. + const segmentTemplate = mpd.querySelector("SegmentTemplate") + const presentationTimeOffsetInFrames = parseFloat(segmentTemplate?.getAttribute("presentationTimeOffset") ?? "") + const timescale = parseFloat(segmentTemplate?.getAttribute("timescale") ?? "") + + return (presentationTimeOffsetInFrames / timescale) * 1000 || 0 +} + +function parseMPD(manifestEl: Document): Promise { + return new Promise((resolve, reject) => { + const mpd = manifestEl.querySelector("MPD") + + if (mpd == null) { + return reject(new TypeError("Could not find an 'MPD' tag in the document")) + } + + const manifestType = getMPDType(mpd) + const presentationTimeOffsetInMilliseconds = getMPDPresentationTimeOffsetInMilliseconds(mpd) + const availabilityStartTimeInMilliseconds = getMPDAvailabilityStartTimeInMilliseconds(mpd) + const timeShiftBufferDepthInMilliseconds = getMPDTimeShiftBufferDepthInMilliseconds(mpd) + + return resolve({ + manifestType, + timeShiftBufferDepthInMilliseconds, + availabilityStartTimeInMilliseconds, + presentationTimeOffsetInMilliseconds, + }) + }).catch((reason: unknown) => { + const errorWithCode = (isError(reason) ? reason : new Error("manifest-dash-parse-error")) as ErrorWithCode + errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE + throw errorWithCode + }) +} + +function parseM3U8(manifest: string): Promise { + return new Promise((resolve) => { + const programDateTimeInMilliseconds = getM3U8ProgramDateTimeInMilliseconds(manifest) + const durationInMilliseconds = getM3U8WindowSizeInMilliseconds(manifest) + + if ( + programDateTimeInMilliseconds == null || + durationInMilliseconds == null || + (programDateTimeInMilliseconds === 0 && durationInMilliseconds === 0) + ) { + throw new Error("manifest-hls-attributes-parse-error") + } + + const manifestType = hasM3U8EndList(manifest) ? ManifestType.STATIC : ManifestType.DYNAMIC + + return resolve({ + manifestType, + timeShiftBufferDepthInMilliseconds: 0, + availabilityStartTimeInMilliseconds: manifestType === ManifestType.STATIC ? 0 : programDateTimeInMilliseconds, + presentationTimeOffsetInMilliseconds: programDateTimeInMilliseconds, + }) + }).catch((reason: unknown) => { + const errorWithCode = (isError(reason) ? reason : new Error("manifest-hls-parse-error")) as ErrorWithCode + errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE + throw errorWithCode + }) +} + +function getM3U8ProgramDateTimeInMilliseconds(data: string) { + const match = /^#EXT-X-PROGRAM-DATE-TIME:(.*)$/m.exec(data) + + if (match == null) { + return 0 + } + + const parsedDate = Date.parse(match[1]) + + return isNaN(parsedDate) ? null : parsedDate +} + +function getM3U8WindowSizeInMilliseconds(data: string): number { + const regex = /#EXTINF:(\d+(?:\.\d+)?)/g + let matches = regex.exec(data) + let result = 0 + + while (matches) { + result += +matches[1] + matches = regex.exec(data) + } + + return Math.floor(result * 1000) +} + +function hasM3U8EndList(data: string): boolean { + const match = /^#EXT-X-ENDLIST$/m.exec(data) + + return match != null +} + +function parse({ body, type }: { body: Document; type: DASH } | { body: string; type: HLS }): Promise { + return Promise.resolve() + .then(() => { + switch (type) { + case TransferFormat.DASH: + return parseMPD(body) + case TransferFormat.HLS: + return parseM3U8(body) + } + }) + .catch((error: ErrorWithCode) => { + DebugTool.error(error) + Plugins.interface.onManifestParseError({ code: error.code, message: error.message }) + + return { + manifestType: ManifestType.STATIC, + timeShiftBufferDepthInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + presentationTimeOffsetInMilliseconds: 0, + } + }) +} + +export default { + parse, +} diff --git a/src/manifest/manifestloader.test.js b/src/manifest/sourceloader.test.js similarity index 64% rename from src/manifest/manifestloader.test.js rename to src/manifest/sourceloader.test.js index aeb366bc..4fff19db 100644 --- a/src/manifest/manifestloader.test.js +++ b/src/manifest/sourceloader.test.js @@ -1,11 +1,11 @@ -import TransferFormats from "../models/transferformats" -import WindowTypes from "../models/windowtypes" +import { ManifestType } from "../models/manifesttypes" +import { TransferFormat } from "../models/transferformats" import Plugins from "../plugins" import getError from "../testutils/geterror" import LoadUrl from "../utils/loadurl" -import DashManifests, { appendTimingResource } from "./stubData/dashmanifests" +import DashManifests, { DASH_MANIFEST_STRINGS } from "./stubData/dashmanifests" import HlsManifests from "./stubData/hlsmanifests" -import ManifestLoader from "./manifestloader" +import ManifestLoader from "./sourceloader" jest.mock("../utils/loadurl") @@ -34,7 +34,7 @@ describe("ManifestLoader", () => { it("rejects when resource is not a recognised manifest type", () => expect(ManifestLoader.load("mock://some.url/")).rejects.toThrow("Invalid media url")) - describe("handling manifests", () => { + describe("handling sources", () => { beforeAll(() => { jest.spyOn(Plugins.interface, "onManifestParseError") }) @@ -43,44 +43,18 @@ describe("ManifestLoader", () => { jest.clearAllMocks() }) - describe("fetching DASH manifests", () => { - it.each([[WindowTypes.STATIC, DashManifests.STATIC_WINDOW()]])( - "resolves to parsed metadata for a valid DASH '%s' manifest", - async (windowType, manifestEl) => { - LoadUrl.mockImplementationOnce((url, config) => { - config.onLoad(manifestEl) - }) - - const { transferFormat, time } = await ManifestLoader.load("mock://some.manifest/test.mpd", { - windowType, - }) - - expect(transferFormat).toBe(TransferFormats.DASH) - expect(time).toEqual(expect.any(Object)) - - expect(Plugins.interface.onManifestParseError).not.toHaveBeenCalled() - } - ) - + describe("fetching DASH sources", () => { it.each([ - [WindowTypes.GROWING, DashManifests.GROWING_WINDOW()], - [WindowTypes.SLIDING, DashManifests.SLIDING_WINDOW()], - ])("resolves to parsed metadata for a valid DASH '%s' manifest", async (windowType, manifestEl) => { - appendTimingResource(manifestEl, "https://time.some-cdn.com/?iso") - + [ManifestType.STATIC, DashManifests.STATIC_NO_PTO()], + [ManifestType.DYNAMIC, DashManifests.PTO_NO_TIMESHIFT()], + ])("resolves to parsed metadata for a valid DASH '%s' manifest", async (_, manifestEl) => { LoadUrl.mockImplementationOnce((url, config) => { config.onLoad(manifestEl) }) - LoadUrl.mockImplementationOnce((url, config) => { - config.onLoad(new Date().toISOString()) - }) + const { transferFormat, time } = await ManifestLoader.load("mock://some.manifest/test.mpd") - const { transferFormat, time } = await ManifestLoader.load("mock://some.manifest/test.mpd", { - windowType, - }) - - expect(transferFormat).toBe(TransferFormats.DASH) + expect(transferFormat).toBe(TransferFormat.DASH) expect(time).toEqual(expect.any(Object)) expect(Plugins.interface.onManifestParseError).not.toHaveBeenCalled() @@ -96,6 +70,21 @@ describe("ManifestLoader", () => { ) }) + it("falls back to a DOMParser if the XMLHTTPRequest client's parser fails", async () => { + jest.spyOn(DOMParser.prototype, "parseFromString") + + LoadUrl.mockImplementationOnce((url, config) => config.onLoad(null, DASH_MANIFEST_STRINGS.STATIC_NO_PTO)) + + const { transferFormat, time } = await ManifestLoader.load("mock://some.manifest/test.mpd") + + expect(transferFormat).toBe(TransferFormat.DASH) + expect(time).toEqual(expect.any(Object)) + + expect(Plugins.interface.onManifestParseError).not.toHaveBeenCalled() + + expect(DOMParser.prototype.parseFromString).toHaveBeenCalledTimes(1) + }) + it("rejects when network request fails", async () => { LoadUrl.mockImplementationOnce((url, config) => { config.onError() @@ -107,7 +96,7 @@ describe("ManifestLoader", () => { }) }) - describe("fetching HLS manifests", () => { + describe("fetching HLS sources", () => { const hlsMasterResponse = "#EXTM3U\n" + "#EXT-X-VERSION:2\n" + @@ -116,23 +105,20 @@ describe("ManifestLoader", () => { '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=433540,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=384x216\n' + "bar.m3u8\n" - it.each(Object.values(WindowTypes))( - "resolves to parsed metadata for a valid HLS '%s' playlist", - async (windowType) => { - LoadUrl.mockImplementationOnce((url, config) => { - config.onLoad(undefined, hlsMasterResponse) - }).mockImplementationOnce((url, config) => { - config.onLoad(undefined, HlsManifests.VALID_PROGRAM_DATETIME) - }) + it("resolves to parsed metadata for a valid HLS 'dynamic' playlist", async () => { + LoadUrl.mockImplementationOnce((url, config) => { + config.onLoad(undefined, hlsMasterResponse) + }).mockImplementationOnce((url, config) => { + config.onLoad(undefined, HlsManifests.VALID_PROGRAM_DATETIME_NO_ENDLIST) + }) - const { transferFormat, time } = await ManifestLoader.load("http://foo.bar/test.m3u8", { windowType }) + const { transferFormat, time } = await ManifestLoader.load("http://foo.bar/test.m3u8") - expect(transferFormat).toBe(TransferFormats.HLS) - expect(time).toEqual(expect.any(Object)) + expect(transferFormat).toBe(TransferFormat.HLS) + expect(time).toEqual(expect.any(Object)) - expect(Plugins.interface.onManifestParseError).not.toHaveBeenCalled() - } - ) + expect(Plugins.interface.onManifestParseError).not.toHaveBeenCalled() + }) it("rejects when network request fails", async () => { LoadUrl.mockImplementation((url, config) => { @@ -178,5 +164,19 @@ describe("ManifestLoader", () => { ) }) }) + + describe("handling MP4 sources", () => { + it("resolves to metadata reflecting an on-demand stream", async () => { + const { transferFormat, time } = await ManifestLoader.load("http://foo.bar/test.mp4") + + expect(transferFormat).toBe(TransferFormat.PLAIN) + expect(time).toEqual({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + }) + }) + }) }) }) diff --git a/src/manifest/sourceloader.ts b/src/manifest/sourceloader.ts new file mode 100644 index 00000000..b875907c --- /dev/null +++ b/src/manifest/sourceloader.ts @@ -0,0 +1,130 @@ +import ManifestParser, { TimeInfo } from "./manifestparser" +import { ManifestType } from "../models/manifesttypes" +import { TransferFormat } from "../models/transferformats" +import LoadUrl from "../utils/loadurl" +import isError from "../utils/iserror" + +function parseXmlString(text: string): Document { + const parser = new DOMParser() + + const document = parser.parseFromString(text, "application/xml") + + // DOMParser lists the XML errors in the document + if (document.querySelector("parsererror") != null) { + throw new TypeError(`Failed to parse input string to XML`) + } + + return document +} + +function retrieveDashManifest(url: string) { + return new Promise((resolveLoad, rejectLoad) => + LoadUrl(url, { + method: "GET", + headers: {}, + timeout: 10000, + // Try to parse ourselves if the XHR parser failed due to f.ex. content-type + onLoad: (responseXML, responseText) => resolveLoad(responseXML || parseXmlString(responseText)), + onError: () => rejectLoad(new Error("Network error: Unable to retrieve DASH manifest")), + }) + ) + .then((xml) => { + if (xml == null) { + throw new TypeError("Unable to retrieve DASH XML response") + } + + return ManifestParser.parse({ body: xml, type: TransferFormat.DASH }) + }) + .then((time) => ({ time, transferFormat: TransferFormat.DASH })) + .catch((error) => { + if (isError(error) && error.message.indexOf("DASH") !== -1) { + throw error + } + + throw new Error("Unable to retrieve DASH XML response") + }) +} + +function retrieveHLSManifest(url: string) { + return new Promise((resolveLoad, rejectLoad) => + LoadUrl(url, { + method: "GET", + headers: {}, + timeout: 10000, + onLoad: (_, responseText) => resolveLoad(responseText), + onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS master playlist")), + }) + ).then((text) => { + if (!text || typeof text !== "string") { + throw new TypeError("Unable to retrieve HLS master playlist") + } + + let streamUrl = getStreamUrl(text) + + if (!streamUrl || typeof streamUrl !== "string") { + throw new TypeError("Unable to retrieve playlist url from HLS master playlist") + } + + if (streamUrl.indexOf("http") !== 0) { + const parts = url.split("/") + + parts.pop() + parts.push(streamUrl) + streamUrl = parts.join("/") + } + + return retrieveHLSLivePlaylist(streamUrl) + }) +} + +function retrieveHLSLivePlaylist(url: string) { + return new Promise((resolveLoad, rejectLoad) => + LoadUrl(url, { + method: "GET", + headers: {}, + timeout: 10000, + onLoad: (_, responseText) => resolveLoad(responseText), + onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS live playlist")), + }) + ) + .then((text) => { + if (!text || typeof text !== "string") { + throw new TypeError("Unable to retrieve HLS live playlist") + } + + return ManifestParser.parse({ body: text, type: TransferFormat.HLS }) + }) + .then((time) => ({ time, transferFormat: TransferFormat.HLS })) +} + +function getStreamUrl(data: string) { + const match = /#EXT-X-STREAM-INF:.*[\n\r]+(.*)[\n\r]?/.exec(data) + + return match ? match[1] : null +} + +export default { + load: (mediaUrl: string): Promise<{ transferFormat: TransferFormat; time: TimeInfo }> => { + if (/\.mpd(\?.*)?$/.test(mediaUrl)) { + return retrieveDashManifest(mediaUrl) + } + + if (/\.m3u8(\?.*)?$/.test(mediaUrl)) { + return retrieveHLSManifest(mediaUrl) + } + + if (/\.mp4(\?.*)?$/.test(mediaUrl)) { + return Promise.resolve({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + }, + transferFormat: TransferFormat.PLAIN, + }) + } + + return Promise.reject(new Error("Invalid media url")) + }, +} diff --git a/src/manifest/stubData/dashmanifests.ts b/src/manifest/stubData/dashmanifests.ts index a3026646..e4a87b8b 100644 --- a/src/manifest/stubData/dashmanifests.ts +++ b/src/manifest/stubData/dashmanifests.ts @@ -1,7 +1,7 @@ -const DASH_MANIFEST_STRINGS = { +export const DASH_MANIFEST_STRINGS = { BAD_ATTRIBUTES: ` @@ -11,7 +11,7 @@ const DASH_MANIFEST_STRINGS = { `, - GROWING_WINDOW: ` + TIMESHIFT_PTO: ` - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + `, - SLIDING_WINDOW: ` + PTO_NO_TIMESHIFT: ` + + + + + + + + + + + + + + + + + + + + + `, + TIMESHIFT_NO_PTO: ` `, - STATIC_WINDOW: ` + STATIC_NO_PTO: ` + + + + + + + + + + + + + + + + + + + + `, + STATIC_PTO: ` - + - + - + @@ -107,17 +174,4 @@ const DashManifests = Object.fromEntries( ]) ) -function appendTimingResource(manifestEl: Document, timingResource: string) { - const timingEl = manifestEl.createElement("UTCTiming") - timingEl.setAttribute("schemeIdUri", "urn:mpeg:dash:utc:http-xsdate:2014") - timingEl.setAttribute("value", timingResource ?? "https://time.some-cdn.com/?iso") - - manifestEl.querySelector("MPD")?.append(timingEl) -} - -function setAvailabilityStartTime(manifestEl: Document, date: ConstructorParameters[0]) { - manifestEl.querySelector("MPD")?.setAttribute("availabilityStartTime", new Date(date).toISOString()) -} - export default DashManifests -export { appendTimingResource, setAvailabilityStartTime } diff --git a/src/manifest/stubData/hlsmanifests.ts b/src/manifest/stubData/hlsmanifests.ts index fea32261..91ef7be5 100644 --- a/src/manifest/stubData/hlsmanifests.ts +++ b/src/manifest/stubData/hlsmanifests.ts @@ -8,21 +8,53 @@ const HLS_MANIFESTS = { "#EXT-X-PROGRAM-DATE-TIME:invaliddatetime\n" + "#EXTINF:8, no desc\n" + "content-audio_2=96000-video=1374000-179532414.ts\n", - VALID_PROGRAM_DATETIME: + VALID_PROGRAM_DATETIME_NO_ENDLIST: "#EXTM3U\n" + - "#EXT-X-VERSION:2\n" + - "#EXT-X-MEDIA-SEQUENCE:179532414\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-INDEPENDENT-SEGMENTS\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-MEDIA-SEQUENCE:450795161\n" + + "#EXT-X-PROGRAM-DATE-TIME:2024-11-08T08:00:00.0Z\n" + + "#EXTINF:3.84\n" + + "450795161.ts\n" + + "#EXTINF:3.84\n" + + "450795162.ts\n" + + "#EXTINF:3.84\n" + + "450795163.ts\n" + + "#EXTINF:3.84\n" + + "450795164.ts\n", + VALID_PROGRAM_DATETIME_AND_ENDLIST: + "#EXTM3U\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-INDEPENDENT-SEGMENTS\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-MEDIA-SEQUENCE:450795161\n" + + "#EXT-X-PROGRAM-DATE-TIME:2024-11-08T06:00:00Z\n" + + "#EXTINF:3.84\n" + + "450793126.ts\n" + + "#EXTINF:3.84\n" + + "450793127.ts\n" + + "#EXTINF:3.84\n" + + "450793128.ts\n" + + "#EXTINF:3.84\n" + + "450793129.ts\n" + + "#EXT-X-ENDLIST\n", + NO_PROGRAM_DATETIME_ENDLIST: + "#EXTM3U\n" + + "#EXT-X-VERSION:3\n" + "#EXT-X-TARGETDURATION:8\n" + - "#USP-X-TIMESTAMP-MAP:MPEGTS=2003059584,LOCAL=2015-07-07T08:55:10Z\n" + - "#EXT-X-PROGRAM-DATE-TIME:2015-07-07T08:55:10Z\n" + - "#EXTINF:18.98, no desc\n" + - "content-audio_2=96000-video=1374000-179532414.ts\n" + - "#EXTINF:4, no desc\n" + - "content-audio_2=96000-video=1374000-179532414.ts\n" + - "#EXTINF:2.68, no desc\n" + - "content-audio_2=96000-video=1374000-179532414.ts\n" + - "#EXTINF:6.50, no desc\n" + - "content-audio_2=96000-video=1374000-179532414.ts\n", + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#USP-X-TIMESTAMP-MAP:MPEGTS=900000,LOCAL=1970-01-01T00:00:00Z\n" + + "#EXTINF:8\n" + + "segment-1.ts\n" + + "#EXTINF:7\n" + + "segment-2.ts\n" + + "#EXTINF:8\n" + + "segment-3.ts\n" + + "#EXTINF:8\n" + + "segment-4.ts\n" + + "#EXT-X-ENDLIST\n", } export default HLS_MANIFESTS diff --git a/src/mediasources.js b/src/mediasources.js deleted file mode 100644 index 0a897939..00000000 --- a/src/mediasources.js +++ /dev/null @@ -1,380 +0,0 @@ -import PlaybackUtils from "./utils/playbackutils" -import WindowTypes from "./models/windowtypes" -import Plugins from "./plugins" -import PluginEnums from "./pluginenums" -import PluginData from "./plugindata" -import DebugTool from "./debugger/debugtool" -import ManifestLoader from "./manifest/manifestloader" -import TransferFormats from "./models/transferformats" -import findSegmentTemplate from "./utils/findtemplate" - -function MediaSources() { - let mediaSources - let failedOverSources = [] - let failoverResetTokens = [] - let windowType - let liveSupport - let initialWallclockTime - let time = {} - let transferFormat - let subtitlesSources - // Default 5000 can be overridden with media.subtitlesRequestTimeout - let subtitlesRequestTimeout = 5000 - let failoverResetTimeMs = 120000 - let failoverSort - - function init(media, newServerDate, newWindowType, newLiveSupport, callbacks) { - if (!media.urls?.length) { - throw new Error("Media Sources urls are undefined") - } - - if (callbacks?.onSuccess == null || callbacks?.onError == null) { - throw new Error("Media Sources callbacks are undefined") - } - - if (media.subtitlesRequestTimeout) { - subtitlesRequestTimeout = media.subtitlesRequestTimeout - } - - if (media.playerSettings?.failoverResetTime) { - failoverResetTimeMs = media.playerSettings.failoverResetTime - } - - if (media.playerSettings?.failoverSort) { - failoverSort = media.playerSettings.failoverSort - } - - windowType = newWindowType - liveSupport = newLiveSupport - initialWallclockTime = newServerDate - mediaSources = media.urls ? PlaybackUtils.cloneArray(media.urls) : [] - subtitlesSources = media.captions ? PlaybackUtils.cloneArray(media.captions) : [] - - updateDebugOutput() - - if (!needToGetManifest(windowType, liveSupport)) { - callbacks.onSuccess() - return - } - - loadManifest(callbacks, { initialWallclockTime, windowType }) - } - - function failover(onFailoverSuccess, onFailoverError, failoverParams) { - if (shouldFailover(failoverParams)) { - emitCdnFailover(failoverParams) - updateCdns(failoverParams.serviceLocation) - updateDebugOutput() - - if (needToGetManifest(windowType, liveSupport)) { - loadManifest({ onSuccess: onFailoverSuccess, onError: onFailoverError }, { windowType }) - } else { - onFailoverSuccess() - } - } else { - onFailoverError() - } - } - - function failoverSubtitles(postFailoverAction, failoverErrorAction, { statusCode, ...rest } = {}) { - if (subtitlesSources.length > 1) { - Plugins.interface.onSubtitlesLoadError({ - status: statusCode, - severity: PluginEnums.STATUS.FAILOVER, - cdn: getCurrentSubtitlesCdn(), - subtitlesSources: subtitlesSources.length, - ...rest, - }) - subtitlesSources.shift() - updateDebugOutput() - if (postFailoverAction) { - postFailoverAction() - } - } else { - Plugins.interface.onSubtitlesLoadError({ - status: statusCode, - severity: PluginEnums.STATUS.FATAL, - cdn: getCurrentSubtitlesCdn(), - subtitlesSources: subtitlesSources.length, - ...rest, - }) - if (failoverErrorAction) { - failoverErrorAction() - } - } - } - - function shouldFailover(failoverParams) { - if (isFirstManifest(failoverParams.serviceLocation)) { - return false - } - const aboutToEnd = failoverParams.duration && failoverParams.currentTime > failoverParams.duration - 5 - const shouldStaticFailover = windowType === WindowTypes.STATIC && !aboutToEnd - const shouldLiveFailover = windowType !== WindowTypes.STATIC - return ( - isFailoverInfoValid(failoverParams) && hasSourcesToFailoverTo() && (shouldStaticFailover || shouldLiveFailover) - ) - } - - function stripQueryParamsAndHash(url) { - return typeof url === "string" ? url.split(/[#?]/)[0] : url - } - - // we don't want to failover on the first playback - // the serviceLocation is set to our first cdn url - // see manifest modifier - generateBaseUrls - function isFirstManifest(serviceLocation) { - return doHostsMatch(serviceLocation, getCurrentUrl()) - } - - function doHostsMatch(firstUrl, secondUrl) { - // Matches anything between *:// and / or the end of the line - const hostRegex = /\w+?:\/\/(.*?)(?:\/|$)/ - - const serviceLocNoQueryHash = stripQueryParamsAndHash(firstUrl) - const currUrlNoQueryHash = stripQueryParamsAndHash(secondUrl) - - const serviceLocationHost = hostRegex.exec(serviceLocNoQueryHash) - const currentUrlHost = hostRegex.exec(currUrlNoQueryHash) - - return serviceLocationHost && currentUrlHost - ? serviceLocationHost[1] === currentUrlHost[1] - : serviceLocNoQueryHash === currUrlNoQueryHash - } - - function isFailoverInfoValid(failoverParams) { - const infoValid = typeof failoverParams === "object" && typeof failoverParams.isBufferingTimeoutError === "boolean" - - if (!infoValid) { - DebugTool.error("failoverInfo is not valid") - } - - return infoValid - } - - function failoverResetTime() { - return failoverResetTimeMs - } - - function hasSegmentedSubtitles() { - const url = getCurrentSubtitlesUrl() - - if (typeof url !== "string" || url === "") { - return false - } - - return findSegmentTemplate(url) != null - } - - function needToGetManifest(windowType, liveSupport) { - const isStartTimeAccurate = { - restartable: true, - seekable: true, - playable: false, - none: false, - } - - const hasManifestBeenLoaded = transferFormat !== undefined - - return ( - (!hasManifestBeenLoaded || transferFormat === TransferFormats.HLS) && - (windowType !== WindowTypes.STATIC || hasSegmentedSubtitles()) && - isStartTimeAccurate[liveSupport] - ) - } - - function refresh(onSuccess, onError) { - loadManifest({ onSuccess, onError }, { windowType }) - } - - // [tag:ServerDate] - function loadManifest(callbacks, { initialWallclockTime, windowType } = {}) { - return ManifestLoader.load(getCurrentUrl(), { initialWallclockTime, windowType }) - .then(({ time: newTime, transferFormat: newTransferFormat } = {}) => { - time = newTime - transferFormat = newTransferFormat - - logManifestLoaded(transferFormat, time) - callbacks.onSuccess() - }) - .catch((error) => { - DebugTool.error(`Failed to load manifest: ${error?.message ?? "cause n/a"}`) - - failover( - () => callbacks.onSuccess(), - () => callbacks.onError({ error: "manifest" }), - { - isBufferingTimeoutError: false, - code: PluginEnums.ERROR_CODES.MANIFEST_LOAD, - message: PluginEnums.ERROR_MESSAGES.MANIFEST, - } - ) - }) - } - - function getCurrentUrl() { - if (mediaSources.length > 0) { - return mediaSources[0].url.toString() - } - - return "" - } - - function getCurrentSubtitlesUrl() { - if (subtitlesSources.length > 0) { - return subtitlesSources[0].url.toString() - } - - return "" - } - - function getCurrentSubtitlesSegmentLength() { - if (subtitlesSources.length > 0) { - return subtitlesSources[0].segmentLength - } - } - - function getSubtitlesRequestTimeout() { - return subtitlesRequestTimeout - } - - function getCurrentSubtitlesCdn() { - if (subtitlesSources.length > 0) { - return subtitlesSources[0].cdn - } - } - - function availableUrls() { - return mediaSources.map((mediaSource) => mediaSource.url) - } - - function generateTime() { - return time - } - - function updateFailedOverSources(mediaSource) { - failedOverSources.push(mediaSource) - - if (failoverSort) { - mediaSources = failoverSort(mediaSources) - } - - const failoverResetToken = setTimeout(() => { - if (mediaSources?.length > 0 && failedOverSources?.length > 0) { - DebugTool.info(`${mediaSource.cdn} has been added back in to available CDNs`) - mediaSources.push(failedOverSources.shift()) - updateDebugOutput() - } - }, failoverResetTimeMs) - - failoverResetTokens.push(failoverResetToken) - } - - function updateCdns(serviceLocation) { - if (hasSourcesToFailoverTo()) { - updateFailedOverSources(mediaSources.shift()) - moveMediaSourceToFront(serviceLocation) - } - } - - function moveMediaSourceToFront(serviceLocation) { - if (serviceLocation) { - let serviceLocationIdx = mediaSources - .map((mediaSource) => stripQueryParamsAndHash(mediaSource.url)) - .indexOf(stripQueryParamsAndHash(serviceLocation)) - - if (serviceLocationIdx < 0) serviceLocationIdx = 0 - - mediaSources.unshift(mediaSources.splice(serviceLocationIdx, 1)[0]) - } - } - - function hasSourcesToFailoverTo() { - return mediaSources.length > 1 - } - - function emitCdnFailover(failoverInfo) { - const evt = new PluginData({ - status: PluginEnums.STATUS.FAILOVER, - stateType: PluginEnums.TYPE.ERROR, - isBufferingTimeoutError: failoverInfo.isBufferingTimeoutError, - cdn: mediaSources[0].cdn, - newCdn: mediaSources[1].cdn, - code: failoverInfo.code, - message: failoverInfo.message, - }) - Plugins.interface.onErrorHandled(evt) - } - - function availableCdns() { - return mediaSources.map((mediaSource) => mediaSource.cdn) - } - - function availableSubtitlesCdns() { - return subtitlesSources.map((subtitleSource) => subtitleSource.cdn) - } - - function logManifestLoaded(transferFormat, time) { - let logMessage = `Loaded ${transferFormat} manifest.` - - const { presentationTimeOffsetSeconds, timeCorrectionSeconds, windowStartTime, windowEndTime } = time - - if (!isNaN(windowStartTime)) { - logMessage += ` Window start time [ms]: ${windowStartTime}.` - } - - if (!isNaN(windowEndTime)) { - logMessage += ` Window end time [ms]: ${windowEndTime}.` - } - - if (!isNaN(timeCorrectionSeconds)) { - logMessage += ` Correction [s]: ${timeCorrectionSeconds}.` - } - - if (!isNaN(presentationTimeOffsetSeconds)) { - logMessage += ` Offset [s]: ${presentationTimeOffsetSeconds}.` - } - - DebugTool.info(logMessage) - } - - function updateDebugOutput() { - DebugTool.dynamicMetric("cdns-available", availableCdns()) - DebugTool.dynamicMetric("current-url", stripQueryParamsAndHash(getCurrentUrl())) - - DebugTool.dynamicMetric("subtitle-cdns-available", availableSubtitlesCdns()) - DebugTool.dynamicMetric("subtitle-current-url", stripQueryParamsAndHash(getCurrentSubtitlesUrl())) - } - - function tearDown() { - failoverResetTokens.forEach((token) => clearTimeout(token)) - - windowType = undefined - liveSupport = undefined - initialWallclockTime = undefined - time = {} - transferFormat = undefined - mediaSources = [] - failedOverSources = [] - failoverResetTokens = [] - subtitlesSources = [] - } - - return { - init, - failover, - failoverSubtitles, - refresh, - currentSource: getCurrentUrl, - currentSubtitlesSource: getCurrentSubtitlesUrl, - currentSubtitlesSegmentLength: getCurrentSubtitlesSegmentLength, - currentSubtitlesCdn: getCurrentSubtitlesCdn, - subtitlesRequestTimeout: getSubtitlesRequestTimeout, - availableSources: availableUrls, - failoverResetTime, - time: generateTime, - tearDown, - } -} - -export default MediaSources diff --git a/src/mediasources.test.js b/src/mediasources.test.js deleted file mode 100644 index 28c32707..00000000 --- a/src/mediasources.test.js +++ /dev/null @@ -1,969 +0,0 @@ -import WindowTypes from "./models/windowtypes" -import LiveSupport from "./models/livesupport" -import TransferFormats from "./models/transferformats" -import PluginEnums from "./pluginenums" -import MediaSources from "./mediasources" -import Plugins from "./plugins" -import ManifestLoader from "./manifest/manifestloader" -import getError from "./testutils/geterror" - -jest.mock("./manifest/manifestloader", () => ({ load: jest.fn(() => Promise.resolve({ time: {} })) })) - -jest.mock("./plugins", () => ({ - interface: { - onErrorCleared: jest.fn(), - onBuffering: jest.fn(), - onBufferingCleared: jest.fn(), - onError: jest.fn(), - onFatalError: jest.fn(), - onErrorHandled: jest.fn(), - onSubtitlesLoadError: jest.fn(), - }, -})) - -describe("Media Sources", () => { - const FAILOVER_RESET_TIMEOUT = 60000 - const SEGMENT_LENGTH = 3.84 - - const initMediaSources = (media, { initialWallclockTime, windowType, liveSupport } = {}) => { - const mediaSources = MediaSources() - - return new Promise((resolveInit, rejectInit) => - mediaSources.init(media, initialWallclockTime, windowType, liveSupport, { - onSuccess: () => resolveInit(mediaSources), - onError: (error) => rejectInit(error), - }) - ) - } - - let testMedia - - beforeAll(() => { - jest.useFakeTimers() - }) - - beforeEach(() => { - jest.clearAllMocks() - jest.clearAllTimers() - - testMedia = { - urls: [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }], - captions: [{ url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }], - playerSettings: { - failoverResetTime: FAILOVER_RESET_TIMEOUT, - }, - } - }) - - describe("init", () => { - it("throws an error when initialised with no sources", () => { - testMedia.urls = [] - - const mediaSources = MediaSources() - - expect(() => - mediaSources.init(testMedia, Date.now(), WindowTypes.STATIC, LiveSupport.SEEKABLE, { - onSuccess: jest.fn(), - onError: jest.fn(), - }) - ).toThrow(new Error("Media Sources urls are undefined")) - }) - - it("clones the urls", async () => { - testMedia.urls = [{ url: "mock://url.test/", cdn: "mock://cdn.test/" }] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - testMedia.urls[0].url = "mock://url.clone/" - - expect(mediaSources.currentSource()).toBe("mock://url.test/") - }) - - it.each([ - ["both callbacks", {}], - ["success callback", { onError: jest.fn() }], - ["failure callback", { onSuccess: jest.fn() }], - ])("throws an error when %s are undefined", (_, callbacks) => { - const mediaSources = MediaSources() - - expect(() => - mediaSources.init(testMedia, new Date(), WindowTypes.STATIC, LiveSupport.SEEKABLE, callbacks) - ).toThrow("Media Sources callbacks are undefined") - }) - - it.each([WindowTypes.GROWING, WindowTypes.SLIDING])( - "passes the '%s' window type to the manifest loader", - async (windowType) => { - await initMediaSources(testMedia, { - windowType, - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(ManifestLoader.load).toHaveBeenCalledWith("http://source1.com/", expect.objectContaining({ windowType })) - } - ) - - it("calls onSuccess callback immediately for STATIC window content", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.time()).toEqual({}) - }) - - it("calls onSuccess callback immediately for LIVE content on a PLAYABLE device", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.PLAYABLE, - }) - - expect(mediaSources.time()).toEqual({}) - }) - - it("calls onSuccess callback when manifest loader returns on success for SLIDING window content", async () => { - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.time()).toEqual({ windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }) - }) - - it("fetch presentation time offset from the manifest for on-demand media with segmented subtitles", async () => { - testMedia.captions = [ - { url: "mock://some.media/captions/$segment$.m4s", cdn: "foo", segmentLength: SEGMENT_LENGTH }, - ] - - ManifestLoader.load.mockResolvedValueOnce({ - time: { presentationTimeOffsetSeconds: 54 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.time()).toEqual({ presentationTimeOffsetSeconds: 54 }) - }) - - it("calls onError when manifest fails to load for media with segmented subtitles", async () => { - testMedia.captions = [ - { url: "mock://some.media/captions/$segment$.m4s", cdn: "foo", segmentLength: SEGMENT_LENGTH }, - ] - - ManifestLoader.load.mockRejectedValueOnce() - - const error = await getError(async () => - initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - ) - - expect(error).toEqual({ error: "manifest" }) - }) - - it("fails over to next source when the first source fails to load", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - ManifestLoader.load.mockRejectedValueOnce() - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.time()).toEqual({ windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }) - }) - - it("calls onError callback when manifest loader fails and there are insufficent sources to failover to", async () => { - ManifestLoader.load.mockRejectedValueOnce() - - const error = await getError(async () => - initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - ) - - expect(error).toEqual({ error: "manifest" }) - }) - - it("sets time data correcly when manifest loader successfully returns", async () => { - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.time()).toEqual({ windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }) - }) - - it("overrides the subtitlesRequestTimeout when set in media object", async () => { - testMedia.subtitlesRequestTimeout = 60000 - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.subtitlesRequestTimeout()).toBe(60000) - }) - }) - - describe("failover", () => { - it("should load the manifest from the next url if manifest load is required", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - // HLS manifests must be reloaded on failover to fetch accurate start time - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: NaN }, - transferFormat: TransferFormats.HLS, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: true }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(2) - expect(ManifestLoader.load).toHaveBeenNthCalledWith(2, "http://source2.com/", expect.any(Object)) - }) - - // [tag:ServerDate] - it("does not provide initial wallclock time to the loader", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - // HLS manifests must be reloaded on failover to fetch accurate start time - ManifestLoader.load.mockResolvedValueOnce({ - time: {}, - transferFormat: TransferFormats.HLS, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: true }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(2) - expect(ManifestLoader.load).toHaveBeenNthCalledWith( - 2, - "http://source2.com/", - expect.not.objectContaining({ initialWallclockTime: expect.anything() }) - ) - }) - - it("should fire onErrorHandled plugin with correct error code and message when failing to load manifest", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - ManifestLoader.load.mockRejectedValueOnce() - - await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.SLIDING, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(Plugins.interface.onErrorHandled).toHaveBeenCalledWith({ - status: PluginEnums.STATUS.FAILOVER, - stateType: PluginEnums.TYPE.ERROR, - isBufferingTimeoutError: false, - cdn: "http://supplier1.com/", - newCdn: "http://supplier2.com/", - isInitialPlay: undefined, - timeStamp: expect.any(Object), - code: PluginEnums.ERROR_CODES.MANIFEST_LOAD, - message: PluginEnums.ERROR_MESSAGES.MANIFEST, - }) - }) - - it("When there are sources to failover to, it calls the post failover callback", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { isBufferingTimeoutError: true }) - - expect(handleFailoverSuccess).toHaveBeenCalled() - expect(handleFailoverError).not.toHaveBeenCalled() - }) - - it("When there are no more sources to failover to, it calls failure action callback", async () => { - testMedia.urls = [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { isBufferingTimeoutError: true }) - - expect(handleFailoverSuccess).not.toHaveBeenCalled() - expect(handleFailoverError).toHaveBeenCalledWith() - }) - - it("When there are sources to failover to, it emits correct plugin event", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { - isBufferingTimeoutError: true, - code: 0, - message: "unknown", - }) - - expect(Plugins.interface.onErrorHandled).toHaveBeenCalledWith({ - status: PluginEnums.STATUS.FAILOVER, - stateType: PluginEnums.TYPE.ERROR, - isBufferingTimeoutError: true, - cdn: "http://supplier1.com/", - newCdn: "http://supplier2.com/", - isInitialPlay: undefined, - timeStamp: expect.any(Object), - code: 0, - message: "unknown", - }) - }) - - it("Plugin event not emitted when there are no sources to failover to", async () => { - testMedia.urls = [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { isBufferingTimeoutError: true }) - - expect(Plugins.interface.onErrorHandled).not.toHaveBeenCalled() - }) - - it("moves the specified service location to the top of the list", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - { url: "http://source3.com/", cdn: "http://supplier3.com/" }, - { url: "http://source4.com/", cdn: "http://supplier4.com/" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - const serviceLocation = "http://source3.com/?key=value#hash" - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { - serviceLocation, - isBufferingTimeoutError: true, - }) - - expect(mediaSources.currentSource()).toBe("http://source3.com/") - }) - - it("selects the next CDN when the service location is not in the CDN list", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://supplier1.com/" }, - { url: "http://source2.com/", cdn: "http://supplier2.com/" }, - { url: "http://source3.com/", cdn: "http://supplier3.com/" }, - { url: "http://source4.com/", cdn: "http://supplier4.com/" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleFailoverSuccess = jest.fn() - const handleFailoverError = jest.fn() - - mediaSources.failover(handleFailoverSuccess, handleFailoverError, { - isBufferingTimeoutError: true, - serviceLocation: "http://sourceInfinity.com/?key=value#hash", - }) - - expect(mediaSources.currentSource()).toBe("http://source2.com/") - }) - }) - - describe("isFirstManifest", () => { - it("does not failover if service location is identical to current source cdn besides path", async () => { - testMedia.urls = [ - { url: "http://source1.com/path/to/thing.extension", cdn: "http://cdn1.com" }, - { url: "http://source2.com", cdn: "http://cdn2.com" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSource()).toBe("http://source1.com/path/to/thing.extension") - - mediaSources.failover(jest.fn(), jest.fn(), { - isBufferingTimeoutError: false, - serviceLocation: "http://source1.com/path/to/different/thing.extension", - }) - - expect(mediaSources.currentSource()).toBe("http://source1.com/path/to/thing.extension") - }) - - it("does not failover if service location is identical to current source cdn besides hash and query", async () => { - testMedia.urls = [ - { url: "http://source1.com", cdn: "http://cdn1.com" }, - { url: "http://source2.com", cdn: "http://cdn2.com" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSource()).toBe("http://source1.com") - - mediaSources.failover(jest.fn(), jest.fn(), { - isBufferingTimeoutError: false, - serviceLocation: "http://source1.com?key=value#hash", - }) - - expect(mediaSources.currentSource()).toBe("http://source1.com") - }) - }) - - describe("currentSource", () => { - beforeEach(() => { - testMedia.urls = [ - { url: "http://source1.com", cdn: "http://cdn1.com" }, - { url: "http://source2.com", cdn: "http://cdn2.com" }, - ] - }) - - it("returns the first media source url", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSource()).toBe("http://source1.com") - }) - - it("returns the second media source following a failover", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: true }) - - expect(mediaSources.currentSource()).toBe("http://source2.com") - }) - }) - - describe("currentSubtitlesSource", () => { - beforeEach(() => { - testMedia.captions = [ - { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, - { url: "http://subtitlessource2.com/", cdn: "http://supplier2.com/", segmentLength: SEGMENT_LENGTH }, - ] - }) - - it("returns the first subtitles source url", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSubtitlesSource()).toBe("http://subtitlessource1.com/") - }) - - it("returns the second subtitle source following a failover", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failoverSubtitles() - - expect(mediaSources.currentSubtitlesSource()).toBe("http://subtitlessource2.com/") - }) - }) - - describe("currentSubtitlesSegmentLength", () => { - it("returns the first subtitles segment length", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSubtitlesSegmentLength()).toBe(SEGMENT_LENGTH) - }) - }) - - describe("currentSubtitlesCdn", () => { - it("returns the first subtitles cdn", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.currentSubtitlesCdn()).toBe("http://supplier1.com/") - }) - }) - - describe("failoverSubtitles", () => { - it("When there are subtitles sources to failover to, it calls the post failover callback", async () => { - testMedia.captions = [ - { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, - { url: "http://subtitlessource2.com/", cdn: "http://supplier2.com/", segmentLength: SEGMENT_LENGTH }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleSubtitleFailoverSuccess = jest.fn() - const handleSubtitleFailoverError = jest.fn() - - mediaSources.failoverSubtitles(handleSubtitleFailoverSuccess, handleSubtitleFailoverError) - - expect(handleSubtitleFailoverSuccess).toHaveBeenCalledTimes(1) - expect(handleSubtitleFailoverError).not.toHaveBeenCalled() - }) - - it("When there are no more subtitles sources to failover to, it calls failure action callback", async () => { - testMedia.captions = [ - { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - const handleSubtitleFailoverSuccess = jest.fn() - const handleSubtitleFailoverError = jest.fn() - - mediaSources.failoverSubtitles(handleSubtitleFailoverSuccess, handleSubtitleFailoverError) - - expect(handleSubtitleFailoverSuccess).not.toHaveBeenCalled() - expect(handleSubtitleFailoverError).toHaveBeenCalledTimes(1) - }) - - it("fires onSubtitlesLoadError plugin with correct parameters when there are sources available to failover to", async () => { - testMedia.captions = [ - { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, - { url: "http://subtitlessource2.com/", cdn: "http://supplier2.com/", segmentLength: SEGMENT_LENGTH }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failoverSubtitles(jest.fn(), jest.fn(), { statusCode: 404 }) - - expect(Plugins.interface.onSubtitlesLoadError).toHaveBeenCalledWith({ - status: 404, - severity: PluginEnums.STATUS.FAILOVER, - cdn: "http://supplier1.com/", - subtitlesSources: 2, - }) - }) - - it("fires onSubtitlesLoadError plugin with correct parameters when there are no sources available to failover to", async () => { - testMedia.captions = [ - { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - mediaSources.failoverSubtitles(jest.fn(), jest.fn(), { statusCode: 404 }) - - expect(Plugins.interface.onSubtitlesLoadError).toHaveBeenCalledWith({ - status: 404, - severity: PluginEnums.STATUS.FATAL, - cdn: "http://supplier1.com/", - subtitlesSources: 1, - }) - }) - }) - - describe("availableSources", () => { - it("returns an array of media source urls", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(mediaSources.availableSources()).toEqual(["http://source1.com/", "http://source2.com/"]) - }) - }) - - describe("should Failover", () => { - let mediaSources - - describe("when window type is STATIC", () => { - beforeEach(async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - - mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - windowType: WindowTypes.STATIC, - liveSupport: LiveSupport.SEEKABLE, - }) - }) - - it("should failover if current time is greater than 5 seconds from duration", () => { - const onSuccessStub = jest.fn() - const onErrorStub = jest.fn() - - const failoverParams = { - duration: 100, - currentTime: 94, - isBufferingTimeoutError: false, - } - - mediaSources.failover(onSuccessStub, onErrorStub, failoverParams) - - expect(onSuccessStub).toHaveBeenCalledTimes(1) - }) - - it("should not failover if current time is within 5 seconds of duration", () => { - const onSuccessStub = jest.fn() - const onErrorStub = jest.fn() - - const failoverParams = { - duration: 100, - currentTime: 96, - isBufferingTimeoutError: false, - } - - mediaSources.failover(onSuccessStub, onErrorStub, failoverParams) - - expect(onErrorStub).toHaveBeenCalledTimes(1) - }) - - it("should failover if playback has not yet started", () => { - const onSuccessStub = jest.fn() - const onErrorStub = jest.fn() - - const failoverParams = { - duration: 0, - currentTime: undefined, - isBufferingTimeoutError: false, - } - - mediaSources.failover(onSuccessStub, onErrorStub, failoverParams) - - expect(onSuccessStub).toHaveBeenCalledTimes(1) - }) - }) - - describe("when window type is not STATIC", () => { - beforeEach(() => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - }) - - describe("and transfer format is DASH", () => { - it.each([WindowTypes.GROWING, WindowTypes.SLIDING])( - "should not reload the manifest for window type: '%s'", - async (windowType) => { - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - windowType, - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(1) - - mediaSources.failover(jest.fn(), jest.fn(), { - isBufferingTimeoutError: false, - }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(1) - } - ) - }) - - describe("and transfer format is HLS", () => { - it.each([WindowTypes.GROWING, WindowTypes.SLIDING])( - "should reload the manifest for window type '%s'", - async (windowType) => { - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: NaN }, - transferFormat: TransferFormats.HLS, - }) - - const mediaSources = await initMediaSources(testMedia, { - windowType, - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(1) - - mediaSources.failover(jest.fn(), jest.fn(), { - isBufferingTimeoutError: false, - }) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(2) - } - ) - }) - }) - }) - - describe("refresh", () => { - it("updates the mediasources time data", async () => { - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }, - transferFormat: TransferFormats.DASH, - }) - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - const existingSource = mediaSources.currentSource() - - // test the current time hasn't changed - expect(mediaSources.time()).toEqual({ windowStartTime: 1000, windowEndTime: 10000, timeCorrectionSeconds: 1 }) - - ManifestLoader.load.mockResolvedValueOnce({ - time: { windowStartTime: 6000, windowEndTime: 16000, timeCorrectionSeconds: 6 }, - transferFormat: TransferFormats.DASH, - }) - - await new Promise((resolve, reject) => - mediaSources.refresh( - () => resolve(), - () => reject() - ) - ) - - expect(mediaSources.currentSource()).toEqual(existingSource) - }) - - // [tag:ServerDate] - it("does not pass initial wall-clock time to the manifest loader", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - await new Promise((resolve, reject) => - mediaSources.refresh( - () => resolve(), - () => reject() - ) - ) - - expect(ManifestLoader.load).toHaveBeenCalledTimes(2) - expect(ManifestLoader.load).toHaveBeenNthCalledWith( - 2, - "http://source1.com/", - expect.not.objectContaining({ initialWallclockTime: expect.anything() }) - ) - }) - }) - - describe("failoverTimeout", () => { - beforeEach(() => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - }) - - it("should add the cdn that failed back in to available cdns after a timeout", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - const expectedCdns = [...mediaSources.availableSources()].reverse() - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: false }) - - jest.advanceTimersByTime(FAILOVER_RESET_TIMEOUT) - - expect(mediaSources.availableSources()).toEqual(expectedCdns) - }) - - it("should not contain the cdn that failed before the timeout has occured", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: false }) - - expect(mediaSources.availableSources()).not.toContain("http://cdn1.com") - }) - - it("should not preserve timers over teardown boundaries", async () => { - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: false }) - - mediaSources.tearDown() - - jest.advanceTimersByTime(FAILOVER_RESET_TIMEOUT) - - expect(mediaSources.availableSources()).toEqual([]) - }) - }) - - describe("failoverSort", () => { - it("called when provided as an override in playerSettings", async () => { - testMedia.urls = [ - { url: "http://source1.com/", cdn: "http://cdn1.com" }, - { url: "http://source2.com/", cdn: "http://cdn2.com" }, - ] - - const mockFailoverSort = jest.fn().mockReturnValue([...testMedia.urls].reverse()) - - testMedia.playerSettings = { - failoverSort: mockFailoverSort, - } - - const mediaSources = await initMediaSources(testMedia, { - initialWallclockTime: Date.now(), - liveSupport: LiveSupport.SEEKABLE, - windowType: WindowTypes.SLIDING, - }) - - mediaSources.failover(jest.fn(), jest.fn(), { isBufferingTimeoutError: true }) - - expect(mockFailoverSort).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/src/mediasources.test.ts b/src/mediasources.test.ts new file mode 100644 index 00000000..75d8e364 --- /dev/null +++ b/src/mediasources.test.ts @@ -0,0 +1,783 @@ +import MediaSources from "./mediasources" +import Plugins from "./plugins" +import PluginEnums from "./pluginenums" +import { MediaDescriptor, Connection } from "./types" +import SourceLoader from "./manifest/sourceloader" +import { ManifestType } from "./models/manifesttypes" +import { TransferFormat } from "./models/transferformats" +import getError from "./testutils/geterror" + +jest.mock("./manifest/sourceloader", () => ({ + default: { + load: jest.fn(() => + Promise.resolve({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + ), + }, +})) + +jest.mock("./plugins", () => ({ + default: { + interface: { + onErrorCleared: jest.fn(), + onBuffering: jest.fn(), + onBufferingCleared: jest.fn(), + onError: jest.fn(), + onFatalError: jest.fn(), + onErrorHandled: jest.fn(), + onSubtitlesLoadError: jest.fn(), + }, + }, +})) + +const FAILOVER_RESET_TIMEOUT_MS = 20000 +const SEGMENT_LENGTH = 3.84 + +function createMediaDescriptor(): MediaDescriptor { + return { + kind: "video", + mimeType: "video/mp4", + type: "application/dash+xml", + urls: [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }], + captions: [{ url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }], + playerSettings: {}, + } +} + +describe("Media Sources", () => { + let testMedia: MediaDescriptor + + beforeAll(() => { + jest.useFakeTimers() + }) + + beforeEach(() => { + jest.clearAllMocks() + jest.clearAllTimers() + + testMedia = createMediaDescriptor() + }) + + describe("init", () => { + it("throws an error when initialised with no sources", async () => { + testMedia.urls = [] + + const mediaSources = MediaSources() + + const error = await getError(() => mediaSources.init(testMedia)) + + expect(error).toEqual(new Error("Media Sources urls are undefined")) + }) + + it("clones the urls", async () => { + testMedia.urls = [{ url: "mock://url.test/", cdn: "mock://cdn.test/" }] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + testMedia.urls[0].url = "mock://url.clone/" + + expect(mediaSources.currentSource()).toBe("mock://url.test/") + }) + + it("resolves when manifest has been loaded", async () => { + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1000, + availabilityStartTimeInMilliseconds: 10000, + timeShiftBufferDepthInMilliseconds: 1000, + }, + transferFormat: TransferFormat.HLS, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + expect(mediaSources.time()).toEqual({ + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1000, + availabilityStartTimeInMilliseconds: 10000, + timeShiftBufferDepthInMilliseconds: 1000, + }) + + expect(mediaSources.transferFormat()).toEqual(TransferFormat.HLS) + expect(mediaSources.currentSource()).toBe("http://source1.com/") + }) + + it("resolves when first manifest fails to load but second load succeeds", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + ] + + jest.mocked(SourceLoader.load).mockRejectedValueOnce(new Error("A network error occured")) + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + expect(mediaSources.time()).toEqual({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) + + expect(mediaSources.transferFormat()).toEqual(TransferFormat.DASH) + expect(mediaSources.currentSource()).toBe("http://source2.com/") + }) + + it("rejects when all available manifest sources fail to load", async () => { + jest.mocked(SourceLoader.load).mockRejectedValueOnce(new Error("A network error occured")) + + const mediaSources = MediaSources() + + const error = await getError(async () => mediaSources.init(testMedia)) + + expect(mediaSources.currentSource()).toBe("http://source1.com/") + expect(error.name).toBe("ManifestLoadError") + }) + + it("overrides the subtitlesRequestTimeout when set in media object", async () => { + testMedia.subtitlesRequestTimeout = 60000 + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.subtitlesRequestTimeout()).toBe(60000) + }) + + it("overrides the failoverResetTime when set on the player settings", async () => { + testMedia.playerSettings = { + failoverResetTime: 60000, + } + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.failoverResetTime()).toBe(60000) + }) + + it("overrides the failoverSort function when set on the player settings", async () => { + const mockFailoverFunction = (sources: Connection[]) => [...sources].reverse() + + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + { url: "http://source3.com/", cdn: "http://supplier3.com/" }, + ] + + testMedia.playerSettings = { + failoverSort: mockFailoverFunction, + } + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: 0, + message: "mock failover", + }) + + expect(mediaSources.availableSources()).toEqual(["http://source3.com/", "http://source2.com/"]) + }) + + it("should fire onErrorHandled plugin with correct error code and message when failing to load manifest", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + ] + + jest.mocked(SourceLoader.load).mockRejectedValueOnce(new Error("A network error occured")) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + expect(jest.mocked(Plugins.interface).onErrorHandled).toHaveBeenCalledWith({ + status: PluginEnums.STATUS.FAILOVER, + stateType: PluginEnums.TYPE.ERROR, + isBufferingTimeoutError: false, + cdn: "http://supplier1.com/", + newCdn: "http://supplier2.com/", + isInitialPlay: undefined, + timeStamp: expect.any(Date), + code: PluginEnums.ERROR_CODES.MANIFEST_LOAD, + message: PluginEnums.ERROR_MESSAGES.MANIFEST, + }) + }) + }) + + describe("failover", () => { + it.each([ + [TransferFormat.PLAIN, ManifestType.STATIC], + [TransferFormat.DASH, ManifestType.STATIC], + [TransferFormat.DASH, ManifestType.DYNAMIC], + [TransferFormat.HLS, ManifestType.STATIC], + ])("does not load the manifest from the next url for a %s %s stream", async (transferFormat, manifestType) => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + ] + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + transferFormat, + time: { + manifestType, + presentationTimeOffsetInMilliseconds: 1731406718000, + availabilityStartTimeInMilliseconds: 1731406758000, + timeShiftBufferDepthInMilliseconds: 72000000, + }, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + await mediaSources.failover({ isBufferingTimeoutError: true, code: 0, message: "A mocked failover reason" }) + + expect(SourceLoader.load).toHaveBeenCalledTimes(1) + expect(SourceLoader.load).toHaveBeenNthCalledWith(1, "http://source1.com/") + }) + + it("loads the manifest from the next url for a dynamic HLS stream", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + ] + + // HLS manifests must be reloaded on failover to fetch accurate start time + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.DYNAMIC, + presentationTimeOffsetInMilliseconds: 1731406718000, + availabilityStartTimeInMilliseconds: 1731406718000, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.HLS, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + await mediaSources.failover({ isBufferingTimeoutError: true, code: 0, message: "A mocked failover reason" }) + + expect(SourceLoader.load).toHaveBeenCalledTimes(2) + expect(SourceLoader.load).toHaveBeenNthCalledWith(2, "http://source2.com/") + }) + + it("should fire onErrorHandled plugin with correct error code and message when there are sources to failover to", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + ] + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + }) + + expect(jest.mocked(Plugins.interface).onErrorHandled).toHaveBeenCalledWith({ + status: PluginEnums.STATUS.FAILOVER, + stateType: PluginEnums.TYPE.ERROR, + isBufferingTimeoutError: true, + cdn: "http://supplier1.com/", + newCdn: "http://supplier2.com/", + isInitialPlay: undefined, + timeStamp: expect.any(Date), + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + }) + + expect(jest.mocked(Plugins.interface).onErrorHandled).toHaveBeenCalledTimes(1) + }) + + it("should not fire a plugin event when there are no sources to failover to", async () => { + testMedia.urls = [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }] + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + await getError(async () => + mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + }) + ) + + expect(Plugins.interface.onErrorHandled).not.toHaveBeenCalled() + expect(mediaSources.currentSource()).toBe("http://source1.com/") + }) + + it("Rejects when the failover parameters are invalid", async () => { + testMedia.urls = [ + { url: "http://source1.com", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + const error = await getError(async () => + mediaSources.failover({ + isBufferingTimeoutError: "yes" as unknown as boolean, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + }) + ) + + expect(error).toEqual(new TypeError("Invalid failover params")) + }) + + it("Rejects when there are no more sources to failover to", async () => { + testMedia.urls = [{ url: "http://source1.com/", cdn: "http://supplier1.com/" }] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + const error = await getError(async () => + mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + }) + ) + + expect(error).toEqual(new Error("Exhaused all sources")) + }) + + it("fails over by moving the specified service location to the top of the list", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + { url: "http://source3.com/", cdn: "http://supplier3.com/" }, + { url: "http://source4.com/", cdn: "http://supplier4.com/" }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + serviceLocation: "http://source3.com/?key=value#hash", + }) + + expect(mediaSources.currentSource()).toBe("http://source3.com/") + }) + + it("fails over to the next CDN when the service location is not in the CDN list", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://supplier1.com/" }, + { url: "http://source2.com/", cdn: "http://supplier2.com/" }, + { url: "http://source3.com/", cdn: "http://supplier3.com/" }, + { url: "http://source4.com/", cdn: "http://supplier4.com/" }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + serviceLocation: "http://sourceInfinity.com/?key=value#hash", + }) + + expect(mediaSources.currentSource()).toBe("http://source2.com/") + }) + + it("does not failover if service location is identical to current source cdn besides path", async () => { + testMedia.urls = [ + { url: "http://source1.com/path/to/thing.extension", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + expect(mediaSources.currentSource()).toBe("http://source1.com/path/to/thing.extension") + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + serviceLocation: "http://source1.com/path/to/different/thing.extension", + }) + + expect(mediaSources.currentSource()).toBe("http://source1.com/path/to/thing.extension") + }) + + it("does not failover if service location is identical to current source cdn besides hash and query", async () => { + testMedia.urls = [ + { url: "http://source1.com", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + expect(mediaSources.currentSource()).toBe("http://source1.com") + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + serviceLocation: "http://source1.com?key=value#hash", + }) + + expect(mediaSources.currentSource()).toBe("http://source1.com") + }) + + it("fails over if current time is greater than 5 seconds from duration", async () => { + testMedia.urls = [ + { url: "http://source1.com", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + duration: 100, + currentTime: 94, + }) + + expect(mediaSources.currentSource()).toBe("http://source2.com") + }) + + it("Rejects if current time is within 5 seconds of duration", async () => { + testMedia.urls = [ + { url: "http://source1.com", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + + await mediaSources.init(testMedia) + + const error = await getError(async () => + mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + duration: 100, + currentTime: 96, + }) + ) + + expect(error).toEqual(new Error("Current time too close to end")) + }) + + it("fails over if playback has not yet started", async () => { + testMedia.urls = [ + { url: "http://source1.com", cdn: "http://cdn1.com" }, + { url: "http://source2.com", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ + isBufferingTimeoutError: true, + code: PluginEnums.ERROR_CODES.BUFFERING_TIMEOUT, + message: PluginEnums.ERROR_MESSAGES.BUFFERING_TIMEOUT, + duration: 0, + currentTime: undefined, + }) + + expect(mediaSources.currentSource()).toBe("http://source2.com") + }) + }) + + describe("Subtitle Sources", () => { + beforeEach(() => { + testMedia.captions = [ + { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, + { url: "http://subtitlessource2.com/", cdn: "http://supplier2.com/", segmentLength: SEGMENT_LENGTH }, + ] + }) + + it("returns the first subtitles source url", async () => { + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.currentSubtitlesSource()).toBe("http://subtitlessource1.com/") + }) + + it("returns the first subtitles segment length", async () => { + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.currentSubtitlesSegmentLength()).toBe(SEGMENT_LENGTH) + }) + + it("returns the first subtitles cdn", async () => { + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.currentSubtitlesCdn()).toBe("http://supplier1.com/") + }) + + it("returns the second subtitle source following a failover", async () => { + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failoverSubtitles() + + expect(mediaSources.currentSubtitlesSource()).toBe("http://subtitlessource2.com/") + }) + + it("Rejects when there are no more subtitles sources to failover to", async () => { + testMedia.captions = [ + { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + const error = await getError(async () => mediaSources.failoverSubtitles()) + + expect(error).toEqual(new Error("Exhaused all subtitle sources")) + }) + + it("fires onSubtitlesLoadError plugin with correct parameters when there are sources available to failover to", async () => { + testMedia.captions = [ + { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, + { url: "http://subtitlessource2.com/", cdn: "http://supplier2.com/", segmentLength: SEGMENT_LENGTH }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failoverSubtitles({ statusCode: 404 }) + + expect(Plugins.interface.onSubtitlesLoadError).toHaveBeenCalledWith({ + status: 404, + severity: PluginEnums.STATUS.FAILOVER, + cdn: "http://supplier1.com/", + subtitlesSources: 2, + }) + }) + + it("fires onSubtitlesLoadError plugin with correct parameters when there are no sources available to failover to", async () => { + testMedia.captions = [ + { url: "http://subtitlessource1.com/", cdn: "http://supplier1.com/", segmentLength: SEGMENT_LENGTH }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await getError(async () => mediaSources.failoverSubtitles({ statusCode: 404 })) + + expect(Plugins.interface.onSubtitlesLoadError).toHaveBeenCalledWith({ + status: 404, + severity: PluginEnums.STATUS.FATAL, + cdn: "http://supplier1.com/", + subtitlesSources: 1, + }) + }) + }) + + describe("availableSources", () => { + it("returns an array of media source urls", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://cdn1.com" }, + { url: "http://source2.com/", cdn: "http://cdn2.com" }, + ] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.availableSources()).toEqual(["http://source1.com/", "http://source2.com/"]) + }) + }) + + describe("refresh", () => { + it("updates the mediasources time data", async () => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://cdn1.com" }, + { url: "http://source2.com/", cdn: "http://cdn2.com" }, + ] + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }, + transferFormat: TransferFormat.DASH, + }) + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + expect(mediaSources.time()).toEqual({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) + + expect(mediaSources.transferFormat()).toEqual(TransferFormat.DASH) + expect(mediaSources.currentSource()).toBe("http://source1.com/") + + jest.mocked(SourceLoader.load).mockResolvedValueOnce({ + time: { + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 100000, + availabilityStartTimeInMilliseconds: 100000, + timeShiftBufferDepthInMilliseconds: 72000000, + }, + transferFormat: TransferFormat.DASH, + }) + + await mediaSources.refresh() + + expect(mediaSources.time()).toEqual({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 100000, + availabilityStartTimeInMilliseconds: 100000, + timeShiftBufferDepthInMilliseconds: 72000000, + }) + + expect(mediaSources.transferFormat()).toEqual(TransferFormat.DASH) + expect(mediaSources.currentSource()).toBe("http://source1.com/") + }) + + it("rejects if manifest fails to load", async () => { + testMedia.urls = [{ url: "http://source1.com/", cdn: "http://cdn1.com" }] + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + jest.mocked(SourceLoader.load).mockRejectedValueOnce(new Error("A network error occured")) + + const error = await getError(async () => mediaSources.refresh()) + + expect(error.name).toBe("ManifestLoadError") + }) + }) + + describe("Reinstating failed over sources", () => { + beforeEach(() => { + testMedia.urls = [ + { url: "http://source1.com/", cdn: "http://cdn1.com" }, + { url: "http://source2.com/", cdn: "http://cdn2.com" }, + ] + }) + + it("should add the cdn that failed back in to available cdns after a timeout", async () => { + testMedia.playerSettings = { + failoverResetTime: FAILOVER_RESET_TIMEOUT_MS, + } + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + const expectedSources = [...mediaSources.availableSources()].reverse() + + await mediaSources.failover({ isBufferingTimeoutError: true, code: 0, message: "A mocked failover reason" }) + + jest.advanceTimersByTime(FAILOVER_RESET_TIMEOUT_MS) + + expect(mediaSources.availableSources()).toEqual(expectedSources) + }) + + it("should not contain the cdn that failed before the timeout has occured", async () => { + testMedia.playerSettings = { + failoverResetTime: FAILOVER_RESET_TIMEOUT_MS, + } + + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ isBufferingTimeoutError: true, code: 0, message: "A mocked failover reason" }) + + jest.advanceTimersByTime(FAILOVER_RESET_TIMEOUT_MS - 1000) + + expect(mediaSources.availableSources()).not.toContain("http://source1.com/") + }) + + it("should not preserve timers over teardown boundaries", async () => { + const mediaSources = MediaSources() + await mediaSources.init(testMedia) + + await mediaSources.failover({ isBufferingTimeoutError: true, code: 0, message: "A mocked failover reason" }) + + mediaSources.tearDown() + + jest.advanceTimersByTime(FAILOVER_RESET_TIMEOUT_MS) + + expect(mediaSources.availableSources()).toEqual([]) + }) + }) +}) diff --git a/src/mediasources.ts b/src/mediasources.ts new file mode 100644 index 00000000..59e2b5b5 --- /dev/null +++ b/src/mediasources.ts @@ -0,0 +1,349 @@ +import PlaybackUtils from "./utils/playbackutils" +import Plugins from "./plugins" +import PluginEnums from "./pluginenums" +import PluginData from "./plugindata" +import DebugTool from "./debugger/debugtool" +import ManifestLoader from "./manifest/sourceloader" +import { TransferFormat } from "./models/transferformats" +import { CaptionsConnection, Connection, MediaDescriptor } from "./types" +import { TimeInfo } from "./manifest/manifestparser" +import isError from "./utils/iserror" +import { ManifestType } from "./models/manifesttypes" + +type FailoverParams = { + isBufferingTimeoutError: boolean + code: number + message: string + duration?: number + currentTime?: number + serviceLocation?: string +} + +function MediaSources() { + let mediaSources: Connection[] = [] + let failedOverSources: Connection[] = [] + let failoverResetTokens: number[] = [] + let time: TimeInfo | null = null + let transferFormat: TransferFormat | null = null + let subtitlesSources: CaptionsConnection[] = [] + // Default 5000 can be overridden with media.subtitlesRequestTimeout + let subtitlesRequestTimeout = 5000 + let failoverResetTimeMs = 120000 + let failoverSort: ((sources: Connection[]) => Connection[]) | null = null + + function init(media: MediaDescriptor): Promise { + return new Promise((resolve, reject) => { + if (!media.urls?.length) { + return reject(new Error("Media Sources urls are undefined")) + } + + if (media.subtitlesRequestTimeout) { + subtitlesRequestTimeout = media.subtitlesRequestTimeout + } + + if (media.playerSettings?.failoverResetTime) { + failoverResetTimeMs = media.playerSettings.failoverResetTime + } + + if (media.playerSettings?.failoverSort) { + failoverSort = media.playerSettings.failoverSort + } + + mediaSources = media.urls ? (PlaybackUtils.cloneArray(media.urls) as Connection[]) : [] + subtitlesSources = media.captions ? (PlaybackUtils.cloneArray(media.captions) as CaptionsConnection[]) : [] + + updateDebugOutput() + + return resolve(loadManifest()) + }) + } + + function failover(failoverParams: FailoverParams): Promise { + return new Promise((resolve, reject) => { + if (!isFailoverInfoValid(failoverParams)) { + return reject(new TypeError("Invalid failover params")) + } + + if ( + time?.manifestType === ManifestType.STATIC && + isAboutToEnd(failoverParams.currentTime, failoverParams.duration) + ) { + return reject(new Error("Current time too close to end")) + } + + if (!hasSourcesToFailoverTo()) { + return reject(new Error("Exhaused all sources")) + } + + if (isFirstManifest(failoverParams.serviceLocation)) { + return resolve() + } + + emitCdnFailover(failoverParams) + updateCdns(failoverParams.serviceLocation) + updateDebugOutput() + + if (!needToGetManifest()) { + return resolve() + } + + return resolve(loadManifest()) + }) + } + + function failoverSubtitles({ statusCode, ...rest }: Partial<{ statusCode: number }> = {}): Promise { + return new Promise((resolve, reject) => { + if (subtitlesSources.length <= 1) { + Plugins.interface.onSubtitlesLoadError({ + status: statusCode, + severity: PluginEnums.STATUS.FATAL, + cdn: getCurrentSubtitlesCdn(), + subtitlesSources: subtitlesSources.length, + ...rest, + }) + + return reject(new Error("Exhaused all subtitle sources")) + } + + Plugins.interface.onSubtitlesLoadError({ + status: statusCode, + severity: PluginEnums.STATUS.FAILOVER, + cdn: getCurrentSubtitlesCdn(), + subtitlesSources: subtitlesSources.length, + ...rest, + }) + + subtitlesSources.shift() + + updateDebugOutput() + + return resolve() + }) + } + + function isAboutToEnd(currentTime: number | undefined, duration: number | undefined) { + return typeof currentTime === "number" && typeof duration === "number" && duration > 0 && currentTime > duration - 5 + } + + function stripQueryParamsAndHash(url: string): string { + return url.replace(/[#?].*/, "") + } + + // we don't want to failover on the first playback + // the serviceLocation is set to our first cdn url + // see manifest modifier - generateBaseUrls + function isFirstManifest(serviceLocation: string | undefined): boolean { + return typeof serviceLocation === "string" && doHostsMatch(serviceLocation, getCurrentUrl()) + } + + function doHostsMatch(firstUrl: string, secondUrl: string): boolean { + // Matches anything between *:// and / or the end of the line + const hostRegex = /\w+?:\/\/(.*?)(?:\/|$)/ + + const serviceLocNoQueryHash = stripQueryParamsAndHash(firstUrl) + const currUrlNoQueryHash = stripQueryParamsAndHash(secondUrl) + + const serviceLocationHost = hostRegex.exec(serviceLocNoQueryHash) + const currentUrlHost = hostRegex.exec(currUrlNoQueryHash) + + return serviceLocationHost && currentUrlHost + ? serviceLocationHost[1] === currentUrlHost[1] + : serviceLocNoQueryHash === currUrlNoQueryHash + } + + function isFailoverInfoValid(failoverParams: FailoverParams): boolean { + const infoValid = typeof failoverParams === "object" && typeof failoverParams.isBufferingTimeoutError === "boolean" + + if (!infoValid) { + DebugTool.error("failoverInfo is not valid") + } + + return infoValid + } + + function failoverResetTime(): number { + return failoverResetTimeMs + } + + function needToGetManifest(): boolean { + const hasManifestBeenLoaded = transferFormat != null + + return ( + !hasManifestBeenLoaded || (transferFormat === TransferFormat.HLS && time?.manifestType === ManifestType.DYNAMIC) + ) + } + + function refresh() { + return new Promise((resolve) => resolve(loadManifest())) + } + + function loadManifest(): Promise { + return ManifestLoader.load(getCurrentUrl()) + .then(({ time: newTime, transferFormat: newTransferFormat }) => { + time = newTime + transferFormat = newTransferFormat + + DebugTool.sourceLoaded({ ...time, transferFormat }) + }) + .catch((reason) => { + DebugTool.error(`Failed to load manifest: ${isError(reason) ? reason.message : "cause n/a"}`) + + return failover({ + isBufferingTimeoutError: false, + code: PluginEnums.ERROR_CODES.MANIFEST_LOAD, + message: PluginEnums.ERROR_MESSAGES.MANIFEST, + }) + }) + .catch((reason: unknown) => { + const error = new Error(isError(reason) ? reason.message : undefined) + + error.name = "ManifestLoadError" + + throw error + }) + } + + function getCurrentUrl(): string { + return mediaSources.length > 0 ? mediaSources[0].url.toString() : "" + } + + function getCurrentSubtitlesUrl(): string { + return subtitlesSources.length > 0 ? subtitlesSources[0].url.toString() : "" + } + + function getCurrentSubtitlesSegmentLength(): number | undefined { + return subtitlesSources.length > 0 ? subtitlesSources[0].segmentLength : undefined + } + + function getSubtitlesRequestTimeout(): number { + return subtitlesRequestTimeout + } + + function getCurrentSubtitlesCdn(): string | undefined { + return subtitlesSources.length > 0 ? subtitlesSources[0].cdn : undefined + } + + function availableUrls(): string[] { + return mediaSources.map((mediaSource) => mediaSource.url) + } + + function generateTime(): TimeInfo | null { + return time + } + + function getCurrentTransferFormat() { + return transferFormat + } + + function updateFailedOverSources(mediaSource: Connection) { + failedOverSources.push(mediaSource) + + if (failoverSort) { + mediaSources = failoverSort(mediaSources) + } + + const failoverResetToken = setTimeout(() => { + const source = failedOverSources.shift() + + if (source == null || mediaSources.length === 0) { + return + } + + DebugTool.info(`${mediaSource.cdn} has been added back in to available CDNs`) + mediaSources.push(source) + updateDebugOutput() + }, failoverResetTimeMs) + + failoverResetTokens.push(failoverResetToken as unknown as number) + } + + function updateCdns(serviceLocation: string | undefined): void { + const source = mediaSources.shift() + + if (source == null) { + return + } + + updateFailedOverSources(source) + + if (serviceLocation == null) { + return + } + + moveMediaSourceToFront(serviceLocation) + } + + function moveMediaSourceToFront(serviceLocation: string): void { + let serviceLocationIdx = mediaSources + .map((mediaSource) => stripQueryParamsAndHash(mediaSource.url)) + .indexOf(stripQueryParamsAndHash(serviceLocation)) + + if (serviceLocationIdx < 0) serviceLocationIdx = 0 + + mediaSources.unshift(mediaSources.splice(serviceLocationIdx, 1)[0]) + } + + function hasSourcesToFailoverTo(): boolean { + return mediaSources.length > 1 + } + + function emitCdnFailover(failoverInfo: FailoverParams) { + const evt = new PluginData({ + status: PluginEnums.STATUS.FAILOVER, + stateType: PluginEnums.TYPE.ERROR, + isBufferingTimeoutError: failoverInfo.isBufferingTimeoutError, + cdn: mediaSources[0].cdn, + newCdn: mediaSources[1].cdn, + code: failoverInfo.code, + message: failoverInfo.message, + }) + + Plugins.interface.onErrorHandled(evt) + } + + function availableCdns(): string[] { + return mediaSources.map((mediaSource) => mediaSource.cdn) + } + + function availableSubtitlesCdns(): string[] { + return subtitlesSources.map((subtitleSource) => subtitleSource.cdn) + } + + function updateDebugOutput() { + DebugTool.dynamicMetric("cdns-available", availableCdns()) + DebugTool.dynamicMetric("current-url", stripQueryParamsAndHash(getCurrentUrl())) + + DebugTool.dynamicMetric("subtitle-cdns-available", availableSubtitlesCdns()) + DebugTool.dynamicMetric("subtitle-current-url", stripQueryParamsAndHash(getCurrentSubtitlesUrl())) + } + + function tearDown() { + failoverResetTokens.forEach((token) => clearTimeout(token)) + + time = null + transferFormat = null + mediaSources = [] + failedOverSources = [] + failoverResetTokens = [] + subtitlesSources = [] + } + + return { + init, + failover, + failoverSubtitles, + refresh, + currentSource: getCurrentUrl, + currentSubtitlesSource: getCurrentSubtitlesUrl, + currentSubtitlesSegmentLength: getCurrentSubtitlesSegmentLength, + currentSubtitlesCdn: getCurrentSubtitlesCdn, + subtitlesRequestTimeout: getSubtitlesRequestTimeout, + availableSources: availableUrls, + failoverResetTime, + time: generateTime, + transferFormat: getCurrentTransferFormat, + tearDown, + } +} + +export default MediaSources diff --git a/src/models/errorcode.ts b/src/models/errorcode.ts new file mode 100644 index 00000000..a1096642 --- /dev/null +++ b/src/models/errorcode.ts @@ -0,0 +1,3 @@ +export interface ErrorWithCode extends Error { + code: number +} diff --git a/src/models/manifesttypes.ts b/src/models/manifesttypes.ts new file mode 100644 index 00000000..d3cee371 --- /dev/null +++ b/src/models/manifesttypes.ts @@ -0,0 +1,6 @@ +export const ManifestType = { + STATIC: "static", + DYNAMIC: "dynamic", +} as const + +export type ManifestType = (typeof ManifestType)[keyof typeof ManifestType] diff --git a/src/models/timeline.ts b/src/models/timeline.ts new file mode 100644 index 00000000..aeb75ce8 --- /dev/null +++ b/src/models/timeline.ts @@ -0,0 +1,7 @@ +export const Timeline = { + AVAILABILITY_TIME: "availabilityTime", + MEDIA_SAMPLE_TIME: "mediaSampleTime", + PRESENTATION_TIME: "presentationTime", +} as const + +export type Timeline = (typeof Timeline)[keyof typeof Timeline] diff --git a/src/models/transferformats.ts b/src/models/transferformats.ts index 027131a9..133414ad 100644 --- a/src/models/transferformats.ts +++ b/src/models/transferformats.ts @@ -1,8 +1,15 @@ -export const TransferFormat = { - DASH: "dash", - HLS: "hls", -} as const +const DASH = "dash" as const -export type TransferFormat = (typeof TransferFormat)[keyof typeof TransferFormat] +const HLS = "hls" as const + +const PLAIN = "plain" as const + +export const TransferFormat = { DASH, HLS, PLAIN } as const + +export type DASH = typeof DASH -export default TransferFormat +export type HLS = typeof HLS + +export type PLAIN = typeof PLAIN + +export type TransferFormat = (typeof TransferFormat)[keyof typeof TransferFormat] diff --git a/src/playbackstrategy/basicstrategy.js b/src/playbackstrategy/basicstrategy.js index 42f8316d..0ab6e395 100644 --- a/src/playbackstrategy/basicstrategy.js +++ b/src/playbackstrategy/basicstrategy.js @@ -1,14 +1,16 @@ import DebugTool from "../debugger/debugtool" -import MediaState from "../models/mediastate" -import WindowTypes from "../models/windowtypes" -import MediaKinds from "../models/mediakinds" +import { ManifestType } from "../models/manifesttypes" import LiveSupport from "../models/livesupport" -import DynamicWindowUtils from "../dynamicwindowutils" -import DOMHelpers from "../domhelpers" +import MediaKinds from "../models/mediakinds" +import MediaState from "../models/mediastate" import handlePlayPromise from "../utils/handleplaypromise" +import TimeShiftDetector from "../utils/timeshiftdetector" +import DOMHelpers from "../domhelpers" +import { autoResumeAtStartOfRange } from "../dynamicwindowutils" -function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { +function BasicStrategy(mediaSources, mediaKind, playbackElement) { const CLAMP_OFFSET_SECONDS = 1.1 + const manifestType = mediaSources.time().manifestType let eventCallbacks = [] let errorCallback @@ -16,7 +18,14 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { let mediaElement let metaDataLoaded - let timeCorrection = mediaSources.time()?.timeCorrectionSeconds || 0 + + const timeShiftDetector = TimeShiftDetector(() => { + if (!isPaused()) { + return + } + + startAutoResumeTimeout() + }) function publishMediaState(mediaState) { for (let index = 0; index < eventCallbacks.length; index++) { @@ -77,9 +86,13 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { mediaElement.addEventListener("loadedmetadata", onLoadedMetadata) } - function setStartTime(startTime) { - if (startTime) { - mediaElement.currentTime = startTime + timeCorrection + function setStartTime(presentationTimeInSeconds) { + if (presentationTimeInSeconds || presentationTimeInSeconds === 0) { + // currentTime = 0 is interpreted as play from live point by many devices + const startTimeInSeconds = + manifestType === ManifestType.DYNAMIC && presentationTimeInSeconds === 0 ? 0.1 : presentationTimeInSeconds + + mediaElement.currentTime = startTimeInSeconds } } @@ -101,7 +114,7 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { function onSeeked() { if (isPaused()) { - if (windowType === WindowTypes.SLIDING) { + if (timeShiftDetector.isSeekableRangeSliding()) { startAutoResumeTimeout() } @@ -131,6 +144,10 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { function onLoadedMetadata() { metaDataLoaded = true + + if (manifestType === ManifestType.DYNAMIC) { + timeShiftDetector.observe(getSeekableRange) + } } function isPaused() { @@ -140,8 +157,8 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { function getSeekableRange() { if (mediaElement && mediaElement.seekable && mediaElement.seekable.length > 0 && metaDataLoaded) { return { - start: mediaElement.seekable.start(0) - timeCorrection, - end: mediaElement.seekable.end(0) - timeCorrection, + start: mediaElement.seekable.start(0), + end: mediaElement.seekable.end(0), } } return { @@ -159,7 +176,7 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { } function getCurrentTime() { - return mediaElement ? mediaElement.currentTime - timeCorrection : 0 + return mediaElement ? mediaElement.currentTime : 0 } function addEventCallback(thisArg, newCallback) { @@ -176,7 +193,7 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { } function startAutoResumeTimeout() { - DynamicWindowUtils.autoResumeAtStartOfRange( + autoResumeAtStartOfRange( getCurrentTime(), getSeekableRange(), addEventCallback, @@ -190,11 +207,11 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { handlePlayPromise(mediaElement.play()) } - function setCurrentTime(time) { + function setCurrentTime(presentationTimeInSeconds) { // Without metadata we cannot clamp to seekableRange mediaElement.currentTime = metaDataLoaded - ? getClampedTime(time, getSeekableRange()) + timeCorrection - : time + timeCorrection + ? getClampedTime(presentationTimeInSeconds, getSeekableRange()) + : presentationTimeInSeconds } function setPlaybackRate(rate) { @@ -233,13 +250,14 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { DOMHelpers.safeRemoveElement(mediaElement) } + timeShiftDetector.disconnect() + eventCallbacks = [] errorCallback = undefined timeUpdateCallback = undefined mediaElement = undefined metaDataLoaded = undefined - timeCorrection = undefined } function reset() {} @@ -248,9 +266,10 @@ function BasicStrategy(mediaSources, windowType, mediaKind, playbackElement) { return mediaElement.ended } - function pause(opts = {}) { + function pause() { mediaElement.pause() - if (opts.disableAutoResume !== true && windowType === WindowTypes.SLIDING) { + + if (timeShiftDetector.isSeekableRangeSliding()) { startAutoResumeTimeout() } } diff --git a/src/playbackstrategy/basicstrategy.test.js b/src/playbackstrategy/basicstrategy.test.js index b6d1aa0d..88c631d2 100644 --- a/src/playbackstrategy/basicstrategy.test.js +++ b/src/playbackstrategy/basicstrategy.test.js @@ -1,34 +1,51 @@ -import WindowTypes from "../models/windowtypes" +import { ManifestType } from "../models/manifesttypes" import MediaKinds from "../models/mediakinds" import MediaState from "../models/mediastate" +import TimeShiftDetector from "../utils/timeshiftdetector" import BasicStrategy from "./basicstrategy" -import DynamicWindowUtils from "../dynamicwindowutils" +import { autoResumeAtStartOfRange } from "../dynamicwindowutils" -const autoResumeSpy = jest.spyOn(DynamicWindowUtils, "autoResumeAtStartOfRange") +jest.mock("../dynamicwindowutils") +jest.mock("../utils/timeshiftdetector") + +const mockTimeShiftDetector = { + disconnect: jest.fn(), + isSeekableRangeSliding: jest.fn(), + observe: jest.fn(), + // Mock function to fake time shift detection + triggerTimeShiftDetected: jest.fn(), +} + +const mockMediaSources = { + time: jest.fn(), + currentSource: jest.fn().mockReturnValue(""), + availableSources: jest.fn().mockReturnValue([]), +} describe("HTML5 Strategy", () => { + let playbackElement let audioElement let videoElement - let basicStrategy let cdnArray - let playbackElement - let mockMediaSources - let testTimeCorrection - function setUpStrategy(windowType, mediaKind) { - const defaultWindowType = windowType || WindowTypes.STATIC - const defaultMediaKind = mediaKind || MediaKinds.VIDEO + beforeAll(() => { + TimeShiftDetector.mockImplementation((onceTimeShiftDetected) => { + mockTimeShiftDetector.triggerTimeShiftDetected.mockImplementation(() => onceTimeShiftDetected()) - basicStrategy = BasicStrategy(mockMediaSources, defaultWindowType, defaultMediaKind, playbackElement) - } + return mockTimeShiftDetector + }) + }) beforeEach(() => { + jest.clearAllMocks() + audioElement = document.createElement("audio") videoElement = document.createElement("video") - jest.spyOn(videoElement, "load").mockImplementation(() => {}) jest.spyOn(videoElement, "pause").mockImplementation(() => {}) jest.spyOn(videoElement, "play").mockImplementation(() => {}) + jest.spyOn(videoElement, "load").mockImplementation(() => {}) + // jest.spyOn(audioElement, "load").mockImplementation(() => {}) playbackElement = document.createElement("div") playbackElement.id = "app" @@ -48,23 +65,24 @@ describe("HTML5 Strategy", () => { { url: "http://testcdn3/test/", cdn: "http://testcdn3/test/" }, ] - mockMediaSources = { - time: () => ({ timeCorrectionSeconds: testTimeCorrection }), - currentSource: () => cdnArray[0].url, - } + mockMediaSources.currentSource.mockReturnValue(cdnArray[0].url) + + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.STATIC, + presentationTimeOffsetInMilliseconds: 0, + availabilityStartTimeInMilliseconds: 0, + timeShiftBufferDepthInMilliseconds: 0, + }) }) afterEach(() => { - testTimeCorrection = 0 - basicStrategy.tearDown() videoElement = undefined audioElement = undefined - autoResumeSpy.mockReset() }) describe("transitions", () => { it("canBePaused() and canBeginSeek transitions are true", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) expect(basicStrategy.transitions.canBePaused()).toBe(true) expect(basicStrategy.transitions.canBeginSeek()).toBe(true) @@ -73,7 +91,7 @@ describe("HTML5 Strategy", () => { describe("load", () => { it("should create a video element and add it to the playback element", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) expect(playbackElement.childElementCount).toBe(0) @@ -84,7 +102,7 @@ describe("HTML5 Strategy", () => { }) it("should create an audio element and add it to the playback element", () => { - setUpStrategy(null, MediaKinds.AUDIO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.AUDIO, playbackElement) expect(playbackElement.childElementCount).toBe(0) @@ -97,7 +115,7 @@ describe("HTML5 Strategy", () => { }) it("should set the style properties correctly on the media element", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) expect(videoElement.style.position).toBe("absolute") @@ -106,7 +124,7 @@ describe("HTML5 Strategy", () => { }) it("should set the autoplay and preload properties correctly on the media element", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) const videoElement = document.querySelector("video") @@ -116,28 +134,37 @@ describe("HTML5 Strategy", () => { }) it("should set the source url correctly on the media element", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) expect(videoElement.src).toBe("http://testcdn1/test/") }) it("should set the currentTime to start time if one is provided", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 25) expect(videoElement.currentTime).toBe(25) }) it("should not set the currentTime to start time if one is not provided", () => { - setUpStrategy(null, MediaKinds.VIDEO) + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) expect(videoElement.currentTime).toBe(0) }) + it("should set currentTime to .1s if start time is zero (0) for a live stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + basicStrategy.load(null, 0) + + expect(videoElement.currentTime).toBe(0.1) + }) + it("should call load on the media element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) const videoLoadSpy = jest.spyOn(videoElement, "load") basicStrategy.load(null) @@ -146,12 +173,12 @@ describe("HTML5 Strategy", () => { }) it("should update the media element source if load is when media element already exists", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) expect(videoElement.src).toBe("http://testcdn1/test/") - mockMediaSources.currentSource = () => cdnArray[1].url + mockMediaSources.currentSource.mockReturnValueOnce(cdnArray[1].url) basicStrategy.load(null) @@ -159,7 +186,7 @@ describe("HTML5 Strategy", () => { }) it("should update the media element currentTime if load is called with a start time when media element already exists", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 25) expect(videoElement.currentTime).toBe(25) @@ -170,7 +197,7 @@ describe("HTML5 Strategy", () => { }) it("should not update the media element currentTime if load is called without a start time when media element already exists", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 25) expect(videoElement.currentTime).toBe(25) @@ -181,7 +208,7 @@ describe("HTML5 Strategy", () => { }) it("should set up bindings to media element events correctly", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) const addEventListenerSpy = jest.spyOn(videoElement, "addEventListener") basicStrategy.load(null) @@ -199,7 +226,7 @@ describe("HTML5 Strategy", () => { describe("play", () => { it("should call through to the media elements play function", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) const playSpy = jest.spyOn(videoElement, "play") basicStrategy.play() @@ -210,41 +237,13 @@ describe("HTML5 Strategy", () => { describe("pause", () => { it("should call through to the media elements pause function", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) const pauseSpy = jest.spyOn(videoElement, "pause") basicStrategy.pause() expect(pauseSpy).toHaveBeenCalled() }) - - it("should start autoresume timeout if sliding window", () => { - setUpStrategy(WindowTypes.SLIDING, MediaKinds.VIDEO) - basicStrategy.load(null, 0) - basicStrategy.pause() - - expect(autoResumeSpy).toHaveBeenCalledTimes(1) - expect(autoResumeSpy).toHaveBeenCalledWith( - 0, - { start: 0, end: 0 }, - expect.any(Function), - expect.any(Function), - expect.any(Function), - basicStrategy.play - ) - }) - - it("should not start autoresume timeout if sliding window but disableAutoResume is set", () => { - const opts = { - disableAutoResume: true, - } - - setUpStrategy(WindowTypes.SLIDING, MediaKinds.VIDEO) - basicStrategy.load(null, 0) - basicStrategy.pause(opts) - - expect(autoResumeSpy).not.toHaveBeenCalled() - }) }) describe("getSeekableRange", () => { @@ -265,34 +264,25 @@ describe("HTML5 Strategy", () => { }) it("returns the correct start and end time before load has been called", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) expect(basicStrategy.getSeekableRange()).toEqual({ start: 0, end: 0 }) }) it("returns the correct start and end time before meta data has loaded", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) expect(basicStrategy.getSeekableRange()).toEqual({ start: 0, end: 0 }) }) it("returns the correct start and end time once meta data has loaded", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) expect(basicStrategy.getSeekableRange()).toEqual({ start: 25, end: 100 }) }) - - it("returns the correct start and end time minus any time correction", () => { - testTimeCorrection = 20 - setUpStrategy() - basicStrategy.load(null) - videoElement.dispatchEvent(new Event("loadedmetadata")) - - expect(basicStrategy.getSeekableRange()).toEqual({ start: 5, end: 80 }) - }) }) describe("getDuration", () => { @@ -301,20 +291,20 @@ describe("HTML5 Strategy", () => { }) it("returns duration of zero before load has been called", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) expect(basicStrategy.getDuration()).toBe(0) }) it("returns duration of zero before meta data has loaded", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) expect(basicStrategy.getDuration()).toBe(0) }) it("returns the correct duration once meta data has loaded", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) @@ -328,13 +318,13 @@ describe("HTML5 Strategy", () => { }) it("returns currentTime of zero before load has been called", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) expect(basicStrategy.getCurrentTime()).toBe(0) }) it("returns the correct currentTime once load has been called", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) expect(basicStrategy.getCurrentTime()).toBe(5) @@ -343,16 +333,6 @@ describe("HTML5 Strategy", () => { expect(basicStrategy.getCurrentTime()).toBe(10) }) - - it("subtracts any time correction from the media elements current time", () => { - testTimeCorrection = 20 - setUpStrategy() - basicStrategy.load(null) - - videoElement.currentTime = 50 - - expect(basicStrategy.getCurrentTime()).toBe(30) - }) }) describe("setCurrentTime", () => { @@ -371,7 +351,7 @@ describe("HTML5 Strategy", () => { }) it("sets the current time on the media element to that passed in", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) basicStrategy.setCurrentTime(10) @@ -379,18 +359,8 @@ describe("HTML5 Strategy", () => { expect(basicStrategy.getCurrentTime()).toBe(10) }) - it("adds time correction from the media source onto the passed in seek time", () => { - testTimeCorrection = 20 - setUpStrategy() - basicStrategy.load(null) - - basicStrategy.setCurrentTime(50) - - expect(videoElement.currentTime).toBe(70) - }) - it("does not attempt to clamp time if meta data is not loaded", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) // this is greater than expected seekable range. although range does not exist until meta data loaded @@ -400,7 +370,7 @@ describe("HTML5 Strategy", () => { }) it("clamps to 1.1 seconds before seekable range end when seeking to end", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) @@ -410,7 +380,7 @@ describe("HTML5 Strategy", () => { }) it("clamps to 1.1 seconds before seekable range end when seeking past end", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) @@ -420,7 +390,7 @@ describe("HTML5 Strategy", () => { }) it("clamps to 1.1 seconds before seekable range end when seeking prior to end", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) @@ -430,7 +400,7 @@ describe("HTML5 Strategy", () => { }) it("clamps to the start of seekable range when seeking before start of range", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null) videoElement.dispatchEvent(new Event("loadedmetadata")) @@ -442,7 +412,7 @@ describe("HTML5 Strategy", () => { describe("Playback Rate", () => { it("sets the playback rate on the media element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) basicStrategy.setPlaybackRate(2) @@ -450,7 +420,7 @@ describe("HTML5 Strategy", () => { }) it("gets the playback rate on the media element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) const testRate = 1.5 basicStrategy.setPlaybackRate(testRate) @@ -463,7 +433,7 @@ describe("HTML5 Strategy", () => { describe("isPaused", () => { it("should return false when the media element is not paused", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(false) @@ -471,7 +441,7 @@ describe("HTML5 Strategy", () => { }) it("should return true when the media element is paused", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(true) @@ -481,7 +451,7 @@ describe("HTML5 Strategy", () => { describe("isEnded", () => { it("should return false when the media element is not ended", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) jest.spyOn(videoElement, "ended", "get").mockReturnValueOnce(false) @@ -489,7 +459,7 @@ describe("HTML5 Strategy", () => { }) it("should return true when the media element is ended", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) jest.spyOn(videoElement, "ended", "get").mockReturnValueOnce(true) @@ -499,7 +469,7 @@ describe("HTML5 Strategy", () => { describe("tearDown", () => { it("should remove all event listener bindings", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) const removeEventListenerSpy = jest.spyOn(videoElement, "removeEventListener") @@ -517,7 +487,7 @@ describe("HTML5 Strategy", () => { }) it("should remove the video element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) expect(playbackElement.childElementCount).toBe(1) @@ -528,7 +498,7 @@ describe("HTML5 Strategy", () => { }) it("should empty the eventCallbacks", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) function tearDownAndError() { // add event callback to prove array is emptied in tearDown @@ -544,7 +514,7 @@ describe("HTML5 Strategy", () => { it("should undefine the error callback", () => { const errorCallbackSpy = jest.fn() - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.addErrorCallback(this, errorCallbackSpy) basicStrategy.load(null, 0) basicStrategy.tearDown() @@ -556,7 +526,7 @@ describe("HTML5 Strategy", () => { it("should undefine the timeupdate callback", () => { const timeUpdateCallbackSpy = jest.fn() - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.addTimeUpdateCallback(this, timeUpdateCallbackSpy) basicStrategy.load(null, 0) basicStrategy.tearDown() @@ -566,17 +536,25 @@ describe("HTML5 Strategy", () => { }) it("should undefine the mediaPlayer element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) basicStrategy.tearDown() expect(basicStrategy.getPlayerElement()).toBeUndefined() }) + + it("should disconnect the time shift detector", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + basicStrategy.load(null, 0) + basicStrategy.tearDown() + + expect(mockTimeShiftDetector.disconnect).toHaveBeenCalledTimes(1) + }) }) describe("getPlayerElement", () => { it("should return the mediaPlayer element", () => { - setUpStrategy() + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) basicStrategy.load(null, 0) expect(basicStrategy.getPlayerElement()).toEqual(videoElement) @@ -584,25 +562,14 @@ describe("HTML5 Strategy", () => { }) describe("events", () => { - let eventCallbackSpy - let timeUpdateCallbackSpy - let errorCallbackSpy - - beforeEach(() => { - setUpStrategy(WindowTypes.SLIDING, MediaKinds.VIDEO) - basicStrategy.load(null, 25) + it("should publish a state change to PLAYING on playing event", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) - eventCallbackSpy = jest.fn() + const eventCallbackSpy = jest.fn() basicStrategy.addEventCallback(this, eventCallbackSpy) - timeUpdateCallbackSpy = jest.fn() - basicStrategy.addTimeUpdateCallback(this, timeUpdateCallbackSpy) - - errorCallbackSpy = jest.fn() - basicStrategy.addErrorCallback(this, errorCallbackSpy) - }) + basicStrategy.load(null, 25) - it("should publish a state change to PLAYING on playing event", () => { videoElement.dispatchEvent(new Event("playing")) expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.PLAYING) @@ -610,6 +577,13 @@ describe("HTML5 Strategy", () => { }) it("should publish a state change to PAUSED on pause event", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + const eventCallbackSpy = jest.fn() + basicStrategy.addEventCallback(this, eventCallbackSpy) + + basicStrategy.load(null, 25) + videoElement.dispatchEvent(new Event("pause")) expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.PAUSED) @@ -617,6 +591,13 @@ describe("HTML5 Strategy", () => { }) it("should publish a state change to WAITING on seeking event", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + const eventCallbackSpy = jest.fn() + basicStrategy.addEventCallback(this, eventCallbackSpy) + + basicStrategy.load(null, 25) + videoElement.dispatchEvent(new Event("seeking")) expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.WAITING) @@ -624,6 +605,13 @@ describe("HTML5 Strategy", () => { }) it("should publish a state change to WAITING on waiting event", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + const eventCallbackSpy = jest.fn() + basicStrategy.addEventCallback(this, eventCallbackSpy) + + basicStrategy.load(null, 25) + videoElement.dispatchEvent(new Event("waiting")) expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.WAITING) @@ -631,20 +619,27 @@ describe("HTML5 Strategy", () => { }) it("should publish a state change to ENDED on ended event", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + const eventCallbackSpy = jest.fn() + basicStrategy.addEventCallback(this, eventCallbackSpy) + + basicStrategy.load(null, 25) + videoElement.dispatchEvent(new Event("ended")) expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.ENDED) expect(eventCallbackSpy).toHaveBeenCalledTimes(1) }) - it("should start auto-resume timeout on seeked event if media element is paused and SLIDING window", () => { - jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(true) - videoElement.dispatchEvent(new Event("seeked")) + it("should publish a time update event on time update", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) - expect(autoResumeSpy).toHaveBeenCalledTimes(1) - }) + const timeUpdateCallbackSpy = jest.fn() + basicStrategy.addTimeUpdateCallback(this, timeUpdateCallbackSpy) + + basicStrategy.load(null, 25) - it("should publish a time update event on time update", () => { videoElement.dispatchEvent(new Event("timeupdate")) expect(timeUpdateCallbackSpy).toHaveBeenCalled() @@ -652,6 +647,13 @@ describe("HTML5 Strategy", () => { }) it("should publish a error event with code and message on error", () => { + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + const errorCallbackSpy = jest.fn() + basicStrategy.addErrorCallback(this, errorCallbackSpy) + + basicStrategy.load(null, 25) + videoElement.dispatchEvent(new Event("error")) // cannot fully test that the MediaError is used as JSDOM cannot set error on the video element @@ -659,4 +661,160 @@ describe("HTML5 Strategy", () => { expect(errorCallbackSpy).toHaveBeenCalledTimes(1) }) }) + + describe("auto resume", () => { + it("provides the seekable range for any dynamic stream to the time shift detector once metadata has loaded", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null) + + expect(mockTimeShiftDetector.observe).not.toHaveBeenCalled() + + videoElement.dispatchEvent(new Event("loadedmetadata")) + + expect(mockTimeShiftDetector.observe).toHaveBeenCalledWith(basicStrategy.getSeekableRange) + }) + + it("should not provide the seekable range to the time shift detector for a static stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.STATIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null) + + videoElement.dispatchEvent(new Event("loadedmetadata")) + + expect(mockTimeShiftDetector.observe).not.toHaveBeenCalled() + }) + + it("should provide the seekable range to the time shift detector again on a reload", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null) + + videoElement.dispatchEvent(new Event("loadedmetadata")) + + expect(mockTimeShiftDetector.observe).toHaveBeenCalledTimes(1) + + basicStrategy.load(null) + + videoElement.dispatchEvent(new Event("loadedmetadata")) + + expect(mockTimeShiftDetector.observe).toHaveBeenCalledTimes(2) + }) + + it("should start auto-resume timeout when Time Shift Detector returns true for sliding", () => { + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(true) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null, 5) + basicStrategy.pause() + + expect(autoResumeAtStartOfRange).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).toHaveBeenCalledWith( + 5, + { start: 0, end: 0 }, + expect.any(Function), + expect.any(Function), + expect.any(Function), + basicStrategy.play + ) + }) + + it("should not start auto-resume timeout when Time Shift Detector returns false for sliding", () => { + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(false) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null, 0) + basicStrategy.pause() + + expect(autoResumeAtStartOfRange).not.toHaveBeenCalled() + }) + + it("should start auto-resume timeout on seeked event if media element is paused and Time Shift Detector returns true for sliding", () => { + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + timeShiftBufferDepthInMilliseconds: 72000000, + availabilityStartTimeInMilliseconds: 1731974400000, + presentationTimeOffsetInMilliseconds: 0, + }) + + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(true) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + basicStrategy.load(null, 0) + + jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(true) + + videoElement.dispatchEvent(new Event("seeked")) + + expect(autoResumeAtStartOfRange).toHaveBeenCalledTimes(1) + }) + + it("should not start auto-resume timeout on seeked event if media element is paused and Time Shift Detector returns false for sliding", () => { + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + timeShiftBufferDepthInMilliseconds: 72000000, + availabilityStartTimeInMilliseconds: 1731974400000, + presentationTimeOffsetInMilliseconds: 0, + }) + + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(false) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + basicStrategy.load(null, 0) + + jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(true) + + videoElement.dispatchEvent(new Event("seeked")) + + expect(autoResumeAtStartOfRange).not.toHaveBeenCalled() + }) + + it("should start auto-resume timeout when Time Shift Detector callback fires while paused", () => { + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + timeShiftBufferDepthInMilliseconds: 72000000, + availabilityStartTimeInMilliseconds: 1731974400000, + presentationTimeOffsetInMilliseconds: 0, + }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null, 0) + + jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(true) + + mockTimeShiftDetector.triggerTimeShiftDetected() + + expect(autoResumeAtStartOfRange).toHaveBeenCalledTimes(1) + }) + + it("should not start auto-resume timeout when Time Shift Detector callback fires while unpaused", () => { + mockMediaSources.time.mockReturnValue({ + manifestType: ManifestType.DYNAMIC, + timeShiftBufferDepthInMilliseconds: 72000000, + availabilityStartTimeInMilliseconds: 1731974400000, + presentationTimeOffsetInMilliseconds: 0, + }) + + const basicStrategy = BasicStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + basicStrategy.load(null, 0) + + jest.spyOn(videoElement, "paused", "get").mockReturnValueOnce(false) + + mockTimeShiftDetector.triggerTimeShiftDetected() + + expect(autoResumeAtStartOfRange).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/playbackstrategy/legacyplayeradapter.js b/src/playbackstrategy/legacyplayeradapter.js index 5aa3805e..295eee07 100644 --- a/src/playbackstrategy/legacyplayeradapter.js +++ b/src/playbackstrategy/legacyplayeradapter.js @@ -1,21 +1,19 @@ -import AllowedMediaTransitions from "../allowedmediatransitions" -import MediaState from "../models/mediastate" -import WindowTypes from "../models/windowtypes" import DebugTool from "../debugger/debugtool" +import MediaState from "../models/mediastate" +import { ManifestType } from "../models/manifesttypes" +import AllowedMediaTransitions from "../allowedmediatransitions" import LiveGlitchCurtain from "./liveglitchcurtain" -function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, player) { - const EVENT_HISTORY_LENGTH = 2 +function LegacyPlayerAdapter(mediaSources, playbackElement, isUHD, player) { + const manifestType = mediaSources.time().manifestType const setSourceOpts = { disableSentinels: - !!isUHD && windowType !== WindowTypes.STATIC && window.bigscreenPlayer?.overrides?.liveUhdDisableSentinels, - disableSeekSentinel: window.bigscreenPlayer?.overrides?.disableSeekSentinel, + !!isUHD && manifestType === ManifestType.DYNAMIC && window.bigscreenPlayer?.overrides?.liveUhdDisableSentinels, + disableSeekSentinel: !!window.bigscreenPlayer?.overrides?.disableSeekSentinel, } - const timeCorrection = mediaSources.time()?.timeCorrectionSeconds || 0 const mediaPlayer = player - const eventHistory = [] const transitions = new AllowedMediaTransitions(mediaPlayer) @@ -56,23 +54,15 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p "error": onError, } - if (handleEvent.hasOwnProperty(event.type)) { + if (Object.prototype.hasOwnProperty.call(handleEvent, event.type)) { handleEvent[event.type].call(this, event) } else { DebugTool.info(`${getSelection()} Event:${event.type}`) } - - if (event.type !== "status") { - if (eventHistory.length >= EVENT_HISTORY_LENGTH) { - eventHistory.pop() - } - - eventHistory.unshift({ type: event.type, time: Date.now() }) - } } function onPlaying(event) { - currentTime = event.currentTime - timeCorrection + currentTime = event.currentTime isPaused = false isEnded = false duration = duration || event.duration @@ -98,7 +88,7 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p // A newly loaded video element will always report a 0 time update // This is slightly unhelpful if we want to continue from a later point but consult currentTime as the source of truth. if (parseInt(event.currentTime) !== 0) { - currentTime = event.currentTime - timeCorrection + currentTime = event.currentTime } // Must publish this time update before checkSeekSucceded - which could cause a pause event @@ -140,7 +130,7 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p const overrides = streaming.overrides || doNotForceBeginPlaybackToEndOfWindow const shouldShowCurtain = - windowType !== WindowTypes.STATIC && (hasStartTime || overrides.forceBeginPlaybackToEndOfWindow) + manifestType === ManifestType.DYNAMIC && (hasStartTime || overrides.forceBeginPlaybackToEndOfWindow) if (shouldShowCurtain) { liveGlitchCurtain = new LiveGlitchCurtain(playbackElement) @@ -178,7 +168,7 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p } function setupExitSeekWorkarounds(mimeType) { - handleErrorOnExitingSeek = windowType !== WindowTypes.STATIC && mimeType === "application/dash+xml" + handleErrorOnExitingSeek = manifestType === ManifestType.DYNAMIC && mimeType === "application/dash+xml" const deviceFailsPlayAfterPauseOnExitSeek = window.bigscreenPlayer.overrides && window.bigscreenPlayer.overrides.pauseOnExitSeek @@ -215,11 +205,11 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p reset() mediaPlayer.initialiseMedia("video", source, mimeType, playbackElement, setSourceOpts) - mediaPlayer.beginPlaybackFrom(currentTime + timeCorrection || 0) + mediaPlayer.beginPlaybackFrom(currentTime || 0) } function requiresLiveCurtain() { - return !!window.bigscreenPlayer.overrides && !!window.bigscreenPlayer.overrides.showLiveCurtain + return !!window.bigscreenPlayer?.overrides?.showLiveCurtain } function reset() { @@ -241,18 +231,24 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p addTimeUpdateCallback: (thisArg, newTimeUpdateCallback) => { timeUpdateCallback = () => newTimeUpdateCallback.call(thisArg) }, - load: (mimeType, startTime) => { + load: (mimeType, presentationTimeInSeconds) => { setupExitSeekWorkarounds(mimeType) isPaused = false - hasStartTime = startTime || startTime === 0 - const isPlaybackFromLivePoint = windowType !== WindowTypes.STATIC && !hasStartTime + hasStartTime = presentationTimeInSeconds || presentationTimeInSeconds === 0 mediaPlayer.initialiseMedia("video", mediaSources.currentSource(), mimeType, playbackElement, setSourceOpts) - if (!isPlaybackFromLivePoint && typeof mediaPlayer.beginPlaybackFrom === "function") { - currentTime = startTime - mediaPlayer.beginPlaybackFrom(startTime + timeCorrection || 0) + if ( + typeof mediaPlayer.beginPlaybackFrom === "function" && + (manifestType === ManifestType.STATIC || hasStartTime) + ) { + // currentTime = 0 is interpreted as play from live point by many devices + const startTimeInSeconds = + manifestType === ManifestType.DYNAMIC && presentationTimeInSeconds === 0 ? 0.1 : presentationTimeInSeconds + + currentTime = startTimeInSeconds || 0 + mediaPlayer.beginPlaybackFrom(currentTime) } else { mediaPlayer.beginPlayback() } @@ -265,18 +261,18 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p if (isEnded) { mediaPlayer.playFrom && mediaPlayer.playFrom(0) } else if (transitions.canResume()) { - mediaPlayer.resume() + mediaPlayer.resume && mediaPlayer.resume() } else { - mediaPlayer.playFrom && mediaPlayer.playFrom(currentTime + timeCorrection) + mediaPlayer.playFrom && mediaPlayer.playFrom(currentTime) } } }, - pause: (options) => { + pause: () => { // TODO - transitions is checked in playerComponent. The check can be removed here. if (delayPauseOnExitSeek && exitingSeek && transitions.canBePaused()) { pauseOnExitSeek = true } else { - mediaPlayer.pause(options) + mediaPlayer.pause() } }, isPaused: () => isPaused, @@ -284,20 +280,14 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p getDuration: () => duration, getPlayerElement: () => mediaPlayer.getPlayerElement && mediaPlayer.getPlayerElement(), getSeekableRange: () => { - if (windowType === WindowTypes.STATIC) { + if (manifestType === ManifestType.STATIC) { return { start: 0, end: duration, } } - const seekableRange = (mediaPlayer.getSeekableRange && mediaPlayer.getSeekableRange()) || {} - if (seekableRange.hasOwnProperty("start")) { - seekableRange.start = seekableRange.start - timeCorrection - } - if (seekableRange.hasOwnProperty("end")) { - seekableRange.end = seekableRange.end - timeCorrection - } - return seekableRange + + return typeof mediaPlayer.getSeekableRange === "function" ? mediaPlayer.getSeekableRange() : null }, setPlaybackRate: (rate) => { if (typeof mediaPlayer.setPlaybackRate === "function") { @@ -311,19 +301,19 @@ function LegacyPlayerAdapter(mediaSources, windowType, playbackElement, isUHD, p return 1 }, getCurrentTime: () => currentTime, - setCurrentTime: (seekToTime) => { + setCurrentTime: (presentationTimeInSeconds) => { isEnded = false - currentTime = seekToTime - const correctedSeekToTime = seekToTime + timeCorrection + currentTime = presentationTimeInSeconds if (handleErrorOnExitingSeek || delayPauseOnExitSeek) { - targetSeekToTime = correctedSeekToTime + targetSeekToTime = presentationTimeInSeconds exitingSeek = true pauseOnExitSeek = isPaused } - mediaPlayer.playFrom && mediaPlayer.playFrom(correctedSeekToTime) - if (isPaused && !delayPauseOnExitSeek) { + mediaPlayer.playFrom && mediaPlayer.playFrom(presentationTimeInSeconds) + + if (isPaused && !delayPauseOnExitSeek && typeof mediaPlayer.pause === "function") { mediaPlayer.pause() } }, diff --git a/src/playbackstrategy/legacyplayeradapter.test.js b/src/playbackstrategy/legacyplayeradapter.test.js index d442c884..2b17f3ee 100644 --- a/src/playbackstrategy/legacyplayeradapter.test.js +++ b/src/playbackstrategy/legacyplayeradapter.test.js @@ -1,6 +1,59 @@ -import WindowTypes from "../models/windowtypes" +import { LiveSupport } from "../models/livesupport" +import { ManifestType } from "../models/manifesttypes" import MediaState from "../models/mediastate" -import LegacyAdaptor from "./legacyplayeradapter" +import LegacyAdapter from "./legacyplayeradapter" +import LiveGlitchCurtain from "./liveglitchcurtain" + +jest.mock("../playbackstrategy/liveglitchcurtain") + +/** + * Note: The default 'seekable' API is identical to the API for on-demand/static streams + * + * @param {LiveSupport} liveSupport + * @returns {Object} A mocked media player instance + */ +function createMockMediaPlayer(liveSupport = LiveSupport.SEEKABLE) { + const eventCallbacks = [] + + function dispatchEvent(event) { + for (const callback of eventCallbacks) { + callback(event) + } + } + + const basePlayer = { + dispatchEvent, + addEventCallback: jest + .fn() + .mockImplementation((component, callback) => eventCallbacks.push(callback.bind(component))), + beginPlayback: jest.fn(), + getMimeType: jest.fn(), + getPlayerElement: jest.fn(), + getState: jest.fn(), + getSource: jest.fn(), + initialiseMedia: jest.fn(), + removeAllEventCallbacks: jest.fn(), + reset: jest.fn(), + stop: jest.fn(), + } + + if (liveSupport === LiveSupport.RESTARTABLE) { + return { ...basePlayer, beginPlaybackFrom: jest.fn() } + } + + if (liveSupport === LiveSupport.SEEKABLE) { + return { + ...basePlayer, + beginPlaybackFrom: jest.fn(), + getSeekableRange: jest.fn(), + playFrom: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + } + } + + return basePlayer +} const mockGlitchCurtain = { showCurtain: jest.fn(), @@ -8,7 +61,10 @@ const mockGlitchCurtain = { tearDown: jest.fn(), } -jest.mock("../playbackstrategy/liveglitchcurtain", () => jest.fn(() => mockGlitchCurtain)) +const mockMediaSources = { + time: jest.fn(), + currentSource: jest.fn().mockReturnValue(""), +} const MediaPlayerEvent = { STOPPED: "stopped", // Event fired when playback is stopped @@ -33,143 +89,179 @@ const MediaPlayerState = { } describe("Legacy Playback Adapter", () => { - let legacyAdaptor - let mediaPlayer - let videoContainer - let eventCallbacks - let testTimeCorrection = 0 - const cdnArray = [] + let mediaElement + let playbackElement - beforeEach(() => { - window.bigscreenPlayer = { - playbackStrategy: "stubstrategy", - } + const originalCreateElement = document.createElement - mediaPlayer = { - addEventCallback: jest.fn(), - initialiseMedia: jest.fn(), - beginPlayback: jest.fn(), - getState: jest.fn(), - resume: jest.fn(), - getPlayerElement: jest.fn(), - getSeekableRange: jest.fn(), - reset: jest.fn(), - stop: jest.fn(), - removeAllEventCallbacks: jest.fn(), - getSource: jest.fn(), - getMimeType: jest.fn(), - beginPlaybackFrom: jest.fn(), - playFrom: jest.fn(), - pause: jest.fn(), - setPlaybackRate: jest.fn(), - getPlaybackRate: jest.fn(), - } - }) + const mockMediaElement = (mediaKind) => { + const mediaEl = originalCreateElement.call(document, mediaKind) - afterEach(() => { - jest.clearAllMocks() - delete window.bigscreenPlayer - testTimeCorrection = 0 - }) + mediaEl.__mocked__ = true - // Options = windowType, playableDevice, timeCorrection, deviceReplacement, isUHD - function setUpLegacyAdaptor(opts) { - const mockMediaSources = { - time: () => ({ timeCorrectionSeconds: testTimeCorrection }), - currentSource: () => cdnArray[0].url, - } + jest.spyOn(mediaEl, "addEventListener") + jest.spyOn(mediaEl, "removeEventListener") - const options = opts || {} + return mediaEl + } - cdnArray.push({ url: "testcdn1/test/", cdn: "cdn1" }) + beforeAll(() => { + LiveGlitchCurtain.mockReturnValue(mockGlitchCurtain) - const windowType = options.windowType || WindowTypes.STATIC + jest.spyOn(document, "createElement").mockImplementation((elementType) => { + if (["audio", "video"].includes(elementType)) { + mediaElement = mockMediaElement(elementType) + return mediaElement + } - mediaPlayer.addEventCallback.mockImplementation((component, callback) => { - eventCallbacks = (event) => callback.call(component, event) + return originalCreateElement.call(document, elementType) }) + }) - videoContainer = document.createElement("div") - videoContainer.id = "app" - document.body.appendChild(videoContainer) - legacyAdaptor = LegacyAdaptor(mockMediaSources, windowType, videoContainer, options.isUHD, mediaPlayer) - } + beforeEach(() => { + jest.clearAllMocks() - describe("transitions", () => { - it("should pass back possible transitions", () => { - setUpLegacyAdaptor() + window.bigscreenPlayer = { + playbackStrategy: "stubstrategy", + } - expect(legacyAdaptor.transitions).toEqual( - expect.objectContaining({ - canBePaused: expect.any(Function), - canBeStopped: expect.any(Function), - canBeginSeek: expect.any(Function), - canResume: expect.any(Function), - }) - ) - }) + playbackElement = originalCreateElement.call(document, "div") + + mockMediaSources.time.mockReturnValue({ manifestType: ManifestType.STATIC }) + }) + + afterEach(() => { + delete window.bigscreenPlayer }) describe("load", () => { it("should initialise the media player", () => { - setUpLegacyAdaptor() + mockMediaSources.currentSource.mockReturnValueOnce("mock://media.src/") + + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.load("video/mp4", 0) + legacyAdapter.load("video/mp4", 0) expect(mediaPlayer.initialiseMedia).toHaveBeenCalledWith( "video", - cdnArray[0].url, + "mock://media.src/", "video/mp4", - videoContainer, - expect.any(Object) + playbackElement, + { + disableSeekSentinel: false, + disableSentinels: false, + } ) }) - it("should begin playback from the passed in start time + time correction if we are watching live on a restartable device", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + it("should begin playback from zero if no start time is passed in for a static stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.STATIC }) - legacyAdaptor.load("video/mp4", 50) + const mediaPlayer = createMockMediaPlayer() - expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(60) - }) - - it("should begin playback at the live point if no start time is passed in and we are watching live on a playable device", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING, playableDevice: true }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.load("video/mp4") + legacyAdapter.load("video/mp4", null) - expect(mediaPlayer.beginPlayback).toHaveBeenCalledWith() + expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(0) }) - it("should begin playback from the passed in start time if we are watching vod", () => { - setUpLegacyAdaptor() + it("should begin playback from the passed in start time for a static stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.STATIC }) + + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("video/mp4", 50) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", 50) expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(50) }) - it("should begin playback from if no start time is passed in when watching vod", () => { - setUpLegacyAdaptor() + it.each([LiveSupport.PLAYABLE, LiveSupport.RESTARTABLE, LiveSupport.SEEKABLE])( + "should begin playback at the live point for a dynamic stream on a %s device", + (liveSupport) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - legacyAdaptor.load("video/mp4") + const mediaPlayer = createMockMediaPlayer(liveSupport) - expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(0) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) + + expect(mediaPlayer.beginPlayback).toHaveBeenCalledTimes(1) + } + ) + + it("should ignore start time and begin playback at the live point for a dynamic stream on a playable device", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", 50) + + expect(mediaPlayer.beginPlayback).toHaveBeenCalledTimes(1) }) - it("should disable sentinels if we are watching UHD and configured to do so", () => { + it.each([LiveSupport.RESTARTABLE, LiveSupport.SEEKABLE])( + "should begin playback from the start time for a dynamic stream on a %s device", + (liveSupport) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer(liveSupport) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", 50) + + expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(50) + } + ) + + it.each([LiveSupport.RESTARTABLE, LiveSupport.SEEKABLE])( + "should begin playback from .1s for a dynamic stream on a %s device when start time is zero", + (liveSupport) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer(liveSupport) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", 0) + + expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(0.1) + } + ) + + it("should disable all sentinels for a dynamic UHD stream when configured to do so", () => { window.bigscreenPlayer.overrides = { liveUhdDisableSentinels: true, } - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING, isUHD: true }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + mockMediaSources.currentSource.mockReturnValueOnce("mock://media.src/") + + const isUHD = true + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("video/mp4") + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, isUHD, mediaPlayer) - const properties = mediaPlayer.initialiseMedia.mock.calls[mediaPlayer.initialiseMedia.mock.calls.length - 1][4] + legacyAdapter.load("video/mp4") - expect(properties.disableSentinels).toBe(true) + expect(mediaPlayer.initialiseMedia).toHaveBeenCalledWith( + "video", + "mock://media.src/", + "video/mp4", + playbackElement, + { + disableSeekSentinel: false, + disableSentinels: true, + } + ) }) it("should disable seek sentinels if we are configured to do so", () => { @@ -177,483 +269,673 @@ describe("Legacy Playback Adapter", () => { disableSeekSentinel: true, } - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.currentSource.mockReturnValueOnce("mock://media.src/") + + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load(cdnArray, "video/mp4") + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - const properties = mediaPlayer.initialiseMedia.mock.calls[mediaPlayer.initialiseMedia.mock.calls.length - 1][4] + legacyAdapter.load("video/mp4") - expect(properties.disableSeekSentinel).toBe(true) + expect(mediaPlayer.initialiseMedia).toHaveBeenCalledWith( + "video", + "mock://media.src/", + "video/mp4", + playbackElement, + { + disableSeekSentinel: true, + disableSentinels: false, + } + ) }) }) describe("play", () => { - describe("if the player supports playFrom()", () => { + describe("when the player supports playFrom()", () => { it("should play from 0 if the stream has ended", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + legacyAdapter.load("video/mp4", null) - legacyAdaptor.play() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) + + legacyAdapter.play() expect(mediaPlayer.playFrom).toHaveBeenCalledWith(0) }) - it("should play from the current time if we are not ended, paused or buffering", () => { - setUpLegacyAdaptor() - - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) + it.each([ManifestType.STATIC, ManifestType.DYNAMIC])( + "should play from the current time for a %s stream when we are not ended, paused or buffering", + (manifestType) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType }) - legacyAdaptor.play() + const mediaPlayer = createMockMediaPlayer() - expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should play from the current time on live if we are not ended, paused or buffering", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) - legacyAdaptor.play() + legacyAdapter.play() - expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) - }) + expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) + } + ) }) - describe("if the player does not support playFrom()", () => { - beforeEach(() => { - delete mediaPlayer.playFrom - }) - + describe("when the player does not support playFrom()", () => { it("should not throw an error when playback has completed", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + legacyAdapter.load("video/mp4", null) - expect(() => legacyAdaptor.play()).not.toThrow() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) + + expect(() => legacyAdapter.play()).not.toThrow() }) - it("should do nothing if we are not ended, paused or buffering", () => { - setUpLegacyAdaptor() + it("should not throw an error if we are not ended or in a state where player can resume", () => { + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) + legacyAdapter.load("video/mp4", null) - expect(() => legacyAdaptor.play()).not.toThrow() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) + + expect(() => legacyAdapter.play()).not.toThrow() }) + }) - it("should resume if the player is in a paused or buffering state", () => { - setUpLegacyAdaptor() + describe("player resume support", () => { + it("should resume when in a state where player can resume", () => { + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) mediaPlayer.getState.mockReturnValue(MediaPlayerState.PAUSED) - legacyAdaptor.play() + legacyAdapter.play() expect(mediaPlayer.resume).toHaveBeenCalledWith() }) + + it("should not throw when the player does not support resume", () => { + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + mediaPlayer.getState.mockReturnValue(MediaPlayerState.PAUSED) + + expect(() => legacyAdapter.play()).not.toThrow() + }) }) }) describe("pause", () => { it("should pause when we don't need to delay a call to pause", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.pause({ disableAutoResume: false }) + legacyAdapter.pause() - expect(mediaPlayer.pause).toHaveBeenCalledWith({ disableAutoResume: false }) + expect(mediaPlayer.pause).toHaveBeenCalledTimes(1) }) it("should not pause when we need to delay a call to pause", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - legacyAdaptor.load("application/dash+xml") + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.setCurrentTime(10) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + legacyAdapter.load("application/dash+xml", null) + + // seeking + legacyAdapter.setCurrentTime(10) mediaPlayer.getState.mockReturnValue(MediaPlayerState.BUFFERING) - legacyAdaptor.pause({ disableAutoResume: false }) + legacyAdapter.pause() - expect(mediaPlayer.pause).not.toHaveBeenCalledWith({ disableAutoResume: false }) + expect(mediaPlayer.pause).not.toHaveBeenCalled() }) }) describe("isPaused", () => { + it("should be set to true on initialisation", () => { + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) + + expect(legacyAdapter.isPaused()).toBeUndefined() + }) + it("should be set to false once we have loaded", () => { - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - legacyAdaptor.load("video/mp4") + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(false) + expect(legacyAdapter.isPaused()).toBe(false) }) it("should be set to false when we call play", () => { - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - legacyAdaptor.play() + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(false) + legacyAdapter.play() + + expect(legacyAdapter.isPaused()).toBe(false) }) it("should be set to false when we get a playing event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.PLAYING }) + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(false) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING }) + + expect(legacyAdapter.isPaused()).toBe(false) }) it("should be set to false when we get a time update event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.STATUS }) + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(false) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS }) + + expect(legacyAdapter.isPaused()).toBe(false) }) it("should be set to true when we get a paused event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(true) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) + + expect(legacyAdapter.isPaused()).toBe(true) }) it("should be set to true when we get a ended event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isPaused()).toBe(true) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) + + expect(legacyAdapter.isPaused()).toBe(true) }) }) describe("isEnded", () => { it("should be set to false on initialisation of the strategy", () => { - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - expect(legacyAdaptor.isEnded()).toBe(false) + expect(legacyAdapter.isEnded()).toBe(false) }) it("should be set to true when we get an ended event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + legacyAdapter.load("video/mp4", null) - expect(legacyAdaptor.isEnded()).toBe(true) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) + + expect(legacyAdapter.isEnded()).toBe(true) }) it("should be set to false when we a playing event is recieved", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.PLAYING }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING }) - expect(legacyAdaptor.isEnded()).toBe(false) + expect(legacyAdapter.isEnded()).toBe(false) }) it("should be set to false when we get a waiting event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.BUFFERING }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.BUFFERING }) - expect(legacyAdaptor.isEnded()).toBe(false) + expect(legacyAdapter.isEnded()).toBe(false) }) it("should be set to true when we get a completed event then false when we start initial buffering from playing", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(legacyAdaptor.isEnded()).toBe(true) + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.BUFFERING }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) - expect(legacyAdaptor.isEnded()).toBe(false) + expect(legacyAdapter.isEnded()).toBe(true) + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.BUFFERING }) + + expect(legacyAdapter.isEnded()).toBe(false) }) }) describe("getDuration", () => { it("should be set to 0 on initialisation", () => { - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - expect(legacyAdaptor.getDuration()).toBe(0) + expect(legacyAdapter.getDuration()).toBe(0) }) it("should be updated by the playing event duration when the duration is undefined or 0", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.PLAYING, duration: 10 }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING, duration: 10 }) - expect(legacyAdaptor.getDuration()).toBe(10) + expect(legacyAdapter.getDuration()).toBe(10) }) it("should use the local duration when the value is not undefined or 0", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() - eventCallbacks({ type: MediaPlayerEvent.PLAYING, duration: 10 }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(legacyAdaptor.getDuration()).toBe(10) + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.PLAYING, duration: 20 }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING, duration: 10 }) - expect(legacyAdaptor.getDuration()).toBe(10) + expect(legacyAdapter.getDuration()).toBe(10) + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING, duration: 20 }) + + expect(legacyAdapter.getDuration()).toBe(10) }) }) describe("getPlayerElement", () => { it("should return the mediaPlayer element", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) const videoElement = document.createElement("video") mediaPlayer.getPlayerElement.mockReturnValue(videoElement) - expect(legacyAdaptor.getPlayerElement()).toEqual(videoElement) + expect(legacyAdapter.getPlayerElement()).toEqual(videoElement) }) }) describe("getSeekableRange", () => { - it("should return the start as 0 and the end as the duration for vod", () => { - setUpLegacyAdaptor() + it("should return the start as 0 and the end as the duration for a static stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.STATIC }) - eventCallbacks({ type: MediaPlayerEvent.PLAYING, duration: 10 }) + const mediaPlayer = createMockMediaPlayer() - expect(legacyAdaptor.getSeekableRange()).toEqual({ start: 0, end: 10 }) - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should return the start/end from the player - time correction", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING, playableDevice: false }) + legacyAdapter.load("video/mp4", null) - mediaPlayer.getSeekableRange.mockReturnValue({ start: 110, end: 1010 }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING, duration: 10 }) - expect(legacyAdaptor.getSeekableRange()).toEqual({ start: 100, end: 1000 }) + expect(legacyAdapter.getSeekableRange()).toEqual({ start: 0, end: 10 }) }) - it("should return the start/end from the player when the time correction is 0", () => { - testTimeCorrection = 0 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING, playableDevice: false }) + it("should return the start/end from the player for a dynamic stream on a seekable device", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer(LiveSupport.SEEKABLE) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) - mediaPlayer.getSeekableRange.mockReturnValue({ start: 100, end: 1000 }) + mediaPlayer.getSeekableRange.mockReturnValue({ start: 100, end: 200 }) - expect(legacyAdaptor.getSeekableRange()).toEqual({ start: 100, end: 1000 }) + expect(legacyAdapter.getSeekableRange()).toEqual({ start: 100, end: 200 }) }) + + it.each([LiveSupport.PLAYABLE, LiveSupport.RESTARTABLE])( + "should return null for a dynamic stream on a %s device", + (liveSupport) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer(liveSupport) + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.load("video/mp4", null) + + expect(legacyAdapter.getSeekableRange()).toBeNull() + } + ) }) describe("getCurrentTime", () => { - it("should be set when we get a playing event", () => { - setUpLegacyAdaptor() + it("should be undefined on initialisation", () => { + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - eventCallbacks({ type: MediaPlayerEvent.PLAYING, currentTime: 10 }) - - expect(legacyAdaptor.getCurrentTime()).toBe(10) + expect(legacyAdapter.getCurrentTime()).toBeUndefined() }) - it("should be set with time correction when we get a playing event", () => { - testTimeCorrection = 5 - setUpLegacyAdaptor({ windowType: WindowTypes.STATIC }) + it("should be set when we get a playing event", () => { + const mediaPlayer = createMockMediaPlayer() - eventCallbacks({ type: MediaPlayerEvent.PLAYING, currentTime: 10 }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(legacyAdaptor.getCurrentTime()).toBe(5) + legacyAdapter.load("video/mp4", null) + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PLAYING, currentTime: 10 }) + + expect(legacyAdapter.getCurrentTime()).toBe(10) }) it("should be set when we get a time update event", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) - - expect(legacyAdaptor.getCurrentTime()).toBe(10) - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should be set with time correction when we get a time update event", () => { - testTimeCorrection = 5 - setUpLegacyAdaptor({ windowType: WindowTypes.STATIC }) + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS, currentTime: 10 }) - expect(legacyAdaptor.getCurrentTime()).toBe(5) + expect(legacyAdapter.getCurrentTime()).toBe(10) }) }) describe("setCurrentTime", () => { - it("should set isEnded to false", () => { - setUpLegacyAdaptor() + it("should update currentTime to the time value passed in", () => { + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) - legacyAdaptor.setCurrentTime(10) + legacyAdapter.setCurrentTime(10) - expect(legacyAdaptor.isEnded()).toBe(false) + expect(legacyAdapter.getCurrentTime()).toBe(10) }) - it("should update currentTime to the time value passed in", () => { - setUpLegacyAdaptor() + it("should set isEnded to false", () => { + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.setCurrentTime(10) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(legacyAdaptor.getCurrentTime()).toBe(10) + legacyAdapter.load("video/mp4", null) + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.COMPLETE }) + + legacyAdapter.setCurrentTime(10) + + expect(legacyAdapter.isEnded()).toBe(false) }) describe("if the player supports playFrom()", () => { - it("should seek to the time value passed in", () => { - setUpLegacyAdaptor() + it.each([ManifestType.STATIC, ManifestType.DYNAMIC])( + "should seek to the time value passed in for a %s stream", + (manifestType) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType }) - legacyAdaptor.setCurrentTime(10) + const mediaPlayer = createMockMediaPlayer() - expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should seek to the time value passed in + time correction", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + legacyAdapter.setCurrentTime(10) - legacyAdaptor.setCurrentTime(10) + expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) + } + ) - expect(mediaPlayer.playFrom).toHaveBeenCalledWith(20) - }) + it("should pause after a seek if we were in a paused state", () => { + const mediaPlayer = createMockMediaPlayer() - it("should pause after a seek if we were in a paused state, not watching dash and on a capable device", () => { - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + legacyAdapter.load("application/vnd.apple.mpegurl", null) - legacyAdaptor.setCurrentTime(10) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) + + legacyAdapter.setCurrentTime(10) expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) expect(mediaPlayer.pause).toHaveBeenCalledWith() }) - it("should not pause after a seek if we are not on capable device and watching a dash stream", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + it("should not pause after a seek if we were in a paused state on a dynamic DASH stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("application/dash+xml") + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + legacyAdapter.load("application/dash+xml", null) - legacyAdaptor.setCurrentTime(10) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) + + legacyAdapter.setCurrentTime(10) expect(mediaPlayer.playFrom).toHaveBeenCalledWith(10) - expect(mediaPlayer.pause).not.toHaveBeenCalledWith() + expect(mediaPlayer.pause).not.toHaveBeenCalled() + }) + + it("should attempt to restart playback from seek time when a seek exits with an error on a dynamic DASH stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + // any dynamic DASH (based on mime type) stream will have handleErrorOnExitingSeek as true when instantiating this module, + // handleErrorOnExitingSeek true will cause exitingSeek to be true on a call to setCurrentTime + legacyAdapter.load("application/dash+xml", null) + legacyAdapter.setCurrentTime(10) + + mediaPlayer.getSource.mockReturnValueOnce("mock://media.src/") + mediaPlayer.getMimeType.mockReturnValueOnce("application/dash+xml") + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.ERROR }) + + expect(mediaPlayer.reset).toHaveBeenCalled() + expect(mediaPlayer.initialiseMedia).toHaveBeenNthCalledWith( + 2, + "video", + "mock://media.src/", + "application/dash+xml", + playbackElement, + { + disableSeekSentinel: false, + disableSentinels: false, + } + ) + expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(10) }) }) describe("if the player does not support playFrom()", () => { - beforeEach(() => { - delete mediaPlayer.playFrom - }) + it.each([ManifestType.STATIC, ManifestType.DYNAMIC])( + "should not throw an error for a %s stream", + (manifestType) => { + mockMediaSources.time.mockReturnValueOnce({ manifestType }) + + const legacyAdapter = LegacyAdapter( + mockMediaSources, + playbackElement, + false, + createMockMediaPlayer(LiveSupport.PLAYABLE) + ) + + expect(() => legacyAdapter.setCurrentTime(10)).not.toThrow() + } + ) - it("should not throw an Error", () => { - setUpLegacyAdaptor() + it("should remain paused if we were in a paused state", () => { + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) - expect(() => legacyAdaptor.setCurrentTime(10)).not.toThrow() - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should not throw an error for live", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + legacyAdapter.load("application/vnd.apple.mpegurl", null) - expect(() => legacyAdaptor.setCurrentTime(10)).not.toThrow() - }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) - it("should remain paused if we were in a paused state, not watching dash and on a capable device", () => { - setUpLegacyAdaptor() + legacyAdapter.setCurrentTime(10) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + expect(legacyAdapter.isPaused()).toBe(true) + }) - legacyAdaptor.setCurrentTime(10) + it("should remain paused after a seek no-op if we were in a paused state on a dynamic DASH stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - expect(legacyAdaptor.isPaused()).toBe(true) - }) + const mediaPlayer = createMockMediaPlayer(LiveSupport.PLAYABLE) - it("should not pause after a no-op seek if we are not on capable device and watching a dash stream", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.load("application/dash+xml") + legacyAdapter.load("application/dash+xml", null) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) - legacyAdaptor.setCurrentTime(10) + legacyAdapter.setCurrentTime(10) - expect(mediaPlayer.pause).not.toHaveBeenCalledWith() + expect(legacyAdapter.isPaused()).toBe(true) }) }) }) describe("Playback Rate", () => { + function createMockOnDemandPlayer() { + return { ...createMockMediaPlayer(LiveSupport.SEEKABLE), getPlaybackRate: jest.fn(), setPlaybackRate: jest.fn() } + } + it("calls through to the mediaPlayers setPlaybackRate function", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockOnDemandPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.setPlaybackRate(2) + legacyAdapter.setPlaybackRate(2) expect(mediaPlayer.setPlaybackRate).toHaveBeenCalledWith(2) }) it("calls through to the mediaPlayers getPlaybackRate function and returns correct value", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockOnDemandPlayer() + mediaPlayer.getPlaybackRate.mockReturnValue(1.5) - const rate = legacyAdaptor.getPlaybackRate() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(mediaPlayer.getPlaybackRate).toHaveBeenCalledWith() - expect(rate).toBe(1.5) + expect(legacyAdapter.getPlaybackRate()).toBe(1.5) + expect(mediaPlayer.getPlaybackRate).toHaveBeenCalled() }) it("getPlaybackRate returns 1.0 if mediaPlayer does not have getPlaybackRate function", () => { - mediaPlayer = { - addEventCallback: jest.fn(), - } - setUpLegacyAdaptor() + const legacyAdapter = LegacyAdapter( + mockMediaSources, + playbackElement, + false, + createMockMediaPlayer(LiveSupport.PLAYABLE) + ) - expect(legacyAdaptor.getPlaybackRate()).toBe(1) + expect(legacyAdapter.getPlaybackRate()).toBe(1) + }) + }) + + describe("transitions", () => { + it("should pass back possible transitions", () => { + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) + + expect(legacyAdapter.transitions).toEqual( + expect.objectContaining({ + canBePaused: expect.any(Function), + canBeStopped: expect.any(Function), + canBeginSeek: expect.any(Function), + canResume: expect.any(Function), + }) + ) }) }) describe("reset", () => { it("should reset the player", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.reset() + legacyAdapter.reset() expect(mediaPlayer.reset).toHaveBeenCalledWith() }) it("should stop the player if we are not in an unstoppable state", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.reset() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.reset() expect(mediaPlayer.stop).toHaveBeenCalledWith() }) it("should not stop the player if we in an unstoppable state", () => { - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) mediaPlayer.getState.mockReturnValue(MediaPlayerState.EMPTY) - legacyAdaptor.reset() + legacyAdapter.reset() expect(mediaPlayer.stop).not.toHaveBeenCalledWith() }) }) describe("tearDown", () => { - beforeEach(() => { - setUpLegacyAdaptor() + it("should remove all event callbacks", () => { + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.tearDown() - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) + + legacyAdapter.tearDown() - it("should remove all event callbacks", () => { expect(mediaPlayer.removeAllEventCallbacks).toHaveBeenCalledWith() }) it("should set isPaused to true", () => { - expect(legacyAdaptor.isPaused()).toBe(true) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) + + legacyAdapter.tearDown() + + expect(legacyAdapter.isPaused()).toBe(true) }) it("should return isEnded as false", () => { - expect(legacyAdaptor.isEnded()).toBe(false) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, createMockMediaPlayer()) + + legacyAdapter.tearDown() + + expect(legacyAdapter.isEnded()).toBe(false) }) }) @@ -665,151 +947,128 @@ describe("Legacy Playback Adapter", () => { }) it("should show curtain for a live restart and we get a seek-attempted event", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("video/mp4", 10) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + legacyAdapter.load("application/vnd.apple.mpegurl", 10) - expect(mockGlitchCurtain.showCurtain).toHaveBeenCalledWith() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + + expect(mockGlitchCurtain.showCurtain).toHaveBeenCalled() }) it("should show curtain for a live restart to 0 and we get a seek-attempted event", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) + + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("video/mp4", 0) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + legacyAdapter.load("application/vnd.apple.mpegurl", 0) - expect(mockGlitchCurtain.showCurtain).toHaveBeenCalledWith() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + + expect(mockGlitchCurtain.showCurtain).toHaveBeenCalled() }) it("should not show curtain when playing from the live point and we get a seek-attempted event", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - legacyAdaptor.load("video/mp4") + const mediaPlayer = createMockMediaPlayer() - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - expect(mockGlitchCurtain.showCurtain).not.toHaveBeenCalledWith() + legacyAdapter.load("application/vnd.apple.mpegurl", null) + + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + + expect(mockGlitchCurtain.showCurtain).not.toHaveBeenCalled() }) it("should show curtain when the forceBeginPlaybackToEndOfWindow config is set and the playback type is live", () => { window.bigscreenPlayer.overrides.forceBeginPlaybackToEndOfWindow = true - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + const mediaPlayer = createMockMediaPlayer() - expect(mockGlitchCurtain.showCurtain).toHaveBeenCalledWith() - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should not show curtain when the config overide is not set and we are playing live", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + legacyAdapter.load("application/vnd.apple.mpegurl", null) - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) - expect(mockGlitchCurtain.showCurtain).not.toHaveBeenCalledWith() + expect(mockGlitchCurtain.showCurtain).toHaveBeenCalled() }) - it("should hide the curtain when we get a seek-finished event", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) - - legacyAdaptor.load("video/mp4", 0) - - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) - - expect(mockGlitchCurtain.showCurtain).toHaveBeenCalledWith() - - eventCallbacks({ type: MediaPlayerEvent.SEEK_FINISHED }) - - expect(mockGlitchCurtain.hideCurtain).toHaveBeenCalledWith() - }) + it("should not show curtain when the config overide is not set and we are playing live", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - it("should tear down the curtain on strategy tearDown if it has been shown", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + const mediaPlayer = createMockMediaPlayer() - legacyAdaptor.load("video/mp4", 0) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) + legacyAdapter.load("application/vnd.apple.mpegurl", null) - legacyAdaptor.tearDown() + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) - expect(mockGlitchCurtain.tearDown).toHaveBeenCalledWith() + expect(mockGlitchCurtain.showCurtain).not.toHaveBeenCalledWith() }) - }) - - describe("dash live on error after exiting seek", () => { - it("should have called reset on the player", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) - // set up the values handleErrorOnExitingSeek && exitingSeek so they are truthy then fire an error event so we restart. - legacyAdaptor.load("application/dash+xml") - - legacyAdaptor.setCurrentTime(10) + it("should hide the curtain when we get a seek-finished event", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - eventCallbacks({ type: MediaPlayerEvent.ERROR }) + const mediaPlayer = createMockMediaPlayer() - expect(mediaPlayer.reset).toHaveBeenCalledWith() - }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - it("should initialise the player", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + legacyAdapter.load("application/vnd.apple.mpegurl", 0) - legacyAdaptor.load("application/dash+xml") + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) - legacyAdaptor.setCurrentTime(10) + expect(mockGlitchCurtain.showCurtain).toHaveBeenCalled() - eventCallbacks({ type: MediaPlayerEvent.ERROR }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_FINISHED }) - expect(mediaPlayer.initialiseMedia).toHaveBeenCalledWith( - "video", - cdnArray[0].url, - "application/dash+xml", - videoContainer, - expect.any(Object) - ) + expect(mockGlitchCurtain.hideCurtain).toHaveBeenCalled() }) - it("should begin playback from the currentTime", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) - - legacyAdaptor.load("application/dash+xml") - - legacyAdaptor.setCurrentTime(10) - - eventCallbacks({ type: MediaPlayerEvent.ERROR }) + it("should tear down the curtain on strategy tearDown if it has been shown", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(10) - }) + const mediaPlayer = createMockMediaPlayer() - it("should begin playback from the currentTime + time correction", () => { - testTimeCorrection = 10 - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.load("application/dash+xml") + legacyAdapter.load("application/vnd.apple.mpegurl", 0) - legacyAdaptor.setCurrentTime(10) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.SEEK_ATTEMPTED }) - eventCallbacks({ type: MediaPlayerEvent.ERROR }) + legacyAdapter.tearDown() - expect(mediaPlayer.beginPlaybackFrom).toHaveBeenCalledWith(20) + expect(mockGlitchCurtain.tearDown).toHaveBeenCalled() }) }) - describe("delay pause until after seek", () => { - it("should pause the player if we were in a paused state on dash live", () => { - setUpLegacyAdaptor({ windowType: WindowTypes.SLIDING }) + describe("handling delaying pause until after a successful seek", () => { + it("should pause the player if we were in a paused state on a dynamic DASH stream", () => { + mockMediaSources.time.mockReturnValueOnce({ manifestType: ManifestType.DYNAMIC }) - legacyAdaptor.load("application/dash+xml") + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + legacyAdapter.load("application/dash+xml", null) - legacyAdaptor.setCurrentTime(10) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) + + legacyAdapter.setCurrentTime(10) expect(mediaPlayer.pause).not.toHaveBeenCalledWith() - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10, seekableRange: { start: 5 } }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS, currentTime: 10, seekableRange: { start: 5 } }) expect(mediaPlayer.pause).toHaveBeenCalledWith() }) @@ -819,98 +1078,75 @@ describe("Legacy Playback Adapter", () => { pauseOnExitSeek: true, } - setUpLegacyAdaptor() + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - legacyAdaptor.load("video/mp4") + legacyAdapter.load("video/mp4", null) - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.PAUSED }) - legacyAdaptor.setCurrentTime(10) + legacyAdapter.setCurrentTime(10) expect(mediaPlayer.pause).not.toHaveBeenCalledWith() - eventCallbacks({ type: MediaPlayerEvent.STATUS, currentTime: 10, seekableRange: { start: 5 } }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS, currentTime: 10, seekableRange: { start: 5 } }) expect(mediaPlayer.pause).toHaveBeenCalledWith() }) }) - describe("events", () => { - it("should publish a playing event", () => { - setUpLegacyAdaptor() - - const eventCallbackSpy = jest.fn() - legacyAdaptor.addEventCallback(this, eventCallbackSpy) - - eventCallbacks({ type: MediaPlayerEvent.PLAYING }) - - expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.PLAYING) - }) - - it("should publish a paused event", () => { - setUpLegacyAdaptor() - - const eventCallbackSpy = jest.fn() - legacyAdaptor.addEventCallback(this, eventCallbackSpy) - - eventCallbacks({ type: MediaPlayerEvent.PAUSED }) - - expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.PAUSED) - }) - - it("should publish a buffering event", () => { - setUpLegacyAdaptor() - - const eventCallbackSpy = jest.fn() - legacyAdaptor.addEventCallback(this, eventCallbackSpy) - - eventCallbacks({ type: MediaPlayerEvent.BUFFERING }) - - expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.WAITING) - }) - - it("should publish an ended event", () => { - setUpLegacyAdaptor() + describe("responding to media player events", () => { + it.each([ + [MediaState.PLAYING, MediaPlayerEvent.PLAYING], + [MediaState.PAUSED, MediaPlayerEvent.PAUSED], + [MediaState.WAITING, MediaPlayerEvent.BUFFERING], + [MediaState.ENDED, MediaPlayerEvent.COMPLETE], + ])("should report media state %i for a %s event", (expectedMediaState, eventType) => { + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - const eventCallbackSpy = jest.fn() - legacyAdaptor.addEventCallback(this, eventCallbackSpy) + const onEvent = jest.fn() + legacyAdapter.addEventCallback(this, onEvent) - eventCallbacks({ type: MediaPlayerEvent.COMPLETE }) + mediaPlayer.dispatchEvent({ type: eventType }) - expect(eventCallbackSpy).toHaveBeenCalledWith(MediaState.ENDED) + expect(onEvent).toHaveBeenCalledWith(expectedMediaState) }) - it("should publish a time update event", () => { - setUpLegacyAdaptor() + it("should report a time update event for a Media Player STATUS event", () => { + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - const timeUpdateCallbackSpy = jest.fn() - legacyAdaptor.addTimeUpdateCallback(this, timeUpdateCallbackSpy) + const onTimeUpdate = jest.fn() + legacyAdapter.addTimeUpdateCallback(this, onTimeUpdate) - eventCallbacks({ type: MediaPlayerEvent.STATUS }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.STATUS }) - expect(timeUpdateCallbackSpy).toHaveBeenCalledWith() + expect(onTimeUpdate).toHaveBeenCalled() }) - it("should publish an error event with default code and message if element does not emit them", () => { - setUpLegacyAdaptor() + it("should report an error event with default code and message if element does not emit them", () => { + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - const errorCallbackSpy = jest.fn() + const onError = jest.fn() + legacyAdapter.addErrorCallback(this, onError) - legacyAdaptor.addErrorCallback(this, errorCallbackSpy) - eventCallbacks({ type: MediaPlayerEvent.ERROR }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.ERROR }) - expect(errorCallbackSpy).toHaveBeenCalledWith({ code: 0, message: "unknown" }) + expect(onError).toHaveBeenCalledWith({ code: 0, message: "unknown" }) }) - it("should publish an error event passing through correct code and message", () => { - setUpLegacyAdaptor() + it("should report an error event passing through correct code and message", () => { + const mediaPlayer = createMockMediaPlayer() + const legacyAdapter = LegacyAdapter(mockMediaSources, playbackElement, false, mediaPlayer) - const errorCallbackSpy = jest.fn() + const onError = jest.fn() + legacyAdapter.addErrorCallback(this, onError) - legacyAdaptor.addErrorCallback(this, errorCallbackSpy) - eventCallbacks({ type: MediaPlayerEvent.ERROR, code: 1, message: "This is a test error" }) + mediaPlayer.dispatchEvent({ type: MediaPlayerEvent.ERROR, code: 1, message: "This is a test error" }) - expect(errorCallbackSpy).toHaveBeenCalledWith({ code: 1, message: "This is a test error" }) + expect(onError).toHaveBeenCalledWith({ code: 1, message: "This is a test error" }) }) }) }) diff --git a/src/playbackstrategy/modifiers/html5.js b/src/playbackstrategy/modifiers/html5.js index 95af5593..e92b6cf6 100644 --- a/src/playbackstrategy/modifiers/html5.js +++ b/src/playbackstrategy/modifiers/html5.js @@ -467,6 +467,8 @@ function Html5() { function onMetadata() { metadataLoaded() + + emitEvent(MediaPlayerBase.EVENT.METADATA) } function exitBuffering() { diff --git a/src/playbackstrategy/modifiers/live/restartable.js b/src/playbackstrategy/modifiers/live/restartable.js index 5cd301cb..472c89ce 100644 --- a/src/playbackstrategy/modifiers/live/restartable.js +++ b/src/playbackstrategy/modifiers/live/restartable.js @@ -1,88 +1,8 @@ import MediaPlayerBase from "../mediaplayerbase" -import WindowTypes from "../../../models/windowtypes" -import DynamicWindowUtils from "../../../dynamicwindowutils" - -function RestartableLivePlayer(mediaPlayer, windowType, mediaSources) { - const fakeTimer = {} - const timeCorrection = mediaSources.time()?.timeCorrectionSeconds || 0 - - let callbacksMap = [] - let startTime - - addEventCallback(this, updateFakeTimer) - - function updateFakeTimer(event) { - if (fakeTimer.wasPlaying && fakeTimer.runningTime) { - fakeTimer.currentTime += (Date.now() - fakeTimer.runningTime) / 1000 - } - - fakeTimer.runningTime = Date.now() - fakeTimer.wasPlaying = event.state === MediaPlayerBase.STATE.PLAYING - } - - function addEventCallback(thisArg, callback) { - function newCallback(event) { - event.currentTime = getCurrentTime() - event.seekableRange = getSeekableRange() - callback(event) - } - - callbacksMap.push({ from: callback, to: newCallback }) - mediaPlayer.addEventCallback(thisArg, newCallback) - } - - function removeEventCallback(thisArg, callback) { - const filteredCallbacks = callbacksMap.filter((cb) => cb.from === callback) - - if (filteredCallbacks.length > 0) { - callbacksMap = callbacksMap.splice(callbacksMap.indexOf(filteredCallbacks[0])) - - mediaPlayer.removeEventCallback(thisArg, filteredCallbacks[0].to) - } - } - - function removeAllEventCallbacks() { - mediaPlayer.removeAllEventCallbacks() - } - - function pause(opts = {}) { - mediaPlayer.pause() - - if (opts.disableAutoResume !== true && windowType === WindowTypes.SLIDING) { - DynamicWindowUtils.autoResumeAtStartOfRange( - getCurrentTime(), - getSeekableRange(), - addEventCallback, - removeEventCallback, - MediaPlayerBase.unpausedEventCheck, - resume - ) - } - } - - function resume() { - mediaPlayer.resume() - } - - function getCurrentTime() { - return fakeTimer.currentTime + timeCorrection - } - - function getSeekableRange() { - const windowLength = (mediaSources.time().windowEndTime - mediaSources.time().windowStartTime) / 1000 - const delta = (Date.now() - startTime) / 1000 - - return { - start: (windowType === WindowTypes.SLIDING ? delta : 0) + timeCorrection, - end: windowLength + delta + timeCorrection, - } - } +function RestartableLivePlayer(mediaPlayer) { return { beginPlayback: () => { - startTime = Date.now() - fakeTimer.currentTime = (mediaSources.time().windowEndTime - mediaSources.time().windowStartTime) / 1000 - if ( window.bigscreenPlayer && window.bigscreenPlayer.overrides && @@ -94,10 +14,8 @@ function RestartableLivePlayer(mediaPlayer, windowType, mediaSources) { } }, - beginPlaybackFrom: (offset) => { - startTime = Date.now() - fakeTimer.currentTime = offset - mediaPlayer.beginPlaybackFrom(offset) + beginPlaybackFrom: (presentationTimeInSeconds) => { + mediaPlayer.beginPlaybackFrom(presentationTimeInSeconds) }, initialiseMedia: (mediaType, sourceUrl, mimeType, sourceContainer, opts) => { @@ -106,20 +24,15 @@ function RestartableLivePlayer(mediaPlayer, windowType, mediaSources) { mediaPlayer.initialiseMedia(mediaSubType, sourceUrl, mimeType, sourceContainer, opts) }, - - pause, - resume, stop: () => mediaPlayer.stop(), reset: () => mediaPlayer.reset(), getState: () => mediaPlayer.getState(), getSource: () => mediaPlayer.getSource(), getMimeType: () => mediaPlayer.getMimeType(), - addEventCallback, - removeEventCallback, - removeAllEventCallbacks, getPlayerElement: () => mediaPlayer.getPlayerElement(), - getCurrentTime, - getSeekableRange, + addEventCallback: (thisArg, callback) => mediaPlayer.addEventCallback(thisArg, callback), + removeEventCallback: (thisArg, callback) => mediaPlayer.removeEventCallback(thisArg, callback), + removeAllEventCallbacks: () => mediaPlayer.removeAllEventCallbacks(), } } diff --git a/src/playbackstrategy/modifiers/live/restartable.test.js b/src/playbackstrategy/modifiers/live/restartable.test.js index c084cfc3..23d16375 100644 --- a/src/playbackstrategy/modifiers/live/restartable.test.js +++ b/src/playbackstrategy/modifiers/live/restartable.test.js @@ -1,62 +1,45 @@ import MediaPlayerBase from "../mediaplayerbase" import RestartableMediaPlayer from "./restartable" -import WindowTypes from "../../../models/windowtypes" describe("restartable HMTL5 Live Player", () => { - const callback = () => {} const sourceContainer = document.createElement("div") - const testTime = { - windowStartTime: 0, - windowEndTime: 100000, - correction: 0, - } - - const mockMediaSources = { - time: () => testTime, - refresh: (success) => success(), - } let player - let restartableMediaPlayer - - function initialiseRestartableMediaPlayer(windowType = WindowTypes.SLIDING) { - restartableMediaPlayer = RestartableMediaPlayer(player, windowType, mockMediaSources) - } - - function wrapperTests(action, expectedReturn) { - if (expectedReturn) { - player[action].mockReturnValue(expectedReturn) - - expect(restartableMediaPlayer[action]()).toBe(expectedReturn) - } else { - restartableMediaPlayer[action]() - - expect(player[action]).toHaveBeenCalledTimes(1) - } - } beforeEach(() => { player = { + addEventCallback: jest.fn(), beginPlayback: jest.fn(), - initialiseMedia: jest.fn(), - stop: jest.fn(), - reset: jest.fn(), + beginPlaybackFrom: jest.fn(), + getMimeType: jest.fn(), + getPlayerElement: jest.fn(), getState: jest.fn(), getSource: jest.fn(), - getMimeType: jest.fn(), - addEventCallback: jest.fn(), + initialiseMedia: jest.fn(), removeEventCallback: jest.fn(), removeAllEventCallbacks: jest.fn(), - getPlayerElement: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - beginPlaybackFrom: jest.fn(), + reset: jest.fn(), + stop: jest.fn(), } }) describe("methods call the appropriate media player methods", () => { + let restartableMediaPlayer + + function wrapperTests(action, expectedReturn) { + if (expectedReturn) { + player[action].mockReturnValue(expectedReturn) + + expect(restartableMediaPlayer[action]()).toBe(expectedReturn) + } else { + restartableMediaPlayer[action]() + + expect(player[action]).toHaveBeenCalledTimes(1) + } + } + beforeEach(() => { - initialiseRestartableMediaPlayer() + restartableMediaPlayer = RestartableMediaPlayer(player) }) it("calls beginPlayback on the media player", () => { @@ -90,18 +73,19 @@ describe("restartable HMTL5 Live Player", () => { it("calls addEventCallback on the media player", () => { const thisArg = "arg" - restartableMediaPlayer.addEventCallback(thisArg, callback) + restartableMediaPlayer.addEventCallback(thisArg, jest.fn()) expect(player.addEventCallback).toHaveBeenCalledWith(thisArg, expect.any(Function)) }) it("calls removeEventCallback on the media player", () => { const thisArg = "arg" + const callback = jest.fn() restartableMediaPlayer.addEventCallback(thisArg, callback) restartableMediaPlayer.removeEventCallback(thisArg, callback) - expect(player.removeEventCallback).toHaveBeenCalledWith(thisArg, expect.any(Function)) + expect(player.removeEventCallback).toHaveBeenCalledWith(thisArg, callback) }) it("calls removeAllEventCallbacks on the media player", () => { @@ -111,173 +95,51 @@ describe("restartable HMTL5 Live Player", () => { it("calls getPlayerElement on the media player", () => { wrapperTests("getPlayerElement", "thisPlayerElement") }) - - it("calls pause on the media player", () => { - wrapperTests("pause") - }) }) describe("should not have methods for", () => { - function isUndefined(action) { - expect(restartableMediaPlayer[action]).toBeUndefined() - } - - beforeEach(() => { - initialiseRestartableMediaPlayer() - }) - it("playFrom", () => { - isUndefined("playFrom") + expect(RestartableMediaPlayer(player).playFrom).toBeUndefined() }) - }) - - describe("should use fake time for", () => { - const timeUpdates = [] - function timeUpdate(opts) { - timeUpdates.forEach((fn) => fn(opts)) - } - - beforeEach(() => { - jest.useFakeTimers() - // jasmine.clock().mockDate() - - player.addEventCallback.mockImplementation((self, callback) => { - timeUpdates.push(callback) - }) - // player.addEventCallback.and.callFake((self, callback) => { - // timeUpdates.push(callback) - // }) - initialiseRestartableMediaPlayer() + it("pause", () => { + expect(RestartableMediaPlayer(player).pause).toBeUndefined() }) - afterEach(() => { - jest.useRealTimers() + it("resume", () => { + expect(RestartableMediaPlayer(player).resume).toBeUndefined() }) - describe("getCurrentTime", () => { - it("should be set on to the window length on beginPlayback", () => { - restartableMediaPlayer.beginPlayback() - - expect(restartableMediaPlayer.getCurrentTime()).toBe(100) - }) - - it("should start at supplied time", () => { - restartableMediaPlayer.beginPlaybackFrom(10) - - expect(restartableMediaPlayer.getCurrentTime()).toBe(10) - }) - - it("should increase when playing", () => { - restartableMediaPlayer.beginPlaybackFrom(10) - - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - - jest.advanceTimersByTime(1000) - - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - - expect(restartableMediaPlayer.getCurrentTime()).toBe(11) - }) - - it("should not increase when paused", () => { - restartableMediaPlayer.beginPlaybackFrom(10) - timeUpdate({ state: MediaPlayerBase.STATE.PAUSED }) - - jest.advanceTimersByTime(1000) - - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - - expect(restartableMediaPlayer.getCurrentTime()).toBe(10) - }) + it("getCurrentTime", () => { + expect(RestartableMediaPlayer(player).getCurrentTime).toBeUndefined() }) - describe("getSeekableRange", () => { - it("should start at the window time", () => { - restartableMediaPlayer.beginPlaybackFrom(0) - - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - - expect(restartableMediaPlayer.getSeekableRange()).toEqual({ start: 0, end: 100 }) - }) - - it("should increase start and end for a sliding window", () => { - restartableMediaPlayer.beginPlaybackFrom(0) - - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - - jest.advanceTimersByTime(1000) - - expect(restartableMediaPlayer.getSeekableRange()).toEqual({ start: 1, end: 101 }) - }) - - it("should only increase end for a growing window", () => { - initialiseRestartableMediaPlayer(WindowTypes.GROWING) - restartableMediaPlayer.beginPlaybackFrom(0) - timeUpdate({ state: MediaPlayerBase.STATE.PLAYING }) - jest.advanceTimersByTime(1000) - - expect(restartableMediaPlayer.getSeekableRange()).toEqual({ start: 0, end: 101 }) - }) + it("getSeekableRange", () => { + expect(RestartableMediaPlayer(player).getSeekableRange).toBeUndefined() }) }) describe("calls the mediaplayer with the correct media Type", () => { - beforeEach(() => { - initialiseRestartableMediaPlayer() - }) - - it("for static video", () => { - restartableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.VIDEO, "", "", sourceContainer) - - expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_VIDEO, - "", - "", - sourceContainer, - undefined - ) - }) + it.each([ + [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.VIDEO], + [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.LIVE_VIDEO], + [MediaPlayerBase.TYPE.LIVE_AUDIO, MediaPlayerBase.TYPE.AUDIO], + ])("should initialise the Media Player with the correct type %s for a %s stream", (expectedType, streamType) => { + const restartableMediaPlayer = RestartableMediaPlayer(player) - it("for live video", () => { - restartableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.LIVE_VIDEO, "", "", sourceContainer) + restartableMediaPlayer.initialiseMedia(streamType, "http://mock.url", "mockMimeType", sourceContainer) expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_VIDEO, - "", - "", - sourceContainer, - undefined - ) - }) - - it("for static audio", () => { - restartableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.AUDIO, "", "", sourceContainer) - - expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_AUDIO, - "", - "", + expectedType, + "http://mock.url", + "mockMimeType", sourceContainer, undefined ) }) }) - describe("Restartable features", () => { - afterEach(() => { - delete window.bigscreenPlayer - }) - - it("begins playback with the desired offset", () => { - initialiseRestartableMediaPlayer() - const offset = 10 - - restartableMediaPlayer.beginPlaybackFrom(offset) - - expect(player.beginPlaybackFrom).toHaveBeenCalledWith(offset) - }) - + describe("beginPlayback", () => { it("should respect config forcing playback from the end of the window", () => { window.bigscreenPlayer = { overrides: { @@ -285,7 +147,7 @@ describe("restartable HMTL5 Live Player", () => { }, } - initialiseRestartableMediaPlayer() + const restartableMediaPlayer = RestartableMediaPlayer(player) restartableMediaPlayer.beginPlayback() @@ -293,168 +155,17 @@ describe("restartable HMTL5 Live Player", () => { }) }) - describe("Pausing and Auto-Resume", () => { - let mockCallback = [] - - function startPlaybackAndPause(startTime, disableAutoResume, windowType) { - initialiseRestartableMediaPlayer(windowType) - - restartableMediaPlayer.beginPlaybackFrom(startTime) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PLAYING }) - } - - restartableMediaPlayer.pause({ disableAutoResume }) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PAUSED }) - } - } - - beforeEach(() => { - jest.useFakeTimers() - - player.addEventCallback.mockImplementation((self, callback) => { - mockCallback.push(callback) - }) - }) - + describe("beginPlaybackFrom", () => { afterEach(() => { - jest.useRealTimers() - mockCallback = [] - }) - - it("calls resume when approaching the start of the buffer", () => { - startPlaybackAndPause(20, false) - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).toHaveBeenCalledWith() - }) - - it("does not call resume when approaching the start of the buffer with the disableAutoResume option", () => { - startPlaybackAndPause(20, true) - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).not.toHaveBeenCalledWith() - }) - - it("does not call resume if paused after the autoresume point", () => { - startPlaybackAndPause(20, false) - - jest.advanceTimersByTime(11 * 1000) - - expect(player.resume).not.toHaveBeenCalledWith() - }) - - it("does not auto-resume if the video is no longer paused", () => { - startPlaybackAndPause(20, false) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PLAYING }) - } - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).not.toHaveBeenCalledTimes(2) - }) - - it("Calls resume when paused is called multiple times", () => { - startPlaybackAndPause(0, false) - - const event = { state: MediaPlayerBase.STATE.PLAYING, currentTime: 25 } - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index](event) - } - - restartableMediaPlayer.pause() - - event.currentTime = 30 - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index](event) - } - - restartableMediaPlayer.pause() - // uses real time to determine pause intervals - // if debugging the time to the buffer will be decreased by the time spent. - jest.advanceTimersByTime(22 * 1000) - - expect(player.resume).toHaveBeenCalledTimes(1) - }) - - it("calls auto-resume immeditetly if paused after an autoresume", () => { - startPlaybackAndPause(20, false) - - jest.advanceTimersByTime(12 * 1000) - - restartableMediaPlayer.pause() - - jest.advanceTimersByTime(1) - - expect(player.resume).toHaveBeenCalledTimes(2) - }) - - it("auto-resume is not cancelled by a paused event state", () => { - startPlaybackAndPause(20, false) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PAUSED }) - } - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).toHaveBeenCalledTimes(1) - }) - - it("will fake pause if attempting to pause at the start of playback", () => { - startPlaybackAndPause(0, false) - - jest.advanceTimersByTime(1) - - expect(player.pause).toHaveBeenCalledTimes(1) - expect(player.resume).toHaveBeenCalledTimes(1) - }) - - it("does not calls autoresume immeditetly if paused after an auto-resume with disableAutoResume options", () => { - startPlaybackAndPause(20, true) - - jest.advanceTimersByTime(12 * 1000) - - jest.advanceTimersByTime(1) - - expect(player.resume).not.toHaveBeenCalledTimes(1) - }) - - it("time spend buffering is deducted when considering time to auto-resume", () => { - startPlaybackAndPause() - - restartableMediaPlayer.beginPlaybackFrom(20) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.BUFFERING, currentTime: 20 }) - } - - jest.advanceTimersByTime(11 * 1000) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PLAYING, currentTime: 20 }) - } - - restartableMediaPlayer.pause() - - jest.advanceTimersByTime(3 * 1000) - - expect(player.resume).toHaveBeenCalledTimes(1) + delete window.bigscreenPlayer }) - it("Should not start auto resume timeout if window type is not SLIDING", () => { - startPlaybackAndPause(20, false, WindowTypes.GROWING) + it("begins playback with the desired offset", () => { + const restartableMediaPlayer = RestartableMediaPlayer(player) - jest.advanceTimersByTime(12 * 1000) + restartableMediaPlayer.beginPlaybackFrom(10) - expect(player.resume).not.toHaveBeenCalled() + expect(player.beginPlaybackFrom).toHaveBeenCalledWith(10) }) }) }) diff --git a/src/playbackstrategy/modifiers/live/seekable.js b/src/playbackstrategy/modifiers/live/seekable.js index 8250f4d5..b9d1d5bb 100644 --- a/src/playbackstrategy/modifiers/live/seekable.js +++ b/src/playbackstrategy/modifiers/live/seekable.js @@ -1,10 +1,25 @@ import MediaPlayerBase from "../mediaplayerbase" -import WindowTypes from "../../../models/windowtypes" -import DynamicWindowUtils from "../../../dynamicwindowutils" +import { autoResumeAtStartOfRange } from "../../../dynamicwindowutils" +import TimeShiftDetector from "../../../utils/timeshiftdetector" -function SeekableLivePlayer(mediaPlayer, windowType) { +function SeekableLivePlayer(mediaPlayer) { const AUTO_RESUME_WINDOW_START_CUSHION_SECONDS = 8 + const timeShiftDetector = TimeShiftDetector(() => { + if (getState() !== MediaPlayerBase.STATE.PAUSED) { + return + } + + startAutoResumeTimeout() + }) + + mediaPlayer.addEventCallback(null, (event) => { + if (event.type === MediaPlayerBase.EVENT.METADATA) { + // Avoid observing the seekable range before metadata loads + timeShiftDetector.observe(getSeekableRange) + } + }) + function addEventCallback(thisArg, callback) { mediaPlayer.addEventCallback(thisArg, callback) } @@ -21,72 +36,88 @@ function SeekableLivePlayer(mediaPlayer, windowType) { mediaPlayer.resume() } + function getState() { + return mediaPlayer.getState() + } + + function getSeekableRange() { + return mediaPlayer.getSeekableRange() + } + + function reset() { + timeShiftDetector.disconnect() + mediaPlayer.reset() + } + + function stop() { + timeShiftDetector.disconnect() + mediaPlayer.stop() + } + + function startAutoResumeTimeout() { + autoResumeAtStartOfRange( + mediaPlayer.getCurrentTime(), + mediaPlayer.getSeekableRange(), + addEventCallback, + removeEventCallback, + MediaPlayerBase.unpausedEventCheck, + resume + ) + } + return { - initialiseMedia: function initialiseMedia(mediaType, sourceUrl, mimeType, sourceContainer, opts) { - if (mediaType === MediaPlayerBase.TYPE.AUDIO) { - mediaType = MediaPlayerBase.TYPE.LIVE_AUDIO - } else { - mediaType = MediaPlayerBase.TYPE.LIVE_VIDEO - } + initialiseMedia: function initialiseMedia(mediaKind, sourceUrl, mimeType, sourceContainer, opts) { + const mediaType = + mediaKind === MediaPlayerBase.TYPE.AUDIO ? MediaPlayerBase.TYPE.LIVE_AUDIO : MediaPlayerBase.TYPE.LIVE_VIDEO mediaPlayer.initialiseMedia(mediaType, sourceUrl, mimeType, sourceContainer, opts) }, beginPlayback: function beginPlayback() { - if ( - window.bigscreenPlayer && - window.bigscreenPlayer.overrides && - window.bigscreenPlayer.overrides.forceBeginPlaybackToEndOfWindow - ) { + if (window.bigscreenPlayer?.overrides?.forceBeginPlaybackToEndOfWindow) { mediaPlayer.beginPlaybackFrom(Infinity) } else { mediaPlayer.beginPlayback() } }, - beginPlaybackFrom: function beginPlaybackFrom(offset) { - mediaPlayer.beginPlaybackFrom(offset) + beginPlaybackFrom: function beginPlaybackFrom(presentationTimeInSeconds) { + mediaPlayer.beginPlaybackFrom(presentationTimeInSeconds) }, - playFrom: function playFrom(offset) { - mediaPlayer.playFrom(offset) + playFrom: function playFrom(presentationTimeInSeconds) { + mediaPlayer.playFrom(presentationTimeInSeconds) }, - pause: function pause(opts) { - const secondsUntilStartOfWindow = mediaPlayer.getCurrentTime() - mediaPlayer.getSeekableRange().start - opts = opts || {} - - if (opts.disableAutoResume) { - mediaPlayer.pause() - } else if (secondsUntilStartOfWindow <= AUTO_RESUME_WINDOW_START_CUSHION_SECONDS) { + pause: function pause() { + if ( + mediaPlayer.getCurrentTime() - mediaPlayer.getSeekableRange().start <= + AUTO_RESUME_WINDOW_START_CUSHION_SECONDS + ) { mediaPlayer.toPaused() mediaPlayer.toPlaying() - } else { - mediaPlayer.pause() - if (windowType === WindowTypes.SLIDING) { - DynamicWindowUtils.autoResumeAtStartOfRange( - mediaPlayer.getCurrentTime(), - mediaPlayer.getSeekableRange(), - addEventCallback, - removeEventCallback, - MediaPlayerBase.unpausedEventCheck, - resume - ) - } + + return + } + + mediaPlayer.pause() + + if (timeShiftDetector.isSeekableRangeSliding()) { + startAutoResumeTimeout() } }, - resume: resume, - stop: () => mediaPlayer.stop(), - reset: () => mediaPlayer.reset(), - getState: () => mediaPlayer.getState(), + resume, + stop, + reset, + getState, getSource: () => mediaPlayer.getSource(), getCurrentTime: () => mediaPlayer.getCurrentTime(), - getSeekableRange: () => mediaPlayer.getSeekableRange(), + getSeekableRange, getMimeType: () => mediaPlayer.getMimeType(), - addEventCallback: addEventCallback, - removeEventCallback: removeEventCallback, - removeAllEventCallbacks: removeAllEventCallbacks, + addEventCallback, + removeEventCallback, + removeAllEventCallbacks, getPlayerElement: () => mediaPlayer.getPlayerElement(), getLiveSupport: () => MediaPlayerBase.LIVE_SUPPORT.SEEKABLE, } diff --git a/src/playbackstrategy/modifiers/live/seekable.test.js b/src/playbackstrategy/modifiers/live/seekable.test.js index fb83acf4..1f65034a 100644 --- a/src/playbackstrategy/modifiers/live/seekable.test.js +++ b/src/playbackstrategy/modifiers/live/seekable.test.js @@ -1,57 +1,91 @@ import MediaPlayerBase from "../mediaplayerbase" import SeekableMediaPlayer from "./seekable" -import WindowTypes from "../../../models/windowtypes" +import TimeShiftDetector from "../../../utils/timeshiftdetector" +import { autoResumeAtStartOfRange } from "../../../dynamicwindowutils" + +jest.mock("../../../dynamicwindowutils") +jest.mock("../../../utils/timeshiftdetector") + +function createMockMediaPlayer() { + const eventCallbacks = [] + + function dispatchEvent(event) { + for (const callback of eventCallbacks) { + callback(event) + } + } + + return { + dispatchEvent, + addEventCallback: jest + .fn() + .mockImplementation((component, callback) => eventCallbacks.push(callback.bind(component))), + removeEventCallback: jest.fn(), + removeAllEventCallbacks: jest.fn(), + beginPlayback: jest.fn(), + initialiseMedia: jest.fn(), + stop: jest.fn(), + reset: jest.fn(), + getState: jest.fn(), + getSource: jest.fn(), + getMimeType: jest.fn(), + getPlayerElement: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + beginPlaybackFrom: jest.fn(), + playFrom: jest.fn(), + getCurrentTime: jest.fn(), + getSeekableRange: jest.fn(), + toPaused: jest.fn(), + toPlaying: jest.fn(), + } +} + +const mockTimeShiftDetector = { + disconnect: jest.fn(), + isSeekableRangeSliding: jest.fn(), + observe: jest.fn(), + // Mock function to fake time shift detection + triggerTimeShiftDetected: jest.fn(), +} describe("Seekable HMTL5 Live Player", () => { const callback = () => {} const sourceContainer = document.createElement("div") let player - let seekableMediaPlayer - - function wrapperTests(action, expectedReturn) { - if (expectedReturn) { - player[action].mockReturnValue(expectedReturn) - expect(seekableMediaPlayer[action]()).toBe(expectedReturn) - } else { - seekableMediaPlayer[action]() + beforeAll(() => { + TimeShiftDetector.mockImplementation((onceTimeShiftDetected) => { + mockTimeShiftDetector.triggerTimeShiftDetected.mockImplementation(() => onceTimeShiftDetected()) - expect(player[action]).toHaveBeenCalledTimes(1) - } - } - - function initialiseSeekableMediaPlayer(windowType) { - seekableMediaPlayer = SeekableMediaPlayer(player, windowType) - } + return mockTimeShiftDetector + }) + }) beforeEach(() => { - player = { - beginPlayback: jest.fn(), - initialiseMedia: jest.fn(), - stop: jest.fn(), - reset: jest.fn(), - getState: jest.fn(), - getSource: jest.fn(), - getMimeType: jest.fn(), - addEventCallback: jest.fn(), - removeEventCallback: jest.fn(), - removeAllEventCallbacks: jest.fn(), - getPlayerElement: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - beginPlaybackFrom: jest.fn(), - playFrom: jest.fn(), - getCurrentTime: jest.fn(), - getSeekableRange: jest.fn(), - toPaused: jest.fn(), - toPlaying: jest.fn(), - } + jest.clearAllMocks() + + player = createMockMediaPlayer() }) describe("methods call the appropriate media player methods", () => { + let seekableMediaPlayer + + function wrapperTests(action, expectedReturn) { + if (expectedReturn) { + player[action].mockReturnValue(expectedReturn) + + expect(seekableMediaPlayer[action]()).toBe(expectedReturn) + } else { + seekableMediaPlayer[action]() + + expect(player[action]).toHaveBeenCalledTimes(1) + } + } + beforeEach(() => { - initialiseSeekableMediaPlayer() + seekableMediaPlayer = SeekableMediaPlayer(player) }) it("calls beginPlayback on the media player", () => { @@ -145,7 +179,7 @@ describe("Seekable HMTL5 Live Player", () => { }, } - initialiseSeekableMediaPlayer() + const seekableMediaPlayer = SeekableMediaPlayer(player) seekableMediaPlayer.beginPlayback() @@ -153,242 +187,197 @@ describe("Seekable HMTL5 Live Player", () => { }) }) - describe("calls the mediaplayer with the correct media Type", () => { - beforeEach(() => { - initialiseSeekableMediaPlayer() - }) - - it("for static video", () => { - seekableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.VIDEO, "", "", sourceContainer) + describe("initialise the mediaplayer", () => { + it.each([ + [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.VIDEO], + [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.LIVE_VIDEO], + [MediaPlayerBase.TYPE.LIVE_AUDIO, MediaPlayerBase.TYPE.AUDIO], + ])("should initialise the Media Player with the correct type %s for a %s stream", (expectedType, streamType) => { + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia(streamType, "http://mock.url", "mockMimeType", sourceContainer) expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_VIDEO, - "", - "", + expectedType, + "http://mock.url", + "mockMimeType", sourceContainer, undefined ) }) + }) - it("for live video", () => { - seekableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.LIVE_VIDEO, "", "", sourceContainer) + describe("pause", () => { + it("should call pause on the Media Player when attempting to pause more than 8 seconds from the start of the seekable range", () => { + player.getCurrentTime.mockReturnValue(10) + player.getSeekableRange.mockReturnValue({ start: 0 }) - expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_VIDEO, - "", - "", - sourceContainer, - undefined + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer ) - }) - it("for static audio", () => { - seekableMediaPlayer.initialiseMedia(MediaPlayerBase.TYPE.AUDIO, "", "", sourceContainer) + seekableMediaPlayer.beginPlaybackFrom(0) + seekableMediaPlayer.pause() - expect(player.initialiseMedia).toHaveBeenCalledWith( - MediaPlayerBase.TYPE.LIVE_AUDIO, - "", - "", - sourceContainer, - undefined - ) + expect(player.pause).toHaveBeenCalledTimes(1) }) - }) - - describe("Pausing and Auto-Resume", () => { - let mockCallback = [] - - function startPlaybackAndPause(startTime, disableAutoResume) { - seekableMediaPlayer.beginPlaybackFrom(startTime || 0) - seekableMediaPlayer.pause({ disableAutoResume }) - } - - beforeEach(() => { - jest.useFakeTimers() - - initialiseSeekableMediaPlayer(WindowTypes.SLIDING) + it("will 'fake pause' if attempting to pause within 8 seconds of the start of the seekable range", () => { + player.getCurrentTime.mockReturnValue(7) player.getSeekableRange.mockReturnValue({ start: 0 }) - player.getCurrentTime.mockReturnValue(20) - - player.addEventCallback.mockImplementation((self, callback) => { - mockCallback.push(callback) - }) - }) - - afterEach(() => { - jest.useRealTimers() - mockCallback = [] - }) - it("calls resume when approaching the start of the buffer", () => { - startPlaybackAndPause(20, false) - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).toHaveBeenCalledWith() - }) - - it("does not call resume when approaching the start of the buffer with the disableAutoResume option", () => { - startPlaybackAndPause(20, true) + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - jest.advanceTimersByTime(11 * 1000) + seekableMediaPlayer.beginPlaybackFrom(7) + seekableMediaPlayer.pause() - expect(player.resume).not.toHaveBeenCalledWith() + expect(player.toPaused).toHaveBeenCalledTimes(1) + expect(player.toPlaying).toHaveBeenCalledTimes(1) + expect(player.pause).not.toHaveBeenCalled() }) + }) - it("does not call resume if paused after the auto resume point", () => { - startPlaybackAndPause(20, false) + describe("Auto-Resume", () => { + it("provides the seekable range to the time shift detector once metadata loaded", () => { + player.getSeekableRange.mockReturnValue({ start: 0, end: 10 }) - jest.advanceTimersByTime(11 * 1000) + const seekableMediaPlayer = SeekableMediaPlayer(player) - expect(player.resume).not.toHaveBeenCalledWith() - }) - - it("does not auto-resume if the video is no longer paused", () => { - startPlaybackAndPause(20, false) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PLAYING }) - } + expect(mockTimeShiftDetector.observe).not.toHaveBeenCalled() - jest.advanceTimersByTime(12 * 1000) + player.dispatchEvent({ type: MediaPlayerBase.EVENT.METADATA }) - expect(player.resume).not.toHaveBeenCalled() + expect(mockTimeShiftDetector.observe).toHaveBeenCalledWith(seekableMediaPlayer.getSeekableRange) }) - it("Calls resume when paused is called multiple times", () => { - startPlaybackAndPause(0, false) + it("should start auto-resume timeout when pausing and Time Shift Detector returns true for sliding", () => { + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(true) - const event = { state: MediaPlayerBase.STATE.PLAYING, currentTime: 25 } - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index](event) - } - - seekableMediaPlayer.pause() + player.getCurrentTime.mockReturnValue(10) + player.getSeekableRange.mockReturnValue({ start: 0, end: 100 }) - event.currentTime = 30 - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index](event) - } + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) + seekableMediaPlayer.beginPlaybackFrom(0) seekableMediaPlayer.pause() - // uses real time to determine pause intervals - // if debugging the time to the buffer will be decreased by the time spent. - jest.advanceTimersByTime(22 * 1000) - expect(player.resume).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).toHaveBeenCalledWith( + 10, + { start: 0, end: 100 }, + expect.any(Function), + expect.any(Function), + expect.any(Function), + seekableMediaPlayer.resume + ) }) - it("calls auto-resume immeditetly if paused after an autoresume", () => { - startPlaybackAndPause(20, false) + it("should not start auto-resume timeout when Time Shift Detector returns false for sliding", () => { + mockTimeShiftDetector.isSeekableRangeSliding.mockReturnValueOnce(false) - jest.advanceTimersByTime(12 * 1000) + player.getCurrentTime.mockReturnValue(10) + player.getSeekableRange.mockReturnValue({ start: 0 }) - player.getSeekableRange.mockReturnValue({ start: 12 }) + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) + seekableMediaPlayer.beginPlaybackFrom(0) seekableMediaPlayer.pause() - jest.advanceTimersByTime(1) - - expect(player.resume).toHaveBeenCalledTimes(1) - expect(player.toPaused).toHaveBeenCalledTimes(1) - expect(player.toPlaying).toHaveBeenCalledTimes(1) - }) - - it("does not calls autoresume immeditetly if paused after an auto-resume with disableAutoResume options", () => { - startPlaybackAndPause(20, true) - - jest.advanceTimersByTime(12 * 1000) - player.getSeekableRange.mockReturnValue({ start: 12 }) - - jest.advanceTimersByTime(1) - - expect(player.resume).not.toHaveBeenCalledTimes(1) - }) - - it("auto-resume is not cancelled by a paused event state", () => { - startPlaybackAndPause(20, false) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PAUSED }) - } - - jest.advanceTimersByTime(12 * 1000) - - expect(player.resume).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).not.toHaveBeenCalled() }) - it("auto-resume is not cancelled by a status event", () => { - startPlaybackAndPause(20, false) - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ type: MediaPlayerBase.EVENT.STATUS }) - } + it("should start auto-resume timeout when Time Shift Detector callback fires while paused", () => { + player.getCurrentTime.mockReturnValue(10) + player.getSeekableRange.mockReturnValue({ start: 0 }) - jest.advanceTimersByTime(12 * 1000) + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - expect(player.resume).toHaveBeenCalledTimes(1) - }) + player.getState.mockReturnValueOnce(MediaPlayerBase.STATE.PAUSED) - it("will fake pause if attempting to pause at the start of playback", () => { - player.getCurrentTime.mockReturnValue(0) - startPlaybackAndPause(0, false) + mockTimeShiftDetector.triggerTimeShiftDetected() - expect(player.toPaused).toHaveBeenCalledTimes(1) - expect(player.toPlaying).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).toHaveBeenCalledTimes(1) }) - it("time spend buffering is deducted when considering time to auto-resume", () => { - startPlaybackAndPause(0, false) - - seekableMediaPlayer.resume() - player.resume.mockClear() - - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.BUFFERING, currentTime: 20 }) - } - - jest.advanceTimersByTime(11 * 1000) + it("should not start auto-resume timeout when Time Shift Detector callback fires while unpaused", () => { + player.getCurrentTime.mockReturnValue(10) + player.getSeekableRange.mockReturnValue({ start: 0 }) - for (let index = 0; index < mockCallback.length; index++) { - mockCallback[index]({ state: MediaPlayerBase.STATE.PLAYING, currentTime: 20 }) - } - player.getSeekableRange.mockReturnValue({ start: 20 }) + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - seekableMediaPlayer.pause() + player.getState.mockReturnValueOnce(MediaPlayerBase.STATE.PLAYING) - jest.advanceTimersByTime(3 * 1000) + mockTimeShiftDetector.triggerTimeShiftDetected() - expect(player.toPlaying).toHaveBeenCalledTimes(1) + expect(autoResumeAtStartOfRange).not.toHaveBeenCalled() }) - it("should not call autoresume immeditetly if paused after an auto-resume with disableAutoResume options", () => { - startPlaybackAndPause(20, true) - - jest.advanceTimersByTime(12 * 1000) + it("should disconect from the Time Shift Detector on a call to reset", () => { + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - jest.advanceTimersByTime(1) + seekableMediaPlayer.reset() - expect(player.resume).not.toHaveBeenCalledTimes(1) + expect(mockTimeShiftDetector.disconnect).toHaveBeenCalled() }) - it("Should auto resume when paused after a seek", () => { - player.getSeekableRange.mockReturnValue({ start: 0 }) - player.getCurrentTime.mockReturnValue(100) - - startPlaybackAndPause(100, false) - - player.getCurrentTime.mockReturnValue(50) - player.getState.mockReturnValue(MediaPlayerBase.STATE.PAUSED) - - seekableMediaPlayer.playFrom(50) - - seekableMediaPlayer.pause() + it("should disconect from the Time Shift Detector on a call to stop", () => { + const seekableMediaPlayer = SeekableMediaPlayer(player) + seekableMediaPlayer.initialiseMedia( + MediaPlayerBase.TYPE.VIDEO, + "http://mock.url", + "mockMimeType", + sourceContainer + ) - jest.advanceTimersByTime(42 * 1000) + seekableMediaPlayer.stop() - expect(player.resume).toHaveBeenCalledTimes(1) + expect(mockTimeShiftDetector.disconnect).toHaveBeenCalled() }) }) }) diff --git a/src/playbackstrategy/modifiers/mediaplayerbase.js b/src/playbackstrategy/modifiers/mediaplayerbase.js index 7e173908..b641c01b 100644 --- a/src/playbackstrategy/modifiers/mediaplayerbase.js +++ b/src/playbackstrategy/modifiers/mediaplayerbase.js @@ -16,6 +16,7 @@ const EVENT = { COMPLETE: "complete", // Event fired when media playback has reached the end of the media ERROR: "error", // Event fired when an error condition occurs STATUS: "status", // Event fired regularly during play + METADATA: "metadata", // Event fired when media element loaded the init segment(s) SENTINEL_ENTER_BUFFERING: "sentinel-enter-buffering", // Event fired when a sentinel has to act because the device has started buffering but not reported it SENTINEL_EXIT_BUFFERING: "sentinel-exit-buffering", // Event fired when a sentinel has to act because the device has finished buffering but not reported it SENTINEL_PAUSE: "sentinel-pause", // Event fired when a sentinel has to act because the device has failed to pause when expected @@ -36,16 +37,12 @@ const TYPE = { } function unpausedEventCheck(event) { - if (event && event.state && event.type !== "status") { - return event.state !== STATE.PAUSED - } else { - return undefined - } + return event != null && event.state && event.type !== "status" ? event.state !== STATE.PAUSED : undefined } export default { - STATE: STATE, - EVENT: EVENT, - TYPE: TYPE, - unpausedEventCheck: unpausedEventCheck, + STATE, + EVENT, + TYPE, + unpausedEventCheck, } diff --git a/src/playbackstrategy/modifiers/samsungmaple.js b/src/playbackstrategy/modifiers/samsungmaple.js index 51029b0b..999a23d4 100644 --- a/src/playbackstrategy/modifiers/samsungmaple.js +++ b/src/playbackstrategy/modifiers/samsungmaple.js @@ -316,6 +316,8 @@ function SamsungMaple() { start: 0, end: playerPlugin.GetDuration() / 1000, } + + _emitEvent(MediaPlayerBase.EVENT.METADATA) } function _onCurrentTime(timeInMillis) { diff --git a/src/playbackstrategy/modifiers/samsungstreaming.js b/src/playbackstrategy/modifiers/samsungstreaming.js index d738b744..ce87f895 100644 --- a/src/playbackstrategy/modifiers/samsungstreaming.js +++ b/src/playbackstrategy/modifiers/samsungstreaming.js @@ -575,6 +575,7 @@ function SamsungStreaming() { switch (eventType) { case PlayerEventCodes.STREAM_INFO_READY: _updateRange() + _emitEvent(MediaPlayerBase.EVENT.METADATA) break case PlayerEventCodes.CURRENT_PLAYBACK_TIME: diff --git a/src/playbackstrategy/modifiers/samsungstreaming2015.js b/src/playbackstrategy/modifiers/samsungstreaming2015.js index 50b53a2e..5140fa0a 100644 --- a/src/playbackstrategy/modifiers/samsungstreaming2015.js +++ b/src/playbackstrategy/modifiers/samsungstreaming2015.js @@ -561,6 +561,7 @@ function SamsungStreaming2015() { switch (eventType) { case PlayerEventCodes.STREAM_INFO_READY: _updateRange() + _emitEvent(MediaPlayerBase.EVENT.METADATA) break case PlayerEventCodes.CURRENT_PLAYBACK_TIME: diff --git a/src/playbackstrategy/msestrategy.js b/src/playbackstrategy/msestrategy.js index 377b121a..d7fb9fc7 100644 --- a/src/playbackstrategy/msestrategy.js +++ b/src/playbackstrategy/msestrategy.js @@ -1,18 +1,15 @@ import { MediaPlayer } from "dashjs/index_mediaplayerOnly" import MediaState from "../models/mediastate" -import WindowTypes from "../models/windowtypes" import DebugTool from "../debugger/debugtool" import MediaKinds from "../models/mediakinds" import Plugins from "../plugins" import ManifestModifier from "../manifest/manifestmodifier" import LiveSupport from "../models/livesupport" -import DynamicWindowUtils from "../dynamicwindowutils" -import TimeUtils from "../utils/timeutils" +import { autoResumeAtStartOfRange } from "../dynamicwindowutils" import DOMHelpers from "../domhelpers" import Utils from "../utils/playbackutils" -import buildSourceAnchor, { TimelineZeroPoints } from "../utils/mse/build-source-anchor" import convertTimeRangesToArray from "../utils/mse/convert-timeranges-to-array" -import PauseTriggers from "../models/pausetriggers" +import { ManifestType } from "../models/manifesttypes" const DEFAULT_SETTINGS = { liveDelay: 0, @@ -21,16 +18,16 @@ const DEFAULT_SETTINGS = { function MSEStrategy( mediaSources, - windowType, mediaKind, playbackElement, - isUHD, - customPlayerSettings, - enableBroadcastMixAD, - callBroadcastMixADCallbacks + _isUHD = false, + customPlayerSettings = {}, + enableBroadcastMixAD = false, + callBroadcastMixADCallbacks = null ) { let mediaPlayer let mediaElement + const manifestType = mediaSources.time().manifestType const playerSettings = Utils.merge( { @@ -53,18 +50,14 @@ function MSEStrategy( let errorCallback let timeUpdateCallback - let timeCorrection = mediaSources.time()?.timeCorrectionSeconds || 0 - const seekDurationPadding = isNaN(playerSettings.streaming?.seekDurationPadding) ? DEFAULT_SETTINGS.seekDurationPadding : playerSettings.streaming?.seekDurationPadding const liveDelay = isNaN(playerSettings.streaming?.delay?.liveDelay) ? DEFAULT_SETTINGS.liveDelay : playerSettings.streaming?.delay?.liveDelay - let failoverTime - let failoverZeroPoint - let refreshFailoverTime - let slidingWindowPausedTime = 0 + let failoverPresentationTimeInSeconds + let refreshFailoverPresentationTimeInSeconds let isEnded = false let dashMetrics @@ -158,7 +151,7 @@ function MSEStrategy( isSeeking = false if (isPaused()) { - if (windowType === WindowTypes.SLIDING) { + if (manifestType === ManifestType.DYNAMIC && isSliding()) { startAutoResumeTimeout() } publishMediaState(MediaState.PAUSED) @@ -199,21 +192,17 @@ function MSEStrategy( function onTimeUpdate() { DebugTool.updateElementTime(mediaElement.currentTime) - const currentMpdTimeSeconds = - windowType === WindowTypes.SLIDING - ? mediaPlayer.getDashMetrics().getCurrentDVRInfo(mediaKind)?.time - : mediaElement.currentTime + const currentPresentationTimeInSeconds = mediaElement.currentTime // Note: Multiple consecutive CDN failover logic // A newly loaded video element will always report a 0 time update // This is slightly unhelpful if we want to continue from a later point but consult failoverTime as the source of truth. if ( - typeof currentMpdTimeSeconds === "number" && - isFinite(currentMpdTimeSeconds) && - parseInt(currentMpdTimeSeconds) > 0 + typeof currentPresentationTimeInSeconds === "number" && + isFinite(currentPresentationTimeInSeconds) && + parseInt(currentPresentationTimeInSeconds) > 0 ) { - failoverTime = currentMpdTimeSeconds - failoverZeroPoint = TimelineZeroPoints.MPD + failoverPresentationTimeInSeconds = currentPresentationTimeInSeconds } publishTimeUpdate() @@ -265,8 +254,6 @@ function MSEStrategy( } function manifestDownloadError(mediaError) { - const error = () => publishError(mediaError) - const failoverParams = { isBufferingTimeoutError: false, currentTime: getCurrentTime(), @@ -275,20 +262,22 @@ function MSEStrategy( message: mediaError.message, } - mediaSources.failover(load, error, failoverParams) + mediaSources + .failover(failoverParams) + .then(() => load()) + .catch(() => publishError(mediaError)) } function onManifestLoaded(event) { if (event.data) { DebugTool.info(`Manifest loaded. Duration is: ${event.data.mediaPresentationDuration}`) - const manifest = event.data + let manifest = event.data const representationOptions = window.bigscreenPlayer.representationOptions || {} ManifestModifier.filter(manifest, representationOptions) ManifestModifier.generateBaseUrls(manifest, mediaSources.availableSources()) - manifest.manifestRequestTime = manifestRequestTime - manifest.manifestLoadCount = manifestLoadCount + manifest = { ...manifest, manifestLoadCount, manifestRequestTime } manifestLoadCount = 0 emitManifestInfo(manifest) @@ -301,7 +290,8 @@ function MSEStrategy( function onManifestValidityChange(event) { DebugTool.info(`Manifest validity changed. Duration is: ${event.newDuration}`) - if (windowType === WindowTypes.GROWING) { + + if (manifestType === ManifestType.DYNAMIC) { mediaPlayer.refreshManifest((manifest) => { DebugTool.info(`Manifest Refreshed. Duration is: ${manifest.mediaPresentationDuration}`) }) @@ -309,8 +299,7 @@ function MSEStrategy( } function onStreamInitialised() { - const setMseDuration = window.bigscreenPlayer.overrides && window.bigscreenPlayer.overrides.mseDurationOverride - if (setMseDuration && (windowType === WindowTypes.SLIDING || windowType === WindowTypes.GROWING)) { + if (window.bigscreenPlayer?.overrides?.mseDurationOverride && manifestType === ManifestType.DYNAMIC) { // Workaround for no setLiveSeekableRange/clearLiveSeekableRange mediaPlayer.setMediaDuration(Number.MAX_SAFE_INTEGER) } @@ -414,7 +403,11 @@ function MSEStrategy( } failoverInfo.serviceLocation = event.baseUrl.serviceLocation - mediaSources.failover(log, log, failoverInfo) + + mediaSources.failover(failoverInfo).then( + () => log(), + () => log() + ) } function onServiceLocationAvailable(event) { @@ -486,25 +479,13 @@ function MSEStrategy( return mediaPlayer && mediaPlayer.isReady() ? mediaPlayer.isPaused() : undefined } - function getClampedTime(time, range) { - const isStatic = windowType === WindowTypes.STATIC - const isSliding = windowType === WindowTypes.SLIDING - const clampedRange = { - start: isSliding ? 0 : range.start, - end: isSliding ? mediaPlayer.getDVRWindowSize() : range.end, - correction: isStatic ? seekDurationPadding : Math.max(liveDelay, seekDurationPadding), - } - - return Math.min(Math.max(time, clampedRange.start), clampedRange.end - clampedRange.correction) - } - - function load(mimeType, playbackTime) { + function load(mimeType, presentationTimeInSeconds) { if (mediaPlayer) { - modifySource(refreshFailoverTime || failoverTime, failoverZeroPoint) + modifySource(refreshFailoverPresentationTimeInSeconds || failoverPresentationTimeInSeconds) } else { - failoverTime = playbackTime + failoverPresentationTimeInSeconds = presentationTimeInSeconds setUpMediaElement(playbackElement) - setUpMediaPlayer(playbackTime) + setUpMediaPlayer(presentationTimeInSeconds) setUpMediaListeners() } } @@ -530,7 +511,7 @@ function MSEStrategy( return settings } - function setUpMediaPlayer(playbackTime) { + function setUpMediaPlayer(presentationTimeInSeconds) { const dashSettings = getDashSettings(playerSettings) mediaPlayer = MediaPlayer().create() @@ -544,19 +525,36 @@ function MSEStrategy( }) } - modifySource(playbackTime) + modifySource(presentationTimeInSeconds) } - function modifySource(playbackTime, zeroPoint) { + function modifySource(presentationTimeInSeconds) { const source = mediaSources.currentSource() - const anchor = buildSourceAnchor(playbackTime, zeroPoint, { - windowType, - initialSeekableRangeStartSeconds: mediaSources.time().windowStartTime / 1000, - }) + const anchor = buildSourceAnchor(presentationTimeInSeconds) mediaPlayer.attachSource(`${source}${anchor}`) } + /** + * Calculate time anchor tag for playback within dashjs + * + * Anchor tags applied to the MPD source for playback: + * + * #t=