diff --git a/packages/cxl-ui/package.json b/packages/cxl-ui/package.json index 493fbe6a8..9e9eb7855 100644 --- a/packages/cxl-ui/package.json +++ b/packages/cxl-ui/package.json @@ -32,8 +32,10 @@ "@vaadin/tooltip": "^23.3.7", "@vaadin/vaadin-themable-mixin": "^23.3.7", "cross-env": "~7.0.2", + "crypto-js": "^4.1.1", "headroom.js": "^0.12.0", "imports-loader": "^2.0.0", + "jose": "^4.13.1", "laravel-mix": "^6.0.39", "lit": "^2.2.5", "lodash-es": "^4.17.21", diff --git a/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-shadow.scss b/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-shadow.scss new file mode 100644 index 000000000..ce3bf8992 --- /dev/null +++ b/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-shadow.scss @@ -0,0 +1,147 @@ +@use '~@conversionxl/cxl-lumo-styles/scss/mixins'; +@use '~@conversionxl/cxl-lumo-styles/scss/mq'; + +:host { + background-color: var(--wp--preset--color--white); + box-sizing: border-box; + display: block; + + * { + box-sizing: border-box; + } + + [active] { + background-color: var(--lumo-shade-10pct); + } + + [hidden] { + display: none; + } +} + +:host([captions]) { + #container { + background-color: var(--lumo-shade-5pct); + display: grid; + grid-template-rows: max-content max-content auto; + padding-bottom: var(--lumo-space-m); + } +} + +.captions { + line-height: calc(var(--lumo-line-height-m) * 1.25); + margin: 0 auto; + max-height: 16rem; + max-width: var(--cxl-content-max-width); + + h2, + span { + &:hover { + color: var(--lumo-primary-color); + } + + cursor: var(--captions-cursor, pointer); + } + + mark { + background-color: var(--lumo-shade-10pct); + + &.search-active { + background-color: var(--lumo-primary-color); + color: #fff; + } + } + + span { + padding: var(--lumo-space-xs) calc(var(--lumo-space-xs) / 2); + } +} + +.center { + display: flex; + align-items: center; + justify-content: center; +} + +.cxl-jw-player-container { + height: 100%; +} + +.flex { + display: flex; + height: 100%; +} + +.flex.column { + flex-direction: column; +} + +.flex.grow { + flex-grow: 1; +} + +.gap { + gap: var(--lumo-space-s); +} + +.grid { + display: grid; +} + +.height-50 { + height: 50%; +} + +.height-100 { + height: 100%; +} + +.overflow-hidden { + overflow: hidden; +} + +.padding { + padding: var(--lumo-space-s); +} + +.scroll { + @include mixins.better-webkit-scrollbars(); + + overflow: hidden; + + &:hover { + overflow: auto; + } +} + +.search { + flex-direction: column; + margin: 0 auto; + max-width: var(--cxl-content-max-width); + width: 100%; + + #search-result-count { + font-size: var(--lumo-font-size-s); + } + + vaadin-checkbox { + --lumo-body-text-color: #fff; + + display: none; + } + + vaadin-checkbox:not([checked])::part(checkbox) { + background-color: #fff; + } + + vaadin-text-field { + box-shadow: var(--lumo-box-shadow-xs); + background-color: #fff; + border-radius: var(--lumo-space-xs); + width: 100%; + + &::part(input-field) { + background-color: initial; + } + } +} diff --git a/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-transcript-shadow.scss b/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-transcript-shadow.scss new file mode 100644 index 000000000..769714c22 --- /dev/null +++ b/packages/cxl-ui/scss/cxl-jw-player/cxl-jw-player-transcript-shadow.scss @@ -0,0 +1,3 @@ +:host(:not([hidden])) { + display: block; +} diff --git a/packages/cxl-ui/scss/jw-player/chapter.scss b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-chapter-navigation.scss similarity index 97% rename from packages/cxl-ui/scss/jw-player/chapter.scss rename to packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-chapter-navigation.scss index 83f43539d..54de81403 100644 --- a/packages/cxl-ui/scss/jw-player/chapter.scss +++ b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-chapter-navigation.scss @@ -14,6 +14,10 @@ background: var(--lumo-shade); gap: var(--lumo-space-s); + &[hidden] { + display: none; + } + .close { font-size: var(--lumo-font-size-xs); cursor: pointer; diff --git a/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-nextup.scss b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-nextup.scss new file mode 100644 index 000000000..3a1eb4ef2 --- /dev/null +++ b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-nextup.scss @@ -0,0 +1,35 @@ +cxl-jw-player { + &[wide] { + .jw-nextup-cta-mobile { + display: none; + } + } + + &:not([wide]) { + .jw-nextup-cta { + display: none; + } + } + + .jw-nextup-container { + display: flex; + flex-direction: column; + align-items: flex-end; + + .jw-nextup-cta, + .jw-nextup-cta-mobile { + a { + pointer-events: all; + } + } + + .jw-nextup-cta { + max-width: 400px; + width: 64%; + + vaadin-button { + width: 100%; + } + } + } +} diff --git a/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-transcript.scss b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-transcript.scss new file mode 100644 index 000000000..769714c22 --- /dev/null +++ b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player-transcript.scss @@ -0,0 +1,3 @@ +:host(:not([hidden])) { + display: block; +} diff --git a/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player.scss b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player.scss new file mode 100644 index 000000000..eca744089 --- /dev/null +++ b/packages/cxl-ui/scss/global/cxl-jw-player/cxl-jw-player.scss @@ -0,0 +1,29 @@ +cxl-jw-player { + .jw-player-button { + width: 32px; + fill: rgba(255, 255, 255, 0.8); + + &:hover { + fill: rgba(255, 255, 255, 1); + } + } + + .jw-related-item { + height: 100% !important; /* stylelint-disable-line declaration-no-important */ + } + + .jwplayer:not(.jw-flag-small-player) .jw-related-item-next-up { + .jw-related-item-poster { + height: 100%; + } + + .jw-related-item-title { + position: absolute; + bottom: 0; + } + } +} + +.jw-rightclick-list li:last-of-type { + display: none; +} diff --git a/packages/cxl-ui/scss/jw-player/jw-player.scss b/packages/cxl-ui/scss/jw-player/jw-player.scss deleted file mode 100644 index 5868a2c64..000000000 --- a/packages/cxl-ui/scss/jw-player/jw-player.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use "~@conversionxl/cxl-lumo-styles/scss/mixins"; - -:host { - box-sizing: border-box; - - [active] { - background-color: var(--lumo-shade-10pct); - } -} - -:host([captions]) #container { - grid-template-rows: 1fr max-content 1fr; -} - -.captions { - h2, - span { - &:hover { - color: var(--lumo-primary-color); - } - - cursor: var(--captions-cursor, pointer); - } - - mark { - color: #fff; - background-color: var(--lumo-primary-color); - } - - span { - padding: var(--lumo-space-xs) calc(var(--lumo-space-xs) / 2); - border-radius: var(--lumo-space-xs); - } -} - -.center { - display: flex; - align-items: center; - justify-content: center; -} - -.flex { - display: flex; - height: 100%; -} - -.flex.column { - flex-direction: column; -} - -.flex.grow { - flex-grow: 1; -} - -.gap { - gap: var(--lumo-space-m); -} - -.grid { - display: grid; -} - -.height-50 { - height: 50%; -} - -.height-100 { - height: 100%; -} - -.overflow-hidden { - overflow: hidden; -} - -.padding { - padding: var(--lumo-space-s); -} - -.scroll { - @include mixins.better-webkit-scrollbars(); - - overflow: auto; -} - -.search { - color: #fff; - background-color: #000; - - vaadin-checkbox { - --lumo-body-text-color: #fff; - } - - vaadin-checkbox:not([checked])::part(checkbox) { - background-color: #fff; - } - - vaadin-text-field { - width: 50%; - padding: 0; - background-color: #fff; - border-radius: var(--lumo-space-xs); - - &::part(input-field) { - background-color: initial; - } - } -} diff --git a/packages/cxl-ui/src/components/cxl-course-dialog.js b/packages/cxl-ui/src/components/cxl-course-dialog.js index 45e8b063c..a4a65c110 100644 --- a/packages/cxl-ui/src/components/cxl-course-dialog.js +++ b/packages/cxl-ui/src/components/cxl-course-dialog.js @@ -5,7 +5,6 @@ import { registerGlobalStyles } from '@conversionxl/cxl-lumo-styles/src/utils'; import '@vaadin/button'; import '@vaadin/dialog'; import './cxl-time.js'; -import './jw-player/index.js'; import { dialogFooterRenderer, dialogRenderer } from '@vaadin/dialog/lit.js'; import cxlCourseDialogGlobalStyles from '../styles/global/cxl-course-dialog-css.js'; @@ -64,14 +63,14 @@ export class CXLCourseDialogElement extends LitElement {
${this.video - ? html` ` + >` : ''}

${this.course.description}

diff --git a/packages/cxl-ui/src/components/jw-player/README.md b/packages/cxl-ui/src/components/cxl-jw-player/README.md similarity index 87% rename from packages/cxl-ui/src/components/jw-player/README.md rename to packages/cxl-ui/src/components/cxl-jw-player/README.md index d8d40eb94..0baa00e76 100644 --- a/packages/cxl-ui/src/components/jw-player/README.md +++ b/packages/cxl-ui/src/components/cxl-jw-player/README.md @@ -1,15 +1,16 @@ -# JW Player +# CXL JW Player ## Usage ``` - + > ``` ## Features: @@ -61,13 +62,13 @@ Order is important as each mixin extends the previous one. In this case, `MixinT There are currently two methods which are important to the lifecycle of the component: -`__setup()` +`_setup()` -This method is async and called when the component is first created. You must call `await super.__setup()` at the beginning of this method to make sure each parent class's setup method is called. +This method is async and called when the component is first created. You must call `await super._setup()` at the beginning of this method to make sure each parent class's setup method is called. -`__onTimeListener()` +`_onTimeListener()` -This method is async and called when the player's time changes. As with `__setup()`, you must call `await super.__onTimeListener()` at the beginning of this method. +This method is async and called when the player's time changes. As with `_setup()`, you must call `await super._onTimeListener()` at the beginning of this method. Current mixins available for use: diff --git a/packages/cxl-ui/src/components/cxl-jw-player/cxl-jw-player-transcript/index.js b/packages/cxl-ui/src/components/cxl-jw-player/cxl-jw-player-transcript/index.js new file mode 100644 index 000000000..35a3a47c2 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/cxl-jw-player-transcript/index.js @@ -0,0 +1,21 @@ +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import style from '../../../styles/global/cxl-jw-player/cxl-jw-player-transcript-css'; +import shadowStyle from '../../../styles/cxl-jw-player/cxl-jw-player-transcript-shadow-css'; + +@customElement('cxl-jw-player-transcript') +export class CXLJWPlayerTranscriptElement extends LitElement { + static get styles() { + return [shadowStyle]; + } + + render() { + return html``; + } + + async _setup() { + await super._setup(); + + this._addStyle(style); + } +} diff --git a/packages/cxl-ui/src/components/cxl-jw-player/index.html.js b/packages/cxl-ui/src/components/cxl-jw-player/index.html.js new file mode 100644 index 000000000..8082fe046 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/index.html.js @@ -0,0 +1,58 @@ +import { html, nothing } from 'lit'; +import '@vaadin/icon'; +import '@vaadin/text-field'; + +// eslint-disable-next-line func-names +export const template = function () { + return html` + ${this.ready + ? html` +
+ + +
+ ${this._tracks.map( + (track, index) => + html`${track.data.text + ? html`${track.isChapter + ? html`

+ ${track.data.text} +

` + : html` + + ${track.data.text} + + `}` + : nothing}` + )} +
+
+ ` + : nothing} + ${this.error + ? html`
There was an error loading the video.
` + : nothing} + `; +}; diff --git a/packages/cxl-ui/src/components/cxl-jw-player/index.js b/packages/cxl-ui/src/components/cxl-jw-player/index.js new file mode 100644 index 000000000..dc9143c20 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/index.js @@ -0,0 +1,46 @@ +import { LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import style from '../../styles/global/cxl-jw-player/cxl-jw-player-css'; +import shadowStyle from '../../styles/cxl-jw-player/cxl-jw-player-shadow-css'; +import { template } from './index.html'; +import { + BaseMixin, + ChapterNavigationMixin, + NextUpMixin, + StateMixin, + TranscriptMixin, +} from './mixins'; +import { mixin } from './utility'; + +@customElement('cxl-jw-player') +export class CXLJWPlayerElement extends mixin(LitElement, [ + BaseMixin, + TranscriptMixin, + ChapterNavigationMixin, + NextUpMixin, + StateMixin, +]) { + config = { + height: '100%', + width: '100%', + playbackRateControls: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + skin: { + name: 'cxl-institute', + }, + // stretching: 'uniform', + }; + + static get styles() { + return [shadowStyle]; + } + + render() { + return template.bind(this)(); + } + + async _setup() { + await super._setup(); + + this._addStyle(style); + } +} diff --git a/packages/cxl-ui/src/components/cxl-jw-player/mixins/BaseMixin.js b/packages/cxl-ui/src/components/cxl-jw-player/mixins/BaseMixin.js new file mode 100644 index 000000000..5b14c54e4 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/mixins/BaseMixin.js @@ -0,0 +1,269 @@ +import * as jose from 'jose'; +import { render } from 'lit'; +import { property } from 'lit/decorators.js'; +import { throttle } from 'lodash-es'; +import { parseSync } from 'subtitle'; +import { MD5 } from 'crypto-js'; +import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js'; + +export function BaseMixin(BaseClass) { + class Mixin extends BaseClass { + _baseUrl = 'https://cdn.jwplayer.com/'; + + _boundOnTimeListener; + + _chapters; + + _jwPlayer; + + _jwPlayerContainer; + + _mediaId + + _playlistId; + + // Device Detector media query. + _wideMediaQuery = '(min-width: 750px)'; + + @property({ attribute: 'api-secret', type: String }) apiSecret = ''; + + @property({ attribute: 'error', reflect: true, type: Boolean }) error; + + @property({ attribute: 'is-public', type: Boolean }) isPublic; + + @property({ attribute: 'library-id', type: String }) libraryId; + + @property({ attribute: 'library-source', type: String }) librarySource; + + @property({ attribute: 'media-id', type: String }) mediaId; + + @property({ attribute: 'media-source', type: String }) mediaSource; + + @property({ attribute: 'playlist-id', type: String }) playlistId; + + @property({ attribute: 'playlist-source', type: String }) playlistSource; + + @property({ type: Boolean }) ready; + + // MediaQueryController. + @property({ type: Boolean, reflect: true }) + wide; + + constructor() { + super(); + + this.addController( + new MediaQueryController(this._wideMediaQuery, (matches) => { + this.wide = matches; + }) + ); + } + + async firstUpdated(_changedProperties) { + await super.firstUpdated(_changedProperties); + + await this._beforeSetup(); + this._setup(); + } + + updated(_changedProperties) { + super.updated(_changedProperties); + if (_changedProperties.has('captions') || _changedProperties.has('mediaId')) { + // this._setup(); + } + } + + get _scriptUrl() { + if (!this.libraryId && !this.librarySource) return false; + + let scriptUrl; + + if (this.libraryId) { + if (this.isPublic) { + scriptUrl = `${this._baseUrl}libraries/${this.libraryId}.js`; + } else { + scriptUrl = this.__signedURL(`libraries/${this.libraryId}.js`); + } + } + + if (this.librarySource) { + scriptUrl = this.librarySource; + } + + return scriptUrl; + } + + _addStyle(style) { + const el = document.createElement('style'); + render(style, el); + this.appendChild(el); + } + + // eslint-disable-next-line class-methods-use-this, no-empty-function + _beforeSetup() { + if(this.mediaId) { + this._mediaId = this.mediaId; + } + + if(this.mediaSource) { + const url = new URL(this.mediaSource); + this._mediaId = url.pathname.split('/').pop(); + } + + if(this.playlistId) { + this._playlistId = this.playlistId; + } + + if(this.playlistSource) { + const url = new URL(this.playlistSource); + this._playlistId = url.pathname.split('/').pop(); + } + } + + async _getChapters() { + const playlistItem = this._jwPlayer.getPlaylistItem(); + const chapters = playlistItem.tracks.filter((track) => track.kind === 'chapters'); + + if (chapters.length === 0) { + return []; + } + + const { file } = chapters.length > 0 ? chapters[0] : ''; + const response = await (await fetch(file)).text(); + + return parseSync(response); + } + + async _getMedia() { + if (!this.mediaId && !this.mediaSource) return false; + + let response; + + if (this.mediaId) { + if (this.isPublic) { + response = await fetch(`${this._baseUrl}v2/media/${this.mediaId}`); + } else { + response = await fetch(await this._signedJWTURL(`v2/media/${this.mediaId}`)); + } + } + + if (this.mediaSource) { + response = await fetch(this.mediaSource); + } + + return response.json(); + } + + async _getPlaylist() { + if (!this.playlistId && !this.playlistSource) return false; + + let response; + + if (this.playlistId) { + if (this.isPublic) { + response = await fetch(`${this._baseUrl}v2/playlists/${this.playlistId}`); + } else { + response = await fetch(await this._signedJWTURL(`v2/playlists/${this.playlistId}`)); + } + } + + if (this.playlistSource) { + response = await fetch(this.playlistSource); + } + + return response.json(); + } + + async _loadScript() { + return new Promise(async (resolve) => { + const el = document.createElement('script'); + el.src = this._scriptUrl; + el.onload = () => { + resolve(self.jwplayer); + }; + document.head.appendChild(el); + }); + } + + /** + * Each mixin has the ability to hook onto this method. + */ + + // eslint-disable-next-line class-methods-use-this, no-unused-vars, no-empty-function + async _onReadyListener() { + this.ready = true; + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars, no-empty-function + async _onTimeListener(event) {} + + _registerListeners() { + this._boundOnTimeListener = throttle(this._onTimeListener.bind(this), 1000); + this._jwPlayer.on('time', this._boundOnTimeListener); + } + + /** + * Each mixin has the ability to hook onto this method. + */ + async _setup() { + // Merge configs from `cxlJWPlayerData`. + if (typeof window.cxlJWPlayerData !== 'undefined') { + // eslint-disable-next-line camelcase + const { media_config } = window.cxlJWPlayerData[this.mediaId]; + // eslint-disable-next-line camelcase + this.config = { ...this.config, ...media_config }; + } + + const jwPlayer = await this._loadScript(); + + const el = document.createElement('div'); + this.appendChild(el); + + this._jwPlayer = jwPlayer(el).setup({ + ...this.config, + ...(await this._getMedia()), + ...(await this._getPlaylist()), + }); + + this._jwPlayer.on('setupError', () => { + this.error = true; + }); + + await new Promise((resolve) => { + this._jwPlayer.on('ready', async () => { + await this._onReadyListener(); + + resolve(); + }); + }); + + this._jwPlayerContainer = this._jwPlayer.getContainer(); + + this._registerListeners(); + + this._chapters = await this._getChapters(); + } + + async _signedJWTURL(path) { + const secret = new TextEncoder().encode(this.apiSecret); + const alg = 'HS256'; + const typ = 'JWT'; + + const token = await new jose.SignJWT({ resource: path }) + .setProtectedHeader({ alg, typ }) + .setExpirationTime('2h') + .sign(secret); + + return `${this._baseUrl}${path}?token=${token}`; + } + + __signedURL(path) { + const expires = Math.ceil((new Date().getTime() + 3600) / 300) * 300; + const signature = MD5(`${path}:${expires}:${this.apiSecret}`); + + return `${this._baseUrl}${path}?exp=${expires}&sig=${signature}`; + } + } + + return Mixin; +} diff --git a/packages/cxl-ui/src/components/cxl-jw-player/mixins/NextUpMixin.js b/packages/cxl-ui/src/components/cxl-jw-player/mixins/NextUpMixin.js new file mode 100644 index 000000000..b654ccc58 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/mixins/NextUpMixin.js @@ -0,0 +1,65 @@ +import { html, render } from 'lit'; +import { property } from 'lit/decorators.js'; +import style from '../../../styles/global/cxl-jw-player/cxl-jw-player-nextup-css'; +export function NextUpMixin(BaseClass) { + class Mixin extends BaseClass { + _nextupCTA; + _nextupCTAMobile; + + @property({ attribute: 'nextupoffset', type: String }) nextupoffset = '-100%`'; + + async _beforeSetup() { + await super._beforeSetup(); + + this.config.nextupoffset = this.nextupoffset; + } + + async _setup() { + await super._setup(); + + this._addStyle(style); + + this._nextupCTA = document.createElement('div'); + this._nextupCTA.classList.add('jw-nextup-cta'); + + this._nextupCTAMobile = document.createElement('div'); + this._nextupCTAMobile.classList.add('jw-nextup-cta-mobile'); + + const container = this.querySelector('.jw-nextup-container'); + container.insertBefore(this._nextupCTA, container.firstChild); + container.insertBefore(this._nextupCTAMobile, container.firstChild); + + this._updateNextUp(); + this._jwPlayer.on('playlistItem', this._updateNextUp.bind(this)); + } + + _updateNextUp() { + const playlistItem = this._jwPlayer.getPlaylistItem(); + + if (playlistItem && playlistItem.coursePage) { + render(this._getTemplate(playlistItem), this._nextupCTA); + render(this._getMobileTemplate(playlistItem), this._nextupCTAMobile); + } + } + + // eslint-disable-next-line class-methods-use-this + _getMobileTemplate(playlistItem) { + return html` + + Go to course + + `; + } + + // eslint-disable-next-line class-methods-use-this + _getTemplate(playlistItem) { + return html` + + Go to course + + `; + } + } + + return Mixin; +} diff --git a/packages/cxl-ui/src/components/cxl-jw-player/mixins/StateMixin.js b/packages/cxl-ui/src/components/cxl-jw-player/mixins/StateMixin.js new file mode 100644 index 000000000..11c2aa688 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/mixins/StateMixin.js @@ -0,0 +1,89 @@ +export function StateMixin(BaseClass) { + class Mixin extends BaseClass { + _endpoint; + + _nonce; + + _position = 0; + + _userId; + + async _index() { + if (this._playlistId) { + const index = + localStorage.getItem(`cxl-jw-player-${this._playlistId}-index`) || + this._jwPlayer.getPlaylistIndex(); + + this._jwPlayer.playlistItem(index); + + this._jwPlayer.on('playlistItem', async ({ index }) => { + localStorage.setItem(`cxl-jw-player-${this._playlistId}-index`, index); + }); + } + } + + async _onReadyListener() { + super._onReadyListener(); + + await this._index(); + this._setupPosition(); + this._playbackRate(); + } + + async _setup() { + await super._setup(); + + this._endpoint = `${window.ajaxurl}?action=jwplayer_save_position`; + + if (typeof window.cxl_pum_vars !== 'undefined') { + this._nonce = window.cxl_pum_vars.nonce; + } + + this._jwPlayer.on('complete', () => { + this._position = 0; + localStorage.setItem(`cxl-jw-player-${mediaId}-position`, this._position); + }); + } + + _playbackRate() { + const playbackRate = localStorage.getItem(`cxl-jw-player-playback-rate`); + + if (playbackRate) { + this._jwPlayer.setPlaybackRate(Number(playbackRate)); + } + + this._jwPlayer.on('playbackRateChanged', ({ playbackRate }) => { + localStorage.setItem(`cxl-jw-player-playback-rate`, playbackRate); + }); + } + + _setupPosition() { + if (this._mediaId) { + this._setPosition(); + } + + if (this._playlistId) { + this._jwPlayer.on('playlistItem', async ({ index }) => { + await this._jwPlayer.getPlaylistItemPromise(index); + this._setPosition(); + }); + } + + this._jwPlayer.on('seek time', ({ position }) => { + const mediaId = this._mediaId || this._jwPlayer.getPlaylistItem().mediaid; + localStorage.setItem(`cxl-jw-player-${mediaId}-position`, position); + }); + } + + _setPosition() { + const mediaId = this._mediaId || this._jwPlayer.getPlaylistItem().mediaid; + this._position = localStorage.getItem(`cxl-jw-player-${mediaId}-position`); + + this._jwPlayer.on('firstFrame', () => { + this._jwPlayer.seek(Number(this._position)); + }); + } + } + + return Mixin; +} diff --git a/packages/cxl-ui/src/components/cxl-jw-player/mixins/TranscriptMixin.js b/packages/cxl-ui/src/components/cxl-jw-player/mixins/TranscriptMixin.js new file mode 100644 index 000000000..44feb65e4 --- /dev/null +++ b/packages/cxl-ui/src/components/cxl-jw-player/mixins/TranscriptMixin.js @@ -0,0 +1,227 @@ +import { property, state, query } from 'lit/decorators.js'; +import { debounce } from 'lodash-es'; +import Mark from 'mark.js'; +import { parseSync } from 'subtitle'; + +export function TranscriptMixin(BaseClass) { + class Mixin extends BaseClass { + _debouncedSearch; + + _mark; + + _searchIndex = 0; + + _searchResults = []; + + @property({ reflect: true, type: Boolean }) captions = false; + + @property({ attribute: 'has-captions', reflect: true, type: Boolean }) hasCaptions = false; + + @state() _currentCue = 0; + + @state() _currentTrack = 0; + + @state() _isSearchMinimumLength = false; + + @state() _matches = 0; + + @property({ attribute: 'minimum-search-length', type: Number }) minimumSearchLength = 3; + + @property({ attribute: 'should-scroll', type: Boolean }) shouldScroll = true; + + @query('#search') _searchElement; + + @state() _searchValue; + + @state() _tracks = []; + + constructor() { + super(); + + this._debouncedSearch = debounce(this._search, 300); + } + + async _getCaptions() { + const playlistItem = this._jwPlayer.getPlaylistItem(); + const track = playlistItem.tracks.filter((track) => track.kind === 'captions')[0]; + + if (!track) { + return []; + } + + const response = await (await fetch(track.file)).text(); + + return parseSync(response); + } + + /* eslint-disable array-callback-return, class-methods-use-this, consistent-return, no-return-assign */ + _getCaptionsInChapter(chapters, captions, index) { + return captions.filter((caption) => { + if (caption.data.start >= chapters[index].data.start) { + if (chapters[index + 1]) { + if (caption.data.start <= chapters[index + 1].data.start) { + return caption; + } + } else { + return caption; + } + } + }); + } + /* eslint-enable array-callback-return, class-methods-use-this, consistent-return, no-return-assign */ + + async _getTracks() { + const tracks = []; + + const captions = await this._getCaptions(); + + if (captions.length) { + const chapters = [...[{ data: { start: 0, text: '' } }], ...(await this._getChapters())]; + + chapters.forEach((chapter, index) => { + tracks.push({ ...chapter, ...{ isChapter: true } }); + tracks.push(...this._getCaptionsInChapter(chapters, captions, index)); + }); + } + + return tracks; + } + + _onCaptionClick(e) { + const index = Number(e.currentTarget.dataset.index); + this._jwPlayer.seek(this._tracks[index].data.start / 1000); + } + + _onTimeListener(event) { + super._onTimeListener(event); + + const position = event.position * 1000; // Convert to milliseconds + + this._tracks.forEach(({ data: { end, start } }, index) => { + if (start <= position && end >= position) { + if (this.shouldScroll) { + this._scrollTo(this.renderRoot.querySelector(`[data-index="${index}"]`)); + } + + this._currentTrack = index; + } + }); + } + + _scrollTo(element) { + if (this.shouldScroll) { + const container = this.renderRoot.querySelector('.captions'); + container.scrollTop = element.offsetTop - container.offsetTop; + } + } + + _search() { + this._mark.unmark(); + + if (this._searchElement.value.length >= this.minimumSearchLength) { + this._isSearchMinimumLength = true; + + this._mark.mark(this._searchElement.value, { + done: (total) => { + this._matches = total; + }, + separateWordSearch: false, + }); + } else { + this._isSearchMinimumLength = false; + } + + this._searchResults = this.shadowRoot.querySelectorAll('mark'); + + this._setActive(this._searchResults[0]); + + this._searchIndex++; + } + + _setActive(el) { + this._searchResults.forEach((el) => el.classList.remove('search-active')); + el.classList.add('search-active'); + + this._scrollTo(el); + } + + async _setup() { + await super._setup(); + + this._setupTranscript(); + + this._jwPlayer.on('playlistItem', this._setupTranscript.bind(this)); + + this.shadowRoot.querySelector('vaadin-text-field').addEventListener('keyup', (e) => { + if ('Enter' === e.key) { + if (this._searchResults.length) { + if (this._searchIndex === this._searchResults.length - 1) { + this._searchIndex = 0; + } + + this._setActive(this._searchResults[this._searchIndex]); + + this._searchIndex++; + } + } + }); + } + + async _setupTranscript() { + if (!this._jwPlayer) return; + + this._tracks = []; + + if (this.captions) { + this._tracks = await this._getTracks(); + } + + if (this._tracks.length) { + this.captions = true; + + // Make sure the DOM is up to date + await this.updateComplete; + + this._mark = new Mark(this.renderRoot.querySelectorAll('.captions h2, .captions span')); + + this._jwPlayer.addButton( + ``, + 'Transcript', + this._toggleTranscript.bind(this), + 'toggle-transcript' + ); + } else { + this.captions = false; + this._jwPlayer.removeButton('toggle-transcript'); + } + } + + updated(changedProperties) { + super.updated(changedProperties); + + if (changedProperties.has('captions')) { + if (this.captions) { + this._setupTranscript(); + } else if (this.mark) { + this._mark.unmark(); + } + } + } + + _attachListeners() { + super._attachListeners(); + } + + _toggleShouldScroll() { + this.shouldScroll = !this.shouldScroll; + } + + _toggleTranscript() { + // this.dispatchEvent(new CustomEvent('toggle-transcript')); + + this.captions = !this.captions; + } + } + + return Mixin; +} diff --git a/packages/cxl-ui/src/components/jw-player/mixins/chapter/index.html.js b/packages/cxl-ui/src/components/cxl-jw-player/mixins/chapter-navigation/index.html.js similarity index 73% rename from packages/cxl-ui/src/components/jw-player/mixins/chapter/index.html.js rename to packages/cxl-ui/src/components/cxl-jw-player/mixins/chapter-navigation/index.html.js index 88b64537d..cb89afd14 100644 --- a/packages/cxl-ui/src/components/jw-player/mixins/chapter/index.html.js +++ b/packages/cxl-ui/src/components/cxl-jw-player/mixins/chapter-navigation/index.html.js @@ -8,20 +8,19 @@ export const chapterNavigationTemplate = function (chapters) {
Chapters - - ✕ +