diff --git a/.github/workflows/cd-news-player.yml b/.github/workflows/cd-news-player.yml deleted file mode 100644 index 91d4caa6e..000000000 --- a/.github/workflows/cd-news-player.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Deployment of News player components - -on: workflow_dispatch - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - id-token: write - - steps: - - uses: actions/checkout@v4 - # these if statements ensure that a publication only occurs when - # a new release is created: - with: - fetch-depth: 0 # Fetch all history for all tags and branches - - name: read node version from the .nvmrc file - run: echo ::set-output name=NODE_VERSION::$(cat .nvmrc) - shell: bash - id: nvmrc - - uses: actions/setup-node@v4 - with: - node-version: ${{steps.nvmrc.outputs.NODE_VERSION}} - # this line is required for the setup-node action to be able to run the npm publish below. - registry-url: 'https://registry.npmjs.org' - - - name: Install - run: npm ci - - name: Clean - run: npm run clean - # - name: Test - # run: npm test - - name: Clean - run: npm run clean - - name: Build - run: npm run build:packages - - # Require the custom version of mux-player and mux-video-ads - # Do it here so we still automatically workspace symlink during development. - - name: Update NPM @mux/mux-player version to gitpkg url - run: npm pkg set dependencies.@mux/mux-player='https://gitpkg.vercel.app/muxinc/elements/packages/mux-player?release-news-player' -w @mux/mux-player-react - - name: Update NPM @mux/mux-video-ads version to gitpkg url - run: npm pkg set dependencies.@mux/mux-video-ads='https://gitpkg.vercel.app/muxinc/elements/packages/mux-video-ads?release-news-player' -w @mux/mux-player-react - - - uses: fregante/setup-git-user@v2 - - name: Append !dist to gitignore - run: echo '!dist' >> .gitignore - - name: Stage dist folders - run: git add --all - - name: Commit changes to git - run: git commit -m "Add dist folders" - - name: Force push to release-news-player - run: git push -f origin main-news-player:release-news-player diff --git a/examples/nextjs-with-typescript/package.json b/examples/nextjs-with-typescript/package.json index 4f763a364..15a3d613d 100644 --- a/examples/nextjs-with-typescript/package.json +++ b/examples/nextjs-with-typescript/package.json @@ -19,6 +19,7 @@ "@mux/mux-uploader-react": ">=1.0.0-beta.0", "@mux/mux-video": ">=0.3.0", "@mux/mux-video-react": ">=0.3.0", + "media-chrome": "^4.9.0", "next": "^14.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples/nextjs-with-typescript/pages/MuxNewsPlayer.tsx b/examples/nextjs-with-typescript/pages/MuxNewsPlayer.tsx new file mode 100644 index 000000000..094581dc0 --- /dev/null +++ b/examples/nextjs-with-typescript/pages/MuxNewsPlayer.tsx @@ -0,0 +1,81 @@ +import Head from 'next/head'; +import Script from 'next/script'; +import { useState } from 'react'; +import MuxNewsPlayer from '@mux/mux-player-react/news'; + +function MuxNewsPlayerPage() { + + const relatedVideos = [ + { + imageUrl: "https://image.mux.com/gZh02tKCI015W6k2XdYSh4srGnksYvsoT1uHsYOlv4Blo/thumbnail.webp", + title: "Test video title 1", + playbackId: "gZh02tKCI015W6k2XdYSh4srGnksYvsoT1uHsYOlv4Blo", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + { + imageUrl: "https://image.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA/thumbnail.webp", + title: "Test video title 2", + playbackId: "VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpreonly&ciu_szs=300x250%2C728x90&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + { + imageUrl: "https://image.mux.com/maVbJv2GSYNRgS02kPXOOGdJMWGU1mkA019ZUjYE7VU7k/thumbnail.webp", + title: "Test video title 3", + playbackId: "maVbJv2GSYNRgS02kPXOOGdJMWGU1mkA019ZUjYE7VU7k", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpreonlybumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + ]; + + const [sdkReady, setSdkReady] = useState(false); + + return ( + <> + + <MuxNewsPlayer/> (POC) Demo + + + + + + + +
+
+ +

Elements

+
+
+ +
+
+ +

Mux Player Ads

+ + + + Browse Elements + + diff --git a/examples/vanilla-ts-esm/public/mux-player-error.html b/examples/vanilla-ts-esm/public/mux-player-error.html index 7d0d853f8..a40411091 100644 --- a/examples/vanilla-ts-esm/public/mux-player-error.html +++ b/examples/vanilla-ts-esm/public/mux-player-error.html @@ -41,7 +41,7 @@

Elements

diff --git a/examples/vanilla-ts-esm/public/mux-video-ads.html b/examples/vanilla-ts-esm/public/mux-video-ads.html new file mode 100644 index 000000000..daab1637e --- /dev/null +++ b/examples/vanilla-ts-esm/public/mux-video-ads.html @@ -0,0 +1,64 @@ + + + + + <mux-video> ads example + + + + + + + +
+
+ +

Elements

+
+
+ +
+
+ + + + Browse Elements + + diff --git a/examples/vanilla-ts-esm/src/mux-player-ads.ts b/examples/vanilla-ts-esm/src/mux-player-ads.ts new file mode 100644 index 000000000..58b6293d8 --- /dev/null +++ b/examples/vanilla-ts-esm/src/mux-player-ads.ts @@ -0,0 +1 @@ +export * as MuxPlayer from "@mux/mux-player/ads"; diff --git a/examples/vanilla-ts-esm/src/mux-video-ads.ts b/examples/vanilla-ts-esm/src/mux-video-ads.ts new file mode 100644 index 000000000..377328cbf --- /dev/null +++ b/examples/vanilla-ts-esm/src/mux-video-ads.ts @@ -0,0 +1 @@ +export * as MuxVideo from "@mux/mux-video/ads"; diff --git a/package-lock.json b/package-lock.json index febd7059e..ff8d2f688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -238,6 +238,7 @@ "@mux/mux-uploader-react": ">=1.0.0-beta.0", "@mux/mux-video": ">=0.3.0", "@mux/mux-video-react": ">=0.3.0", + "media-chrome": "^4.9.0", "next": "^14.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -384,6 +385,15 @@ "node": ">= 4" } }, + "examples/nextjs-with-typescript/node_modules/media-chrome": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.9.0.tgz", + "integrity": "sha512-KSGZUDEt0vg0Ogx+B8YYj0O32l0ppPLR8s5rXBMOEpkLYbJrKfhSPfwTmjJGcp2Nf3F/X2ddwO96YvUXHYeYvw==", + "license": "MIT", + "dependencies": { + "ce-la-react": "^0.1.3" + } + }, "examples/nextjs-with-typescript/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4820,6 +4830,15 @@ "resolved": "packages/mux-audio-react", "link": true }, + "node_modules/@mux/mux-data-google-ima": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@mux/mux-data-google-ima/-/mux-data-google-ima-0.2.8.tgz", + "integrity": "sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==", + "license": "MIT", + "dependencies": { + "mux-embed": "5.9.0" + } + }, "node_modules/@mux/mux-elements-codemod": { "resolved": "packages/mux-elements-codemod", "link": true @@ -17496,10 +17515,9 @@ "license": "MIT" }, "node_modules/mux-embed": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.8.3.tgz", - "integrity": "sha512-cNI/5StDDc6R8hBuMI61QPUn0j4NkPhs+lj8IQ1s75HIUnJJEgox8EtmH97G5itB8ZZIzDTw8Ycm77VP1km22A==", - "license": "MIT" + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.9.0.tgz", + "integrity": "sha512-wmunL3uoPhma/tWy8PrDPZkvJpXvSFBwbD3KkC4PG8Ztjfb1X3hRJwGUAQyRz7z99b/ovLm2UTTitrkvStjH4w==" }, "node_modules/nanocolors": { "version": "0.2.13", @@ -30562,6 +30580,7 @@ "version": "0.25.3", "license": "MIT", "dependencies": { + "@mux/mux-data-google-ima": "0.2.8", "@mux/playback-core": "0.29.1", "castable-video": "~1.1.10", "custom-media-element": "~1.4.5", @@ -30569,6 +30588,7 @@ }, "devDependencies": { "@open-wc/testing": "^4.0.0", + "@types/google_interactive_media_ads_types": "^3.697.0", "@typescript-eslint/eslint-plugin": "^8.27.0", "@typescript-eslint/parser": "^8.27.0", "@web/dev-server-esbuild": "^1.0.4", @@ -31711,6 +31731,13 @@ "node": ">=18" } }, + "packages/mux-video/node_modules/@types/google_interactive_media_ads_types": { + "version": "3.697.0", + "resolved": "https://registry.npmjs.org/@types/google_interactive_media_ads_types/-/google_interactive_media_ads_types-3.697.0.tgz", + "integrity": "sha512-BvhC/zIqh6y0XvUQW75dq7JpZ6B9ZRs3KsxSh5dbY2kA9hKhBoTUob7MPl/2qZMbY9a6XSeO9y+rI+juCCe+ew==", + "dev": true, + "license": "MIT" + }, "packages/mux-video/node_modules/@typescript-eslint/eslint-plugin": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", diff --git a/packages/mux-player-react/package.json b/packages/mux-player-react/package.json index d0d752d4a..51adf1cc2 100644 --- a/packages/mux-player-react/package.json +++ b/packages/mux-player-react/package.json @@ -20,8 +20,8 @@ "themes/*": [ "./dist/types-ts3.4/themes/*.d.ts" ], - "lazy": [ - "./dist/types-ts3.4/lazy.d.ts" + "*": [ + "./dist/types-ts3.4/*.d.ts" ] }, "*": { @@ -31,8 +31,8 @@ "themes/*": [ "./dist/types/themes/*.d.ts" ], - "lazy": [ - "./dist/types/lazy.d.ts" + "*": [ + "./dist/types/*.d.ts" ] } }, @@ -50,12 +50,26 @@ "import": "./dist/lazy.mjs", "default": "./dist/lazy.mjs" }, + "./news": { + "types@<4.3.5": "./dist/types-ts3.4/news/index.d.ts", + "types": "./dist/types/news/index.d.ts", + "import": "./dist/news/index.mjs", + "require": "./dist/news/index.cjs.js", + "default": "./dist/news/index.cjs.js" + }, "./themes/*": { "types@<4.3.5": "./dist/types-ts3.4/themes/*.d.ts", "types": "./dist/types/themes/*.d.ts", "import": "./dist/themes/*.mjs", "require": "./dist/themes/*.cjs.js", "default": "./dist/themes/*.cjs.js" + }, + "./*": { + "types@<4.3.5": "./dist/types-ts3.4/*.d.ts", + "types": "./dist/types/*.d.ts", + "import": "./dist/*.mjs", + "require": "./dist/*.cjs.js", + "default": "./dist/*.mjs" } }, "types": "./dist/types/index.d.ts", @@ -68,19 +82,21 @@ "license": "MIT", "scripts": { "clean": "shx rm -rf dist/", - "dev:cjs": "npm run build:cjs -- --watch=forever", - "dev:esm": "npm run build:esm -- --watch=forever", - "dev:cjs:lazy": "echo 'esbuild cjs does not support code-splitting. See https://esbuild.github.io/api/#splitting for details'", - "dev:esm:lazy": "npm run build:esm:lazy -- --watch=forever", "dev:types": "npm run build:types -- -w", - "dev": "npm-run-all --parallel dev:types dev:cjs dev:esm dev:esm:lazy", - "build:cjs": "esbuild src/index.tsx src/themes/*.ts --target=es2019 --bundle --sourcemap --metafile=./dist/cjs.json --format=cjs --loader:.css=text --outdir=dist --out-extension:.js=.cjs.js --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", - "build:esm": "esbuild src/index.tsx src/themes/*.ts --target=es2019 --bundle --sourcemap --metafile=./dist/esm.json --format=esm --loader:.css=text --outdir=dist --out-extension:.js=.mjs --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "dev:esm": "npm-run-all --parallel 'build:esm:** -- --watch=forever'", + "dev:cjs": "npm-run-all --parallel 'build:cjs:** -- --watch=forever'", + "dev": "npm-run-all --parallel dev:types dev:esm dev:cjs", + "build:cjs:index": "esbuild src/index.tsx src/themes/*.ts --target=es2019 --bundle --sourcemap --metafile=./dist/cjs.json --format=cjs --loader:.css=text --outdir=dist --out-extension:.js=.cjs.js --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "build:esm:index": "esbuild src/index.tsx src/themes/*.ts --target=es2019 --bundle --sourcemap --metafile=./dist/esm.json --format=esm --loader:.css=text --outdir=dist --out-extension:.js=.mjs --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "build:esm:news": "esbuild src/news/index.tsx --target=es2019 --bundle --sourcemap --metafile=./dist/news/esm.json --format=esm --loader:.css=text --outdir=dist/news --out-extension:.js=.mjs --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "build:cjs:news": "esbuild src/news/index.tsx --target=es2019 --bundle --sourcemap --metafile=./dist/news/cjs.json --format=cjs --loader:.css=text --outdir=dist/news --out-extension:.js=.cjs.js --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "build:esm:ads": "esbuild src/ads.tsx --target=es2019 --bundle --sourcemap --metafile=./dist/esm.ads.json --format=esm --loader:.css=text --outdir=dist --out-extension:.js=.mjs --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", + "build:cjs:ads": "esbuild src/ads.tsx --target=es2019 --bundle --sourcemap --metafile=./dist/cjs.ads.json --format=cjs --loader:.css=text --outdir=dist --out-extension:.js=.cjs.js --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", "build:cjs:lazy": "echo 'esbuild cjs does not support code-splitting. See https://esbuild.github.io/api/#splitting for details'", "build:esm:lazy": "esbuild src/lazy.tsx --splitting --target=es2019 --bundle --sourcemap --metafile=./dist/esm.lazy.json --format=esm --loader:.css=text --outdir=dist --out-extension:.js=.mjs --external:react --external:@mux/* --external:prop-types --define:PLAYER_VERSION=\"'$npm_package_version'\"", "build:types": "tsc", "postbuild:types": "downlevel-dts ./dist/types ./dist/types-ts3.4", - "build": "npm-run-all --parallel 'build:cjs -- --minify' 'build:esm -- --minify' 'build:esm:lazy -- --minify'" + "build": "npm-run-all --parallel 'build:cjs:* -- --minify' 'build:esm:* -- --minify'" }, "peerDependencies": { "@types/react": "^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0", diff --git a/packages/mux-player-react/src/ads.tsx b/packages/mux-player-react/src/ads.tsx new file mode 100644 index 000000000..2f60370ad --- /dev/null +++ b/packages/mux-player-react/src/ads.tsx @@ -0,0 +1,60 @@ +'use client'; +import React, { forwardRef, useRef } from 'react'; +// Register (ads) web component. +import '@mux/mux-player/ads'; +import MuxPlayer from '@mux/mux-player-react'; +import type { GenericEventListener, Props as MuxPlayerIndexProps } from '@mux/mux-player-react'; +import { useComposedRefs } from './useComposedRefs'; +import { useEventCallbackEffect } from './useEventCallbackEffect'; +import type MuxPlayerElement from '@mux/mux-player/ads'; +import type { EventMap as MuxPlayerElementEventMap } from '@mux/mux-player/ads'; + +export interface MuxPlayerProps extends Omit { + adTagUrl?: string; + allowAdBlocker?: boolean; + + onAdRequest?: GenericEventListener; + onAdResponse?: GenericEventListener; + onAdImpression?: GenericEventListener; + onAdBreakStart?: GenericEventListener; + onAdPlay?: GenericEventListener; + onAdPlaying?: GenericEventListener; + onAdPause?: GenericEventListener; + onAdFirstQuartile?: GenericEventListener; + onAdMidpoint?: GenericEventListener; + onAdThirdQuartile?: GenericEventListener; + onAdError?: GenericEventListener; + onAdClick?: GenericEventListener; + onAdSkip?: GenericEventListener; + onAdEnded?: GenericEventListener; + onAdBreakEnd?: GenericEventListener; + onAdClose?: GenericEventListener; +} + +const MuxPlayerAds = forwardRef((props, ref) => { + const playerRef = useRef(null); + + const adEventProps: Record void> = {}; + const reactProps: Record = {}; + + for (const [k, v] of Object.entries(props)) { + if (k.startsWith('onAd')) { + adEventProps[k] = v; + } else { + reactProps[k] = v; + } + } + + // Set up event listeners on the custom element. + // Still handle events for React 19+ because they don't yet offer + // a way to have nicely camelCased event prop names on custom elements. + for (const propName in adEventProps) { + const callback = adEventProps[propName as keyof typeof adEventProps]; + const eventName = propName.slice(2).toLowerCase() as keyof MuxPlayerElementEventMap; + useEventCallbackEffect(eventName, playerRef, callback); + } + + return ; +}); + +export default MuxPlayerAds; diff --git a/packages/mux-player-react/src/declaration.d.ts b/packages/mux-player-react/src/declaration.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/packages/mux-player-react/src/declaration.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/mux-player-react/src/index.tsx b/packages/mux-player-react/src/index.tsx index 77c4cb2f7..a4d9164ea 100644 --- a/packages/mux-player-react/src/index.tsx +++ b/packages/mux-player-react/src/index.tsx @@ -1,140 +1,18 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import type { CSSProperties } from 'react'; -import type { - StreamTypes, - PlaybackTypes, - CmcdTypes, - MaxResolutionValue, - MinResolutionValue, - RenditionOrderValue, -} from '@mux/playback-core'; +import React, { useState, useRef } from 'react'; import { MaxResolution, MinResolution, RenditionOrder, generatePlayerInitTime } from '@mux/playback-core'; import { MediaError } from '@mux/mux-player'; import type MuxPlayerElement from '@mux/mux-player'; -import type { Tokens, MuxPlayerElementEventMap } from '@mux/mux-player'; +import type { MuxPlayerElementEventMap } from '@mux/mux-player'; import { toNativeProps } from './common/utils'; -import { useRef } from 'react'; import { useComposedRefs } from './useComposedRefs'; import useObjectPropEffect, { defaultHasChanged } from './useObjectPropEffect'; import { getPlayerVersion } from './env'; +import { useEventCallbackEffect } from './useEventCallbackEffect'; +import type { MuxPlayerProps, MuxPlayerRefAttributes } from './types'; export { MediaError, MaxResolution, MinResolution, RenditionOrder, generatePlayerInitTime }; - -type ValueOf = T[keyof T]; -interface GenericEventListener { - (evt: T): void; -} - -export type MuxPlayerRefAttributes = MuxPlayerElement; -type VideoApiAttributes = { - currentTime: number; - volume: number; - paused: boolean; - src: string | null; - poster: string; - playbackRate: number; - playsInline: boolean; - preload: string; - crossOrigin: string; - autoPlay: boolean | string; - loop: boolean; - muted: boolean; - style: CSSProperties; -}; - -type MuxMediaPropTypes = { - audio: boolean; - // envKey: Options["data"]["env_key"]; - envKey: string; - // debug: Options["debug"] & Hls["config"]["debug"]; - debug: boolean; - disableTracking: boolean; - disableCookies: boolean; - disablePictureInPicture?: boolean; - // metadata: Partial; - metadata: { [k: string]: any }; - extraSourceParams: Record; - _hlsConfig: MuxPlayerElement['_hlsConfig']; - beaconCollectionDomain: string; - customDomain: string; - playbackId: string; - preferPlayback: ValueOf | undefined; - // NOTE: Explicitly adding deprecated values here for now to avoid fully breaking changes in TS envs (CJP) - streamType: ValueOf | 'll-live' | 'live:dvr' | 'll-live:dvr'; - defaultStreamType: ValueOf; - targetLiveWindow: number; - startTime: number; - storyboardSrc: string; - preferCmcd: ValueOf | undefined; - children?: React.ReactNode; -}; - -export type MuxPlayerProps = { - className?: string; - hotkeys?: string; - nohotkeys?: boolean; - castReceiver?: string | undefined; - castCustomData?: Record | undefined; - defaultHiddenCaptions?: boolean; - playerSoftwareVersion?: string; - playerSoftwareName?: string; - playerInitTime?: number; - forwardSeekOffset?: number; - backwardSeekOffset?: number; - maxResolution?: MaxResolutionValue; - minResolution?: MinResolutionValue; - renditionOrder?: RenditionOrderValue; - programStartTime?: number; - programEndTime?: number; - proudlyDisplayMuxBadge?: boolean; - assetStartTime?: number; - assetEndTime?: number; - metadataVideoId?: string; - metadataVideoTitle?: string; - metadataViewerUserId?: string; - primaryColor?: string; - secondaryColor?: string; - accentColor?: string; - placeholder?: string; - playbackRates?: number[]; - defaultShowRemainingTime?: boolean; - defaultDuration?: number; - noVolumePref?: boolean; - thumbnailTime?: number; - title?: string; - videoTitle?: string; - tokens?: Tokens; - theme?: string; - themeProps?: { [k: string]: any }; - onAbort?: GenericEventListener; - onCanPlay?: GenericEventListener; - onCanPlayThrough?: GenericEventListener; - onEmptied?: GenericEventListener; - onLoadStart?: GenericEventListener; - onLoadedData?: GenericEventListener; - onLoadedMetadata?: GenericEventListener; - onProgress?: GenericEventListener; - onDurationChange?: GenericEventListener; - onVolumeChange?: GenericEventListener; - onRateChange?: GenericEventListener; - onResize?: GenericEventListener; - onWaiting?: GenericEventListener; - onPlay?: GenericEventListener; - onPlaying?: GenericEventListener; - onTimeUpdate?: GenericEventListener; - onPause?: GenericEventListener; - onSeeking?: GenericEventListener; - onSeeked?: GenericEventListener; - onStalled?: GenericEventListener; - onSuspend?: GenericEventListener; - onEnded?: GenericEventListener; - onError?: GenericEventListener; - onCuePointChange?: GenericEventListener; - onCuePointsChange?: GenericEventListener; - onChapterChange?: GenericEventListener; -} & Partial & - Partial; +export * from './types'; const MuxPlayerInternal = React.forwardRef(({ children, ...props }, ref) => { return React.createElement( @@ -148,22 +26,6 @@ const MuxPlayerInternal = React.forwardRef( - type: K, - ref: // | ((instance: EventTarget | null) => void) - React.MutableRefObject | null | undefined, - callback: GenericEventListener | undefined -) => { - return useEffect(() => { - const eventTarget = ref?.current; - if (!eventTarget || !callback) return; - eventTarget.addEventListener(type, callback); - return () => { - eventTarget.removeEventListener(type, callback); - }; - }, [ref?.current, callback]); -}; - const usePlayer = ( ref: // | ((instance: EventTarget | null) => void) React.MutableRefObject | null | undefined, @@ -194,7 +56,6 @@ const usePlayer = ( onEnded, onError, onCuePointChange, - onCuePointsChange, onChapterChange, metadata, tokens, @@ -239,32 +100,31 @@ const usePlayer = ( if (currentTimeVal == null) return; playerEl.currentTime = currentTimeVal; }); - useEventCallbackEffect('abort', ref, onAbort); - useEventCallbackEffect('canplay', ref, onCanPlay); - useEventCallbackEffect('canplaythrough', ref, onCanPlayThrough); - useEventCallbackEffect('emptied', ref, onEmptied); - useEventCallbackEffect('loadstart', ref, onLoadStart); - useEventCallbackEffect('loadeddata', ref, onLoadedData); - useEventCallbackEffect('loadedmetadata', ref, onLoadedMetadata); - useEventCallbackEffect('progress', ref, onProgress); - useEventCallbackEffect('durationchange', ref, onDurationChange); - useEventCallbackEffect('volumechange', ref, onVolumeChange); - useEventCallbackEffect('ratechange', ref, onRateChange); - useEventCallbackEffect('resize', ref, onResize); - useEventCallbackEffect('waiting', ref, onWaiting); - useEventCallbackEffect('play', ref, onPlay); - useEventCallbackEffect('playing', ref, onPlaying); - useEventCallbackEffect('timeupdate', ref, onTimeUpdate); - useEventCallbackEffect('pause', ref, onPause); - useEventCallbackEffect('seeking', ref, onSeeking); - useEventCallbackEffect('seeked', ref, onSeeked); - useEventCallbackEffect('stalled', ref, onStalled); - useEventCallbackEffect('suspend', ref, onSuspend); - useEventCallbackEffect('ended', ref, onEnded); - useEventCallbackEffect('error', ref, onError); - useEventCallbackEffect('cuepointchange', ref, onCuePointChange); - useEventCallbackEffect('cuepointschange', ref, onCuePointsChange); - useEventCallbackEffect('chapterchange', ref, onChapterChange); + useEventCallbackEffect('abort', ref, onAbort); + useEventCallbackEffect('canplay', ref, onCanPlay); + useEventCallbackEffect('canplaythrough', ref, onCanPlayThrough); + useEventCallbackEffect('emptied', ref, onEmptied); + useEventCallbackEffect('loadstart', ref, onLoadStart); + useEventCallbackEffect('loadeddata', ref, onLoadedData); + useEventCallbackEffect('loadedmetadata', ref, onLoadedMetadata); + useEventCallbackEffect('progress', ref, onProgress); + useEventCallbackEffect('durationchange', ref, onDurationChange); + useEventCallbackEffect('volumechange', ref, onVolumeChange); + useEventCallbackEffect('ratechange', ref, onRateChange); + useEventCallbackEffect('resize', ref, onResize); + useEventCallbackEffect('waiting', ref, onWaiting); + useEventCallbackEffect('play', ref, onPlay); + useEventCallbackEffect('playing', ref, onPlaying); + useEventCallbackEffect('timeupdate', ref, onTimeUpdate); + useEventCallbackEffect('pause', ref, onPause); + useEventCallbackEffect('seeking', ref, onSeeking); + useEventCallbackEffect('seeked', ref, onSeeked); + useEventCallbackEffect('stalled', ref, onStalled); + useEventCallbackEffect('suspend', ref, onSuspend); + useEventCallbackEffect('ended', ref, onEnded); + useEventCallbackEffect('error', ref, onError); + useEventCallbackEffect('cuepointchange', ref, onCuePointChange); + useEventCallbackEffect('chapterchange', ref, onChapterChange); return [remainingProps]; }; diff --git a/packages/mux-player-react/src/news/README.md b/packages/mux-player-react/src/news/README.md new file mode 100644 index 000000000..6df0f7fb9 --- /dev/null +++ b/packages/mux-player-react/src/news/README.md @@ -0,0 +1,226 @@ +# Integrating Video Ads with MuxNewsPlayer Component + +> **Important Note**: This feature is only available in the custom build referenced in this documentation. It is not available in the standard Mux packages from npm. + +This documentation covers how to integrate the `MuxNewsPlayer` component provided by the `mux-player-react` package. This integration allows you to display ads within a playlist of Mux videos. + +## Table of Contents + +1. [Installation](#installation) +2. [Setting Up Dependencies](#setting-up-dependencies) +3. [Implementing the MuxNewsPlayer with Ads](#implementing-the-playlist-with-ads) +4. [Configuration Options](#configuration-options) +5. [Best Practices](#best-practices) +6. [Troubleshooting](#troubleshooting) + +## Installation + +**Note:** The video ads integration with the MuxNewsPlayer component is only available in the custom build referenced below. Standard npm package do not include this functionality. + +You must use the specific custom build through gitpkg as shown in the example below: + +You can execute + +```bash + npm install @mux/mux-player-react +``` + +or manually add it as a dependency in your package.json + +```json +"dependencies": { + "@mux/mux-player-react": "^3.5.0", +} +``` + +## Setting Up Dependencies + +### 1. Import the Required Package + +In your React component: + +```jsx +import MuxNewsPlayer from "@mux/mux-player-react/news"; +``` + +### 2. Load the Google IMA SDK + +The Google Interactive Media Ads (IMA) SDK is required for ad integration. It should be loaded before rendering the MuxNewsPlayer component: + +```jsx +import { useEffect, useState } from "react"; + +export default function YourComponent() { + const [sdkLoaded, setSdkLoaded] = useState(false); + + useEffect(() => { + // Dynamically load the IMA SDK + const loadImaSdk = () => { + const script = document.createElement("script"); + script.src = "https://imasdk.googleapis.com/js/sdkloader/ima3.js"; + script.async = true; + script.onload = () => { + setSdkLoaded(true); + console.log("Google IMA SDK loaded"); + }; + script.onerror = () => { + setSdkLoaded(true); + }; + document.head.appendChild(script); + }; + + if (!window.google || !window.google.ima) { + loadImaSdk(); + } else { + setSdkLoaded(true); + } + + return () => { + // Cleanup by removing the script + const scriptElement = document.querySelector('script[src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"]'); + if (scriptElement) { + scriptElement.remove(); + } + }; + }, []); + + // Rest of your component... +} +``` + +## Implementing the MuxNewsPlayer with Ads + +### 1. Define Your Video List + +Create an array of video objects that includes both video information and ad tag URLs: + +```jsx +const videoList = [ + { + imageUrl: "https://image.mux.com/[PLAYBACK_ID]/thumbnail.jpg", + title: "Video Title 1", + playbackId: "[PLAYBACK_ID]", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + // Add more videos as needed +]; +``` + +Each video object should contain: +- `imageUrl`: Thumbnail image URL for the video +- `title`: Title of the video +- `playbackId`: Mux playback ID for the video +- `adTagUrl`: VAST or VMAP URL for the ads to be displayed with this video + +### 2. Render the MuxNewsPlayer Component + +Only render the MuxNewsPlayer component once the IMA SDK is loaded: + +```jsx +return ( + <> + {sdkLoaded && } + +); +``` + +### 3. Complete Implementation Example + +```jsx +import Head from 'next/head'; +import { useEffect, useState } from "react"; +import MuxNewsPlayer from "@mux/mux-player-react/news"; + +export default function VideoMuxNewsPlayerPage() { + const [sdkLoaded, setSdkLoaded] = useState(false); + + useEffect(() => { + // Dynamically load the IMA SDK + const loadImaSdk = () => { + const script = document.createElement("script"); + script.src = "https://imasdk.googleapis.com/js/sdkloader/ima3.js"; + script.async = true; + script.onload = () => { + setSdkLoaded(true); + console.log("Google IMA SDK loaded"); + }; + script.onerror = () => { + setSdkLoaded(true); + }; + document.head.appendChild(script); + }; + + if (!window.google || !window.google.ima) { + loadImaSdk(); + } else { + setSdkLoaded(true); + } + + return () => { + // Cleanup by removing the script + const scriptElement = document.querySelector('script[src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"]'); + if (scriptElement) { + scriptElement.remove(); + } + }; + }, []); + + const videoList = [ + { + imageUrl: "https://image.mux.com/DVBhwqkhxkOiLRjUAYJS6mCBJSuC00tB4iWjJmEofJoo/thumbnail.jpg", + title: "Test video title 1", + playbackId: "DVBhwqkhxkOiLRjUAYJS6mCBJSuC00tB4iWjJmEofJoo", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + { + imageUrl: "https://image.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA/thumbnail.jpg", + title: "Test video title 2", + playbackId: "VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpreonly&ciu_szs=300x250%2C728x90&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + { + imageUrl: "https://image.mux.com/gZh02tKCI015W6k2XdYSh4srGnksYvsoT1uHsYOlv4Blo/thumbnail.jpg", + title: "Test video title 3", + playbackId: "gZh02tKCI015W6k2XdYSh4srGnksYvsoT1uHsYOlv4Blo", + adTagUrl: "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpreonlybumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&correlator=", + }, + ]; + + return ( + <> + + Video MuxNewsPlayer with Ads + + + {sdkLoaded && } + + ); +} +``` + +## Configuration Options + +### Video Object Properties + +Each video in the `videoList` array can have the following properties: + +| Property | Type | Description | Required | +|----------|------|-------------|----------| +| `playbackId` | String | Mux playback ID for the video | Yes | +| `adTagUrl` | String | URL for the VAST or VMAP ad tag | Yes for ads | +| `imageUrl` | String | URL for the video thumbnail | Yes | +| `title` | String | Title of the video | Yes | + + +## Troubleshooting + +### Common Issues + +1. **Ads not displaying**: + - Ensure the IMA SDK is loaded correctly + - Check that ad tag URLs are correctly formatted + - Verify that ad blocking software is not active + +2. **Console errors about IMA not defined**: + - Make sure you're checking if the SDK is loaded before rendering the MuxNewsPlayer + - Verify the script is loading successfully in the network tab diff --git a/packages/mux-player-react/src/news/index.tsx b/packages/mux-player-react/src/news/index.tsx new file mode 100644 index 000000000..7c1e1ba5f --- /dev/null +++ b/packages/mux-player-react/src/news/index.tsx @@ -0,0 +1,86 @@ +import React, { useRef, useState } from 'react'; +import MuxPlayer, { MuxPlayerProps } from '@mux/mux-player-react/ads'; +import NewsTheme from '@mux/mux-player-react/themes/news'; +import PlaylistEndScreen from './playlist-end-screen'; + +export interface VideoItem { + imageUrl: string; + title: string; + playbackId: string; + adTagUrl: string; +} + +export type PlaylistVideos = VideoItem[]; + +export interface PlaylistProps extends Omit { + videoList: PlaylistVideos; +} + +const MuxNewsPlayer = ({ videoList, ...props }: PlaylistProps) => { + const mediaElRef = useRef(null); + const [currentIndex, setCurrentIndex] = useState(0); + const [isEndScreenVisible, setIsEndScreenVisible] = useState(false); + const [playerKey, setPlayerKey] = useState(0); + + function playVideo() { + setIsEndScreenVisible(false); + setCurrentIndex(currentIndex + 1); + setTimeout(() => { + try { + mediaElRef.current.play(); + } catch { + // Ignore AbortError: The play() request was interrupted by a call to pause() + } + }, 200); + } + + function selectVideo(index: number) { + setIsEndScreenVisible(false); + setCurrentIndex(index); + setTimeout(() => { + try { + mediaElRef.current.play(); + } catch { + // Ignore AbortError: The play() request was interrupted by a call to pause() + } + }, 200); + } + + return ( + { + if (currentIndex < videoList.length - 1) { + setIsEndScreenVisible(true); + } else { + setCurrentIndex(0); + setPlayerKey((prev) => prev + 1); + } + props.onEnded?.(event); + }} + > + + + ); +}; + +export default MuxNewsPlayer; diff --git a/packages/mux-player-react/src/news/playlist-end-screen.css b/packages/mux-player-react/src/news/playlist-end-screen.css new file mode 100644 index 000000000..680c64aa3 --- /dev/null +++ b/packages/mux-player-react/src/news/playlist-end-screen.css @@ -0,0 +1,215 @@ +/* Main Playlist Container */ +.playlist { + /* Ensure it wraps on smaller screens */ + display: inline; + position: relative; + background-color: #12121263; + z-index: 2; + top: 0; + position: absolute; + width: 100%; + height: 100%; +} + +@media (min-width: 1336px) { + .playlist { + align-items: center; + justify-content: center; + } +} + +.overlay { + position: absolute; + width: 100%; + height: 100%; + background: black; + opacity: 0.5; + z-index: 1; +} + +.post-video-section { + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1.5rem 2rem; + position: relative; + gap: 1rem; + padding: 1.5rem 2rem; + height: max-content; + box-sizing: border-box; + z-index: 2; + max-width: 1200px; +} + +.post-video-section hr { + border: none; + background: rgba(255, 255, 255, 0.5); + height: 100%; + width: 1px; +} + +/* Video Section */ +.video-section { + flex: 2; +} + +.video-container { + position: relative; +} + +.title { + font-size: 2.5rem; + font-weight: 500; + line-height: 3rem; + margin: 0; + margin-bottom: 1rem; +} + +.video-wrapper { + position: relative; + width: 100%; + overflow: hidden; +} + +.video-container > .video-title { + font-size: 1.3rem; + font-weight: 600; +} + +.video-thumbnail { + width: 100%; + display: block; +} + +.video-title { + font-size: 1rem; + margin-top: 0.5rem; + cursor: pointer; + color: #ffffff; + text-decoration: none; + line-height: 1.4; + /* Adjusted for better readability */ + word-wrap: break-word; + font-weight: 500; + margin-bottom: 0; +} + +.video-title:hover { + text-decoration: underline; +} + +/* Countdown Timer */ +.countdown-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3.75rem; + height: 3.75rem; + display: flex; + justify-content: center; + align-items: center; +} + +.countdown-ring { + position: absolute; +} + +.circle-background { + fill: none; + stroke: rgba(255, 255, 255, 0.2); + stroke-width: 0.25rem; +} + +.circle-progress { + fill: none; + stroke: #00a3dd; + stroke-width: 0.25rem; + stroke-linecap: round; + transition: stroke-dashoffset 1s linear; +} + +.count-text { + position: absolute; + font-size: 1rem; + font-weight: bold; + color: #ffffff; +} + +/* Related Videos */ +.related-videos-section { + flex: 1; + width: 100%; +} + +.related-title { + font-size: 1.125rem; + font-weight: bold; + margin: 0; + line-height: 2rem; +} + +.related-list { + list-style: none; + padding: 0; + margin: 0; +} + +.related-item { + display: flex; + align-items: start; + border-bottom: 1px solid rgba(255, 255, 255, 0.5); + width: 100%; + gap: 0.5rem; + padding: 0.5rem 0; + border-radius: 0; + background: none; +} + +.related-item:hover { + background: none; +} +.related-thumbnail { + width: 7rem; + object-fit: cover; + aspect-ratio: 16 / 9; +} + +.related-text { + font-size: 0.9rem; + color: white; + line-height: 1.4; + word-wrap: break-word; + max-width: 100%; + margin-top: 0.25rem; + display: block; +} + +.related-text:hover { + text-decoration: underline; +} + +/* Responsive */ + +@media (max-width: 768px) { + .post-video-section { + grid-template-columns: 1fr; + margin: auto; + } + + .post-video-section h2 { + display: none; + } + + hr { + display: none; + } + + .video-section { + width: 60%; + margin: auto; + } + + .related-videos-section { + display: none; + } +} diff --git a/packages/mux-player-react/src/news/playlist-end-screen.tsx b/packages/mux-player-react/src/news/playlist-end-screen.tsx new file mode 100644 index 000000000..516777a93 --- /dev/null +++ b/packages/mux-player-react/src/news/playlist-end-screen.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import { VideoItem, PlaylistVideos } from '.'; +import style from './playlist-end-screen.css'; + +interface PlaylistEndScreenProps { + video: VideoItem; + relatedVideos: PlaylistVideos; + isVisible: boolean; + selectVideoCallback: (index: number) => void; + timerCallback: () => void; +} + +const PlaylistEndScreen = ({ + video, + relatedVideos, + isVisible, + selectVideoCallback, + timerCallback, +}: PlaylistEndScreenProps) => { + const [count, setCount] = useState(3); + + useEffect(() => { + if (!isVisible) { + setCount(3); + return; + } + + if (count < 0) { + timerCallback(); + return; + } + const timer = setInterval(() => { + setCount((prev) => Math.max(prev - 1, -1)); + }, 1000); + + return () => clearInterval(timer); + }, [count, isVisible]); + + return ( + <> + +
+
+
+
+
+

Video

+
+ {video.title} +
+ + + + + {count} +
+
+

{video.title}

+
+
+
+
+

Related Videos

+
    + {relatedVideos.map((relatedVideo, index) => ( +
  • + +
  • + ))} +
+
+
+
+ + ); +}; + +export default PlaylistEndScreen; diff --git a/packages/mux-player-react/src/themes/news.ts b/packages/mux-player-react/src/themes/news.ts new file mode 100644 index 000000000..af64d43b4 --- /dev/null +++ b/packages/mux-player-react/src/themes/news.ts @@ -0,0 +1,3 @@ +'use client'; +import '@mux/mux-player/themes/news'; +export default 'news'; diff --git a/packages/mux-player-react/src/types.ts b/packages/mux-player-react/src/types.ts new file mode 100644 index 000000000..6766ca268 --- /dev/null +++ b/packages/mux-player-react/src/types.ts @@ -0,0 +1,131 @@ +import type { CSSProperties } from 'react'; +import type { + StreamTypes, + PlaybackTypes, + CmcdTypes, + MaxResolutionValue, + MinResolutionValue, + RenditionOrderValue, +} from '@mux/playback-core'; +import type MuxPlayerElement from '@mux/mux-player'; +import type { Tokens, EventMap as MuxPlayerElementEventMap } from '@mux/mux-player'; + +type ValueOf = T[keyof T]; + +export interface GenericEventListener { + (evt: T): void; +} + +export type MuxPlayerRefAttributes = MuxPlayerElement; +type VideoApiAttributes = { + currentTime: number; + volume: number; + paused: boolean; + src: string | null; + poster: string; + playbackRate: number; + playsInline: boolean; + preload: string; + crossOrigin: string; + autoPlay: boolean | string; + loop: boolean; + muted: boolean; + style: CSSProperties; +}; + +type MuxMediaPropTypes = { + audio: boolean; + // envKey: Options["data"]["env_key"]; + envKey: string; + // debug: Options["debug"] & Hls["config"]["debug"]; + debug: boolean; + disableTracking: boolean; + disableCookies: boolean; + disablePictureInPicture?: boolean; + // metadata: Partial; + metadata: { [k: string]: any }; + extraSourceParams: Record; + _hlsConfig: MuxPlayerElement['_hlsConfig']; + beaconCollectionDomain: string; + customDomain: string; + playbackId: string; + preferPlayback: ValueOf | undefined; + // NOTE: Explicitly adding deprecated values here for now to avoid fully breaking changes in TS envs (CJP) + streamType: ValueOf | 'll-live' | 'live:dvr' | 'll-live:dvr'; + defaultStreamType: ValueOf; + targetLiveWindow: number; + startTime: number; + storyboardSrc: string; + preferCmcd: ValueOf | undefined; + children?: React.ReactNode; +}; + +export type Props = MuxPlayerProps; + +export type MuxPlayerProps = { + className?: string; + hotkeys?: string; + nohotkeys?: boolean; + castReceiver?: string | undefined; + castCustomData?: Record | undefined; + defaultHiddenCaptions?: boolean; + playerSoftwareVersion?: string; + playerSoftwareName?: string; + playerInitTime?: number; + forwardSeekOffset?: number; + backwardSeekOffset?: number; + maxResolution?: MaxResolutionValue; + minResolution?: MinResolutionValue; + renditionOrder?: RenditionOrderValue; + programStartTime?: number; + programEndTime?: number; + proudlyDisplayMuxBadge?: boolean; + /** Allow playback with ad blocker */ + allowAdBlocker?: boolean; + adTagUrl?: string; + assetStartTime?: number; + assetEndTime?: number; + metadataVideoId?: string; + metadataVideoTitle?: string; + metadataViewerUserId?: string; + primaryColor?: string; + secondaryColor?: string; + accentColor?: string; + placeholder?: string; + playbackRates?: number[]; + defaultShowRemainingTime?: boolean; + defaultDuration?: number; + noVolumePref?: boolean; + thumbnailTime?: number; + title?: string; + videoTitle?: string; + tokens?: Tokens; + theme?: string; + themeProps?: { [k: string]: any }; + onAbort?: GenericEventListener; + onCanPlay?: GenericEventListener; + onCanPlayThrough?: GenericEventListener; + onEmptied?: GenericEventListener; + onLoadStart?: GenericEventListener; + onLoadedData?: GenericEventListener; + onLoadedMetadata?: GenericEventListener; + onProgress?: GenericEventListener; + onDurationChange?: GenericEventListener; + onVolumeChange?: GenericEventListener; + onRateChange?: GenericEventListener; + onResize?: GenericEventListener; + onWaiting?: GenericEventListener; + onPlay?: GenericEventListener; + onPlaying?: GenericEventListener; + onTimeUpdate?: GenericEventListener; + onPause?: GenericEventListener; + onSeeking?: GenericEventListener; + onSeeked?: GenericEventListener; + onStalled?: GenericEventListener; + onSuspend?: GenericEventListener; + onEnded?: GenericEventListener; + onError?: GenericEventListener; + onCuePointChange?: GenericEventListener; + onChapterChange?: GenericEventListener; +} & Partial & + Partial; diff --git a/packages/mux-player-react/src/useEventCallbackEffect.ts b/packages/mux-player-react/src/useEventCallbackEffect.ts new file mode 100644 index 000000000..129d2b57e --- /dev/null +++ b/packages/mux-player-react/src/useEventCallbackEffect.ts @@ -0,0 +1,27 @@ +import React, { useEffect } from 'react'; +import type { GenericEventListener } from './index'; + +export const useEventCallbackEffect = < + TElement extends EventTarget = EventTarget, + TEventMap extends Record = Record, + K extends keyof TEventMap = keyof TEventMap, +>( + type: K, + ref: // | ((instance: EventTarget | null) => void) + React.MutableRefObject | null | undefined, + callback: GenericEventListener | undefined +) => { + return useEffect(() => { + const eventTarget = ref?.current; + if (!eventTarget || !callback) return; + + // Type assertion needed because TypeScript can't infer the exact event type + const eventName = type as string; + const listener = callback as EventListener; + + eventTarget.addEventListener(eventName, listener); + return () => { + eventTarget.removeEventListener(eventName, listener); + }; + }, [ref?.current, callback, type]); +}; diff --git a/packages/mux-player-react/types/index.d.ts b/packages/mux-player-react/types/index.d.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/mux-player/package.json b/packages/mux-player/package.json index d78620ed3..cb3b0adc6 100644 --- a/packages/mux-player/package.json +++ b/packages/mux-player/package.json @@ -19,11 +19,29 @@ "<4.3.5": { ".": [ "./dist/types-ts3.4/index.d.ts" + ], + "base": [ + "./dist/types-ts3.4/base.d.ts" + ], + "ads": [ + "./dist/types-ts3.4/ads/index.d.ts" + ], + "ads/mixin": [ + "./dist/types-ts3.4/ads/mixin/index.d.ts" ] }, "*": { ".": [ "./dist/types/index.d.ts" + ], + "base": [ + "./dist/types/base.d.ts" + ], + "ads": [ + "./dist/types/ads/index.d.ts" + ], + "ads/mixin": [ + "./dist/types/ads/mixin/index.d.ts" ] } }, @@ -35,29 +53,37 @@ "require": "./dist/index.cjs.js", "default": "./dist/index.cjs.js" }, - "./themes/microvideo": { - "browser": "./dist/themes/microvideo/index.mjs", - "import": "./dist/themes/microvideo/index.mjs", - "require": "./dist/themes/microvideo/index.cjs.js", - "default": "./dist/themes/microvideo/index.cjs.js" + "./ads": { + "types@<4.3.5": "./dist/types-ts3.4/ads/index.d.ts", + "types": "./dist/types/ads/index.d.ts", + "import": "./dist/ads/index.mjs", + "require": "./dist/ads/index.cjs.js", + "default": "./dist/ads/index.cjs.js" }, - "./themes/minimal": { - "browser": "./dist/themes/minimal/index.mjs", - "import": "./dist/themes/minimal/index.mjs", - "require": "./dist/themes/minimal/index.cjs.js", - "default": "./dist/themes/minimal/index.cjs.js" + "./ads/mixin": { + "types@<4.3.5": "./dist/types-ts3.4/ads/mixin/index.d.ts", + "types": "./dist/types/ads/mixin/index.d.ts", + "import": "./dist/ads/mixin/index.mjs", + "require": "./dist/ads/mixin/index.cjs.js", + "default": "./dist/ads/mixin/index.cjs.js" }, - "./themes/classic": { - "browser": "./dist/themes/classic/index.mjs", - "import": "./dist/themes/classic/index.mjs", - "require": "./dist/themes/classic/index.cjs.js", - "default": "./dist/themes/classic/index.cjs.js" + "./themes/*": { + "browser": "./dist/themes/*/index.mjs", + "import": "./dist/themes/*/index.mjs", + "require": "./dist/themes/*/index.cjs.js", + "default": "./dist/themes/*/index.cjs.js" }, - "./themes/gerwig": { - "browser": "./dist/themes/gerwig/index.mjs", - "import": "./dist/themes/gerwig/index.mjs", - "require": "./dist/themes/gerwig/index.cjs.js", - "default": "./dist/themes/gerwig/index.cjs.js" + "./*.js": { + "import": "./dist/*.js", + "require": "./dist/*.cjs.js", + "default": "./dist/*.js" + }, + "./*": { + "types@<4.3.5": "./dist/types-ts3.4/*.d.ts", + "types": "./dist/types/*.d.ts", + "import": "./dist/*.mjs", + "require": "./dist/*.cjs.js", + "default": "./dist/*.mjs" } }, "types": "./dist/types/index.d.ts", @@ -75,28 +101,34 @@ "test": "web-test-runner **/*test.js --port 8001 --coverage --config test/web-test-runner.config.mjs --root-dir ../..", "posttest": "replace 'SF:src/' 'SF:packages/mux-player/src/' coverage/lcov.info --silent", "i18n": "npm run build:esm -- --keep-names && i18n-utils dist/index.mjs ./lang", - "dev:iife": "npm run build:iife -- --watch=forever", - "dev:esm": "npm run build:esm -- --watch=forever", - "dev:esm-module": "npm run build:esm-module -- --watch=forever", - "dev:cjs": "npm run build:cjs -- --watch=forever", + "dev:iife": "npm-run-all --parallel 'build:iife:** -- --watch=forever'", + "dev:esm": "npm-run-all --parallel 'build:esm:** -- --watch=forever'", + "dev:esm-module": "npm-run-all --parallel 'build:esm-module:** -- --watch=forever'", + "dev:cjs": "npm-run-all --parallel 'build:cjs:** -- --watch=forever'", "dev:types": "npm run build:types -- -w", "dev:themes": "node ./scripts/build-themes.mjs --dev", "dev": "npm-run-all --parallel dev:types dev:esm dev:cjs dev:esm-module dev:iife dev:themes", - "build:esm": "esbuilder src/index.ts --format=esm --out-extension:.js=.mjs", - "build:esm-module": "esbuilder src/index.ts --format=esm-module --outfile=dist/mux-player.mjs", - "build:iife": "esbuilder src/index.ts --format=iife --outfile=dist/mux-player.js", - "build:cjs": "esbuilder src/index.ts --format=cjs --out-extension:.js=.cjs.js", + "build:esm:index": "esbuilder src/index.ts --format=esm --out-extension:.js=.mjs", + "build:esm-module:index": "esbuilder src/index.ts --format=esm-module --outfile=dist/mux-player.mjs", + "build:iife:index": "esbuilder src/index.ts --format=iife --outfile=dist/mux-player.js", + "build:cjs:index": "esbuilder src/index.ts --format=cjs --out-extension:.js=.cjs.js", + "build:esm:base": "esbuilder src/base.ts --format=esm --outdir=dist --out-extension:.js=.mjs", + "build:cjs:base": "esbuilder src/base.ts --format=cjs --outdir=dist --out-extension:.js=.cjs.js", + "build:esm:ads": "esbuilder src/ads/index.ts --format=esm --outdir=dist/ads --out-extension:.js=.mjs", + "build:cjs:ads": "esbuilder src/ads/index.ts --format=cjs --outdir=dist/ads --out-extension:.js=.cjs.js", + "build:esm:ads:mixin": "esbuilder src/ads/mixin/index.ts --format=esm --outdir=dist/ads/mixin --out-extension:.js=.mjs", + "build:cjs:ads:mixin": "esbuilder src/ads/mixin/index.ts --format=cjs --outdir=dist/ads/mixin --out-extension:.js=.cjs.js", "build:themes": "node ./scripts/build-themes.mjs", "copypolyfills": "shx mkdir -p src/polyfills && shx cp ../../shared/polyfills/index.ts ./src/polyfills/index.ts", "build:types": "tsc", "postbuild:types": "downlevel-dts ./dist/types ./dist/types-ts3.4", - "build": "npm-run-all --parallel 'build:esm -- --minify' 'build:iife -- --minify' 'build:cjs -- --minify' 'build:esm-module -- --minify' 'build:themes'" + "build": "npm-run-all 'build:esm:base -- --minify' 'build:cjs:base -- --minify' 'build:esm:ads:mixin -- --minify' 'build:cjs:ads:mixin -- --minify' --parallel 'build:esm:** -- --minify' 'build:iife:** -- --minify' 'build:cjs:** -- --minify' 'build:esm-module:** -- --minify' 'build:themes'" }, "dependencies": { "@mux/mux-video": "0.25.3", "@mux/playback-core": "0.29.1", "media-chrome": "~4.11.1", - "player.style": "^0.1.8" + "player.style": "^0.1.9" }, "devDependencies": { "@mux/esbuilder": "0.1.0", diff --git a/packages/mux-player/scripts/build-themes.mjs b/packages/mux-player/scripts/build-themes.mjs index bb0f98ee9..f15520403 100644 --- a/packages/mux-player/scripts/build-themes.mjs +++ b/packages/mux-player/scripts/build-themes.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import esbuild from 'esbuild'; -const themes = ['classic', 'microvideo', 'minimal', 'gerwig']; +const themes = ['classic', 'microvideo', 'minimal', 'gerwig', 'news']; const devMode = process.argv.includes('--dev'); const shared = { diff --git a/packages/mux-player/src/ads/index.ts b/packages/mux-player/src/ads/index.ts new file mode 100644 index 000000000..23199dddc --- /dev/null +++ b/packages/mux-player/src/ads/index.ts @@ -0,0 +1,19 @@ +import { globalThis } from '../polyfills'; +// Register (ads) web component. +import '@mux/mux-video/ads'; +import MuxPlayerBaseElement from '@mux/mux-player/base'; +import { AdsPlayerMixin } from '@mux/mux-player/ads/mixin'; +import type { EventMap as MuxVideoAdsEventMap } from '@mux/mux-video/ads'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +export type EventMap = Expand; + +class MuxPlayerElement extends AdsPlayerMixin(MuxPlayerBaseElement) {} + +if (!globalThis.customElements.get('mux-player')) { + globalThis.customElements.define('mux-player', MuxPlayerElement); + (globalThis as any).MuxPlayerElement = MuxPlayerElement; +} + +export * from '@mux/mux-player/base'; +export default MuxPlayerElement; diff --git a/packages/mux-player/src/ads/mixin/index.ts b/packages/mux-player/src/ads/mixin/index.ts new file mode 100644 index 000000000..264847c72 --- /dev/null +++ b/packages/mux-player/src/ads/mixin/index.ts @@ -0,0 +1,78 @@ +import { MuxPlayerElementConstructor, Constructor, IAdsPlayer } from './types.js'; + +export const Attributes = { + AD_TAG_URL: 'ad-tag-url', + ALLOW_AD_BLOCKER: 'allow-ad-blocker', +} as const; + +export function AdsPlayerMixin(superclass: T): Constructor & T { + class AdsPlayer extends superclass implements IAdsPlayer { + static get observedAttributes() { + return [...super.observedAttributes, ...Object.values(Attributes)]; + } + + #tracks: HTMLTrackElement[] = []; + + connectedCallback() { + super.connectedCallback(); + + if (this.media) { + this.media.allowAdBlocker = this.allowAdBlocker; + this.media.adTagUrl = this.adTagUrl; + } + + this.addEventListener('adbreakstart', () => { + this.mediaTheme?.toggleAttribute('mediaadbreak', true); + + // Remove any tracks during ad-break to prevent cues from showing + // but also to fix a bug on iOS where the video would not start. + this.#tracks = Array.from(this.media?.querySelectorAll('track') || []); + this.#tracks.forEach((track) => track.remove()); + }); + + this.addEventListener('adbreakend', () => { + this.mediaTheme?.toggleAttribute('mediaadbreak', false); + this.media?.append(...this.#tracks); + }); + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string) { + super.attributeChangedCallback(attrName, oldValue, newValue); + + if (!this.media) return; + + switch (attrName) { + case Attributes.ALLOW_AD_BLOCKER: + this.media.allowAdBlocker = this.allowAdBlocker; + break; + case Attributes.AD_TAG_URL: + this.media.adTagUrl = this.adTagUrl; + break; + } + } + + get allowAdBlocker() { + return this.hasAttribute(Attributes.ALLOW_AD_BLOCKER); + } + + set allowAdBlocker(val: boolean) { + if (val === this.allowAdBlocker) return; + this.toggleAttribute(Attributes.ALLOW_AD_BLOCKER, Boolean(val)); + } + + get adTagUrl() { + return this.getAttribute(Attributes.AD_TAG_URL) ?? undefined; + } + + set adTagUrl(val: string | undefined) { + if (val === this.adTagUrl) return; + if (val) { + this.setAttribute(Attributes.AD_TAG_URL, val); + } else { + this.removeAttribute(Attributes.AD_TAG_URL); + } + } + } + + return AdsPlayer as unknown as Constructor & T; +} diff --git a/packages/mux-player/src/ads/mixin/types.ts b/packages/mux-player/src/ads/mixin/types.ts new file mode 100644 index 000000000..ac0dbb207 --- /dev/null +++ b/packages/mux-player/src/ads/mixin/types.ts @@ -0,0 +1,32 @@ +import type MuxPlayerElement from '@mux/mux-player/base'; +import type { EventMap as MuxVideoAdsEventMap } from '@mux/mux-video/ads'; + +export type MuxPlayerElementConstructor = Constructor & { + observedAttributes: string[]; +}; + +export type Constructor = new (...args: any[]) => T; + +export interface IAdsPlayer { + /** + * Allow playback with ad blocker. + */ + allowAdBlocker: boolean; + + /** + * The URL of the ad tag to be requested. + */ + adTagUrl: string | undefined; + + addEventListener( + type: K, + listener: (this: HTMLMediaElement, ev: MuxVideoAdsEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: K, + listener: (this: HTMLMediaElement, ev: MuxVideoAdsEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; +} diff --git a/packages/mux-player/src/base.ts b/packages/mux-player/src/base.ts new file mode 100644 index 000000000..2e9e379c6 --- /dev/null +++ b/packages/mux-player/src/base.ts @@ -0,0 +1,1844 @@ +import { globalThis, document } from './polyfills'; +import { MediaController, MediaErrorDialog } from 'media-chrome'; +import { Attributes as MediaControllerAttributes } from 'media-chrome/dist/media-container.js'; +import { MediaStateChangeEvents, MediaUIAttributes, MediaUIEvents } from 'media-chrome/dist/constants.js'; +import 'media-chrome/dist/experimental/index.js'; +import { MediaThemeElement } from 'media-chrome/dist/media-theme-element.js'; +import { MediaError, Attributes as MuxVideoAttributes } from '@mux/mux-video/base'; +import { + StreamTypes, + PlaybackTypes, + addTextTrack, + removeTextTrack, + CmcdTypes, + CmcdTypeValues, + i18n, + parseJwt, + MuxJWTAud, + generatePlayerInitTime, +} from '@mux/playback-core'; +import type { + ValueOf, + Metadata, + PlaybackEngine, + MaxResolutionValue, + MinResolutionValue, + RenditionOrderValue, + Chapter, + CuePoint, + Tokens, +} from '@mux/playback-core'; +import VideoApiElement from './video-api'; +import { + getPlayerVersion, + toPropName, + AttributeTokenList, + getPosterURLFromPlaybackId, + getStoryboardURLFromPlaybackId, + getStreamTypeFromAttr, +} from './helpers'; +import { template } from './template'; +import { render } from './html'; +import { muxMediaErrorToDialog, muxMediaErrorToDevlog } from './errors'; +import { toNumberOrUndefined, containsComposedNode, camelCase, kebabCase } from './utils'; +import * as logger from './logger'; +import type { MuxTemplateProps, ErrorEvent, IMuxPlayerElement } from './types'; +import './themes/gerwig'; +import { HlsConfig } from 'hls.js'; + +export type { EventMap, MuxPlayerElementEventMap } from './types'; +export type { Tokens }; +export { MediaError, generatePlayerInitTime }; + +const DefaultThemeName = 'gerwig'; + +const VideoAttributes = { + SRC: 'src', + POSTER: 'poster', +} as const; + +const PlayerAttributes = { + STYLE: 'style', + DEFAULT_HIDDEN_CAPTIONS: 'default-hidden-captions', + PRIMARY_COLOR: 'primary-color', + SECONDARY_COLOR: 'secondary-color', + ACCENT_COLOR: 'accent-color', + FORWARD_SEEK_OFFSET: 'forward-seek-offset', + BACKWARD_SEEK_OFFSET: 'backward-seek-offset', + PLAYBACK_TOKEN: 'playback-token', + THUMBNAIL_TOKEN: 'thumbnail-token', + STORYBOARD_TOKEN: 'storyboard-token', + DRM_TOKEN: 'drm-token', + STORYBOARD_SRC: 'storyboard-src', + THUMBNAIL_TIME: 'thumbnail-time', + AUDIO: 'audio', + NOHOTKEYS: 'nohotkeys', + HOTKEYS: 'hotkeys', + PLAYBACK_RATES: 'playbackrates', + DEFAULT_SHOW_REMAINING_TIME: 'default-show-remaining-time', + DEFAULT_DURATION: 'default-duration', + TITLE: 'title', + VIDEO_TITLE: 'video-title', // video-title is an alternative for title which doesn't cause a tooltip. + PLACEHOLDER: 'placeholder', + THEME: 'theme', + DEFAULT_STREAM_TYPE: 'default-stream-type', + TARGET_LIVE_WINDOW: 'target-live-window', + EXTRA_SOURCE_PARAMS: 'extra-source-params', + NO_VOLUME_PREF: 'no-volume-pref', + CAST_RECEIVER: 'cast-receiver', + NO_TOOLTIPS: 'no-tooltips', + PROUDLY_DISPLAY_MUX_BADGE: 'proudly-display-mux-badge', +} as const; + +const ThemeAttributeNames = [ + 'audio', + 'backwardseekoffset', + 'defaultduration', + 'defaultshowremainingtime', + 'defaultsubtitles', + 'noautoseektolive', + 'disabled', + 'exportparts', + 'forwardseekoffset', + 'hideduration', + 'hotkeys', + 'nohotkeys', + 'playbackrates', + 'defaultstreamtype', + 'streamtype', + 'style', + 'targetlivewindow', + 'template', + 'title', + 'videotitle', + 'novolumepref', + 'proudlydisplaymuxbadge', +]; + +function getProps(el: MuxPlayerElement, state?: any): MuxTemplateProps { + const props = { + // Give priority to playbackId derrived asset URL's if playbackId is set. + src: !el.playbackId && el.src, + playbackId: el.playbackId, + hasSrc: !!el.playbackId || !!el.src || !!el.currentSrc, + poster: el.poster, + storyboard: el.storyboard, + storyboardSrc: el.getAttribute(PlayerAttributes.STORYBOARD_SRC), + placeholder: el.getAttribute('placeholder'), + themeTemplate: getThemeTemplate(el), + thumbnailTime: !el.tokens.thumbnail && el.thumbnailTime, + autoplay: el.autoplay, + crossOrigin: el.crossOrigin, + loop: el.loop, + // NOTE: Renaming internal prop due to state (sometimes derived from attributeChangedCallback attr values) + // overwriting prop value (type mismatch: string vs. boolean) (CJP) + noHotKeys: el.hasAttribute(PlayerAttributes.NOHOTKEYS), + hotKeys: el.getAttribute(PlayerAttributes.HOTKEYS), + muted: el.muted, + paused: el.paused, + // NOTE: Currently unsupported due to "default true attribute" problem + // playsInline: el.playsInline, + preload: el.preload, + envKey: el.envKey, + preferCmcd: el.preferCmcd, + debug: el.debug, + disableTracking: el.disableTracking, + disableCookies: el.disableCookies, + tokens: el.tokens, + beaconCollectionDomain: el.beaconCollectionDomain, + maxResolution: el.maxResolution, + minResolution: el.minResolution, + programStartTime: el.programStartTime, + programEndTime: el.programEndTime, + assetStartTime: el.assetStartTime, + assetEndTime: el.assetEndTime, + renditionOrder: el.renditionOrder, + metadata: el.metadata, + playerInitTime: el.playerInitTime, + playerSoftwareName: el.playerSoftwareName, + playerSoftwareVersion: el.playerSoftwareVersion, + startTime: el.startTime, + preferPlayback: el.preferPlayback, + audio: el.audio, + defaultStreamType: el.defaultStreamType, + targetLiveWindow: el.getAttribute(MuxVideoAttributes.TARGET_LIVE_WINDOW), + streamType: getStreamTypeFromAttr(el.getAttribute(MuxVideoAttributes.STREAM_TYPE)), + primaryColor: el.getAttribute(PlayerAttributes.PRIMARY_COLOR), + secondaryColor: el.getAttribute(PlayerAttributes.SECONDARY_COLOR), + accentColor: el.getAttribute(PlayerAttributes.ACCENT_COLOR), + forwardSeekOffset: el.forwardSeekOffset, + backwardSeekOffset: el.backwardSeekOffset, + defaultHiddenCaptions: el.defaultHiddenCaptions, + defaultDuration: el.defaultDuration, + defaultShowRemainingTime: el.defaultShowRemainingTime, + hideDuration: getHideDuration(el), + playbackRates: el.getAttribute(PlayerAttributes.PLAYBACK_RATES), + customDomain: el.getAttribute(MuxVideoAttributes.CUSTOM_DOMAIN) ?? undefined, + title: el.getAttribute(PlayerAttributes.TITLE), + videoTitle: el.getAttribute(PlayerAttributes.VIDEO_TITLE) ?? el.getAttribute(PlayerAttributes.TITLE), + novolumepref: el.hasAttribute(PlayerAttributes.NO_VOLUME_PREF), + proudlyDisplayMuxBadge: el.hasAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE), + castReceiver: el.castReceiver, + ...state, + // NOTE: since the attribute value is used as the "source of truth" for the property getter, + // moving this below the `...state` spread so it resolves to the default value when unset (CJP) + extraSourceParams: el.extraSourceParams, + }; + + return props; +} + +const baseFormatErrorMessage = MediaErrorDialog.formatErrorMessage; +MediaErrorDialog.formatErrorMessage = (error: { code: number; message: string }) => { + if (error instanceof MediaError) { + const dialog = muxMediaErrorToDialog(error, false); + return ` + ${dialog?.title ? `

${dialog.title}

` : ''} + ${ + dialog?.message || dialog?.linkUrl + ? `

+ ${dialog?.message} + ${ + dialog?.linkUrl + ? `${dialog.linkText ?? dialog.linkUrl}` + : '' + } +

` + : '' + } + `; + } + return baseFormatErrorMessage(error); +}; + +function getThemeTemplate(el: MuxPlayerElement) { + let themeName = el.theme; + + if (themeName) { + const templateElement = (el.getRootNode() as ShadowRoot | Document | null)?.getElementById?.(themeName); + // NOTE: Since folks may unknowingly use matching ids for elements other than their theme + // (intending to use path two for template identification, below), make sure the matching + // element is, in fact, an HTMLTemplateElement (CJP) + if (templateElement && templateElement instanceof HTMLTemplateElement) return templateElement; + + if (!themeName.startsWith('media-theme-')) { + themeName = `media-theme-${themeName}`; + } + + const ThemeElement = globalThis.customElements.get(themeName) as MediaThemeElement | undefined; + if (ThemeElement?.template) return ThemeElement.template; + } +} + +function getHideDuration(el: MuxPlayerElement) { + const timeDisplay = el.mediaController?.querySelector('media-time-display'); + return ( + timeDisplay && + getComputedStyle(timeDisplay as unknown as HTMLElement) + .getPropertyValue('--media-duration-display-display') + .trim() === 'none' + ); +} + +function getMetadataFromAttrs(el: MuxPlayerElement) { + // Adding title defaulting, when present, as a seed value here to ensure it's + // overridden by metadata-video-title if it is also present. (CJP) + const seedValue: { [key: string]: string } = !!el.videoTitle ? { video_title: el.videoTitle } : {}; + return el + .getAttributeNames() + .filter((attrName) => attrName.startsWith('metadata-')) + .reduce((currAttrs, attrName) => { + const value = el.getAttribute(attrName); + if (value !== null) { + currAttrs[attrName.replace(/^metadata-/, '').replace(/-/g, '_')] = value; + } + return currAttrs; + }, seedValue); +} + +const MuxVideoAttributeNames = Object.values(MuxVideoAttributes); +const VideoAttributeNames = Object.values(VideoAttributes); +const PlayerAttributeNames = Object.values(PlayerAttributes); + +export const playerSoftwareVersion = getPlayerVersion(); +export const playerSoftwareName = 'mux-player'; + +const initialState = { + isDialogOpen: false, +}; + +const DEFAULT_EXTRA_PLAYLIST_PARAMS = { redundant_streams: true }; + +class MuxPlayerElement extends VideoApiElement implements IMuxPlayerElement { + #defaultPlayerInitTime: number; + #isInit = false; + #tokens: Tokens = {}; + #userInactive = true; + #hotkeys = new AttributeTokenList(this, 'hotkeys'); + #state: Partial = { + ...initialState, + onCloseErrorDialog: (event) => { + const localName = (event.composedPath()[0] as HTMLElement)?.localName; + if (localName !== 'media-error-dialog') return; + + this.#setState({ isDialogOpen: false }); + }, + onFocusInErrorDialog: (event) => { + const localName = (event.composedPath()[0] as HTMLElement)?.localName; + if (localName !== 'media-error-dialog') return; + + const isFocusedElementInPlayer = containsComposedNode(this, document.activeElement); + if (!isFocusedElementInPlayer) event.preventDefault(); + }, + }; + + static get NAME() { + return playerSoftwareName; + } + + static get VERSION() { + return playerSoftwareVersion; + } + + static get observedAttributes() { + return [ + ...(VideoApiElement.observedAttributes ?? []), + ...VideoAttributeNames, + ...MuxVideoAttributeNames, + ...PlayerAttributeNames, + ]; + } + + constructor() { + super(); + this.#defaultPlayerInitTime = generatePlayerInitTime(); + + this.attachShadow({ mode: 'open' }); + this.#setupCSSProperties(); + + // If the custom element is defined before the HTML is parsed + // no attributes will be available in the constructor (construction process). + // Wait until initializing attributes in the attributeChangedCallback. + // If this element is connected to the DOM, the attributes will be available. + if (this.isConnected) { + this.#init(); + } + } + + #init() { + if (this.#isInit) return; + this.#isInit = true; + + // The next line triggers the first render of the template. + this.#render(); + + // Fixes a bug in React where mux-player's CE children were not upgraded yet. + // These lines ensure the rendered mux-video and media-controller are upgraded, + // even before they are connected to the main document. + try { + customElements.upgrade(this.mediaTheme as Node); + if (!(this.mediaTheme instanceof globalThis.HTMLElement)) throw ''; + } catch (_error) { + logger.error(` failed to upgrade!`); + } + + try { + customElements.upgrade(this.media as Node); + } catch (_error) { + logger.error('underlying media element failed to upgrade!'); + } + + try { + customElements.upgrade(this.mediaController as Node); + if (!(this.mediaController instanceof MediaController)) throw ''; + } catch (_error) { + logger.error(` failed to upgrade!`); + } + + this.#setUpThemeAttributes(); + this.#setUpErrors(); + this.#setUpCaptionsButton(); + this.#userInactive = this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? true; + this.#setUpCaptionsMovement(); + + // NOTE: Make sure we re-render when stream type changes to ensure other props-driven + // template details get updated appropriately (e.g. thumbnails track) (CJP) + this.media?.addEventListener('streamtypechange', () => this.#render()); + + // NOTE: Make sure we re-render when tags are appended so hasSrc is updated. + this.media?.addEventListener('loadstart', () => this.#render()); + } + + #setupCSSProperties() { + // registerProperty will throw if the prop has already been registered + // and there's currently no way to check ahead of time. + // initialValue's are defined in the theme + try { + // @ts-ignore + window?.CSS?.registerProperty({ + name: '--media-primary-color', + syntax: '', + inherits: true, + }); + // @ts-ignore + window?.CSS?.registerProperty({ + name: '--media-secondary-color', + syntax: '', + inherits: true, + }); + } catch (_error) {} + } + + get mediaTheme(): Element | null | undefined { + return this.shadowRoot?.querySelector('media-theme'); + } + + get mediaController(): MediaController | null | undefined { + return this.mediaTheme?.shadowRoot?.querySelector('media-controller'); + } + + connectedCallback() { + const muxVideo = this.media; + if (muxVideo) { + muxVideo.metadata = getMetadataFromAttrs(this); + } + } + + #setState(newState: Record) { + Object.assign(this.#state, newState); + this.#render(); + } + + #render(props: Record = {}) { + render(template(getProps(this, { ...this.#state, ...props })), this.shadowRoot as Node); + } + + #setUpThemeAttributes() { + // Forward `theme-` prefixed attributes to the theme. + // e.g. `theme-control-bar-vertical` for the Micro theme. + const setThemeAttribute = (attributeName: string | null) => { + if (!attributeName?.startsWith('theme-')) return; + + const themeAttrName = attributeName.replace(/^theme-/, ''); + if (ThemeAttributeNames.includes(themeAttrName)) return; + + const value = this.getAttribute(attributeName); + if (value != null) { + this.mediaTheme?.setAttribute(themeAttrName, value); + } else { + this.mediaTheme?.removeAttribute(themeAttrName); + } + }; + + const observer = new MutationObserver((mutationList) => { + for (const { attributeName } of mutationList) { + setThemeAttribute(attributeName); + } + }); + + observer.observe(this, { attributes: true }); + this.getAttributeNames().forEach(setThemeAttribute); + } + + #setUpErrors() { + const onError = (_event: Event) => { + let error = this.media?.error as unknown as MediaError; + + if (!(error instanceof MediaError)) { + const { message, code } = error ?? {}; + error = new MediaError(message, code); + } + + // Don't show an error dialog if it's not fatal. + if (!error?.fatal) { + logger.warn(error); + if (error.data) { + logger.warn(`${error.name} data:`, error.data); + } + return; + } + + const devlog = muxMediaErrorToDevlog(error, false); + + if (devlog.message) { + logger.devlog(devlog); + } + + logger.error(error); + if (error.data) { + logger.error(`${error.name} data:`, error.data); + } + + this.#setState({ isDialogOpen: true }); + }; + + // Keep this event listener on mux-player instead of calling onError directly + // from video.onerror. This allows us to simulate errors from the outside. + this.addEventListener('error', onError); + + /** @TODO Push errorTranslator logic down to playback-core. Should be able to use MediaError message + context + code (muxCode?) (CJP) */ + if (this.media) { + this.media.errorTranslator = (errorEvent: ErrorEvent = {}) => { + if (!(this.media?.error instanceof MediaError)) return errorEvent; + + const devlog = muxMediaErrorToDevlog(this.media?.error, false); + + return { + player_error_code: this.media?.error.code, + player_error_message: devlog.message ? String(devlog.message) : errorEvent.player_error_message, + player_error_context: devlog.context ? String(devlog.context) : errorEvent.player_error_context, + }; + }; + } + } + + #setUpCaptionsButton() { + const onTrackCountChange = () => this.#render(); + this.media?.textTracks?.addEventListener('addtrack', onTrackCountChange); + this.media?.textTracks?.addEventListener('removetrack', onTrackCountChange); + } + + #setUpCaptionsMovement() { + const isFirefox = /Firefox/i.test(navigator.userAgent); + if (!isFirefox) return; + + let selectedTrack: TextTrack; + const cuesmap = new WeakMap(); + + const shouldSkipLineToggle = () => { + // skip line toggle when: + // - streamType is live, unless secondary color is set or player size is too small + // - native fullscreen on iPhones + return this.streamType === StreamTypes.LIVE && !this.secondaryColor && this.offsetWidth >= 800; + }; + + // toggles activeCues for a particular track depending on whether the user is active or not + const toggleLines = (track: TextTrack, userInactive: boolean, force = false) => { + if (shouldSkipLineToggle()) { + return; + } + + const cues = Array.from((track && track.activeCues) || []) as VTTCue[]; + + cues.forEach((cue) => { + // ignore cues that are + // - positioned vertically via percentage. + // - cues that are not at the bottom + // - line is less than -5 + // - line is between 0 and 10 + // @ts-ignore + if (!cue.snapToLines || cue.line < -5 || (cue.line >= 0 && cue.line < 10)) { + return; + } + + // if the user is active or if the player is paused, the captions should be moved up + if (!userInactive || this.paused) { + // for cues that have more than one line, we want to push the cue further up + const lines = cue.text.split('\n').length; + // start at -3 to account for thumbnails as well. + let offset = -3; + + if (this.streamType === StreamTypes.LIVE) { + offset = -2; + } + + const setTo = offset - lines; + + // if the line is already set to -4, we don't want to update it again + // this can happen in the same tick on chrome and safari which fire a cuechange + // event when the line property is changed to a different value. + if (cue.line === setTo && !force) { + return; + } + + if (!cuesmap.has(cue)) { + cuesmap.set(cue, cue.line); + } + + cue.line = setTo; + } else { + setTimeout(() => { + cue.line = cuesmap.get(cue) || 'auto'; + }, 500); + } + }); + }; + + // this is necessary so that if a cue becomes active while the user is active, we still position it above the control bar + const cuechangeHandler = () => { + toggleLines(selectedTrack, this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? false); + }; + + const selectTrack = () => { + const tracks = Array.from(this.mediaController?.media?.textTracks || []) as TextTrack[]; + const newSelectedTrack = tracks.filter( + (t) => ['subtitles', 'captions'].includes(t.kind) && t.mode === 'showing' + )[0] as TextTrack; + + if (newSelectedTrack !== selectedTrack) { + selectedTrack?.removeEventListener('cuechange', cuechangeHandler); + } + + selectedTrack = newSelectedTrack; + selectedTrack?.addEventListener('cuechange', cuechangeHandler); + // it's possible there are currently active cues on the new track + toggleLines(selectedTrack, this.#userInactive); + }; + + selectTrack(); + // update the selected track as necessary + this.textTracks?.addEventListener('change', selectTrack); + this.textTracks?.addEventListener('addtrack', selectTrack); + + this.addEventListener('userinactivechange', () => { + const newUserInactive = this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? true; + + if (this.#userInactive === newUserInactive) { + return; + } + + this.#userInactive = newUserInactive; + + toggleLines(selectedTrack, this.#userInactive); + }); + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string) { + // Initialize right after construction when the attributes become available. + this.#init(); + + super.attributeChangedCallback(attrName, oldValue, newValue); + + switch (attrName) { + case PlayerAttributes.HOTKEYS: + this.#hotkeys.value = newValue; + break; + case PlayerAttributes.THUMBNAIL_TIME: { + if (newValue != null && this.tokens.thumbnail) { + logger.warn( + i18n( + `Use of thumbnail-time with thumbnail-token is currently unsupported. Ignore thumbnail-time.` + ).toString() + ); + } + break; + } + case PlayerAttributes.THUMBNAIL_TOKEN: { + if (newValue) { + const jwtObj = parseJwt(newValue); + /** @TODO refactor to account for other JWT-based errors (CJP) */ + if (jwtObj) { + const { aud } = jwtObj; + const expectedAud = MuxJWTAud.THUMBNAIL; + const tokenNamePrefix = 'thumbnail'; + if (aud !== expectedAud) { + logger.warn( + i18n( + `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` + ).format({ aud, expectedAud, tokenNamePrefix }) + ); + } + } + } + break; + } + case PlayerAttributes.STORYBOARD_TOKEN: { + if (newValue) { + const jwtObj = parseJwt(newValue); + /** @TODO refactor to account for other JWT-based errors (CJP) */ + if (jwtObj) { + const { aud } = jwtObj; + const expectedAud = MuxJWTAud.STORYBOARD; + const tokenNamePrefix = 'storyboard'; + if (aud !== expectedAud) { + logger.warn( + i18n( + `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` + ).format({ aud, expectedAud, tokenNamePrefix }) + ); + } + } + } + break; + } + case PlayerAttributes.DRM_TOKEN: { + if (newValue) { + const jwtObj = parseJwt(newValue); + /** @TODO refactor to account for other JWT-based errors (CJP) */ + if (jwtObj) { + const { aud } = jwtObj; + const expectedAud = MuxJWTAud.DRM; + const tokenNamePrefix = 'drm'; + if (aud !== expectedAud) { + logger.warn( + i18n( + `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` + ).format({ aud, expectedAud, tokenNamePrefix }) + ); + } + } + } + break; + } + case MuxVideoAttributes.PLAYBACK_ID: { + if (newValue?.includes('?token')) { + logger.error( + i18n( + 'The specificed playback ID {playbackId} contains a token which must be provided via the playback-token attribute.' + ).format({ + playbackId: newValue, + }) + ); + } + break; + } + case MuxVideoAttributes.STREAM_TYPE: { + if (newValue && ![StreamTypes.LIVE, StreamTypes.ON_DEMAND, StreamTypes.UNKNOWN].includes(newValue as any)) { + // Handle deprecated values by translating to new properties for the time being. + // NOTE: The value of `streamType` / `stream-type` will be translated at the template + // level. See template.ts for more information (CJP). + if (['ll-live', 'live:dvr', 'll-live:dvr'].includes(this.streamType as any)) { + // NOTE: For now, we won't log any warnings/errors for "deprecated" stream types (CJP). + // logger.devlog({ + // file: 'deprecated-stream-type.md', + // message: i18n( + // `The stream type is deprecated: \`{streamType}\`. Please provide stream-type as either: \`on-demand\`, \`live\`. For DVR, please use \`target-live-window="Infinity"\`` + // ).format({ streamType: this.streamType }), + // }); + this.targetLiveWindow = newValue.includes('dvr') ? Number.POSITIVE_INFINITY : 0; + } else { + logger.devlog({ + file: 'invalid-stream-type.md', + message: i18n( + 'Invalid stream-type value supplied: `{streamType}`. Please provide stream-type as either: `on-demand` or `live`' + ).format({ streamType: this.streamType }), + }); + } + } else { + // NOTE: For now, since we are continuing support of the deprecated stream types (namely, "dvr" types) and not advertising the + // new APIs such as `targetLiveWindow`/`target-live-window`, we will (presumpuously) update the `targetLiveWindow` based on the + // stream type (CJP). + if (newValue === StreamTypes.LIVE) { + // Don't override if the user has already set a value. + if (this.getAttribute(PlayerAttributes.TARGET_LIVE_WINDOW) == null) { + this.targetLiveWindow = 0; + } + } else { + this.targetLiveWindow = Number.NaN; + } + } + } + } + + const shouldClearState = [ + MuxVideoAttributes.PLAYBACK_ID, + VideoAttributes.SRC, + PlayerAttributes.PLAYBACK_TOKEN, + // @ts-ignore + ].includes(attrName); + + if (shouldClearState && oldValue !== newValue) { + this.#state = { ...this.#state, ...initialState }; + } + + this.#render({ [toPropName(attrName)]: newValue }); + } + + async requestFullscreen(_options?: FullscreenOptions) { + if (!this.mediaController || this.mediaController.hasAttribute(MediaUIAttributes.MEDIA_IS_FULLSCREEN)) { + return; + } + this.mediaController?.dispatchEvent( + new globalThis.CustomEvent(MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST, { + composed: true, + bubbles: true, + }) + ); + return new Promise((resolve, _reject) => { + this.mediaController?.addEventListener(MediaStateChangeEvents.MEDIA_IS_FULLSCREEN, () => resolve(), { + once: true, + }); + }); + } + + async exitFullscreen() { + if (!this.mediaController || !this.mediaController.hasAttribute(MediaUIAttributes.MEDIA_IS_FULLSCREEN)) { + return; + } + this.mediaController?.dispatchEvent( + new globalThis.CustomEvent(MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST, { + composed: true, + bubbles: true, + }) + ); + return new Promise((resolve, _reject) => { + this.mediaController?.addEventListener(MediaStateChangeEvents.MEDIA_IS_FULLSCREEN, () => resolve(), { + once: true, + }); + }); + } + + get preferCmcd() { + return (this.getAttribute(MuxVideoAttributes.PREFER_CMCD) as ValueOf) ?? undefined; + } + + set preferCmcd(value: ValueOf | undefined) { + if (value === this.preferCmcd) return; + if (!value) { + this.removeAttribute(MuxVideoAttributes.PREFER_CMCD); + } else if (CmcdTypeValues.includes(value)) { + this.setAttribute(MuxVideoAttributes.PREFER_CMCD, value); + } else { + logger.warn(`Invalid value for preferCmcd. Must be one of ${CmcdTypeValues.join()}`); + } + } + + get hasPlayed() { + return this.mediaController?.hasAttribute(MediaUIAttributes.MEDIA_HAS_PLAYED) ?? false; + } + + get inLiveWindow() { + return this.mediaController?.hasAttribute(MediaUIAttributes.MEDIA_TIME_IS_LIVE); + } + + get _hls(): PlaybackEngine | undefined { + return this.media?._hls; + } + + get mux() { + return this.media?.mux; + } + + /** + * Gets the theme. + */ + get theme() { + return this.getAttribute(PlayerAttributes.THEME) ?? DefaultThemeName; + } + + /** + * Sets the theme. + */ + set theme(val) { + this.setAttribute(PlayerAttributes.THEME, `${val}`); + } + + /** + * Get the theme attributes in a plain object (camelCase keys). + * This doesn't include already defined attributes. e.g. streamType, disabled, etc. + */ + get themeProps() { + const theme = this.mediaTheme; + if (!theme) return; + + const props: Record = {}; + + for (const name of theme.getAttributeNames()) { + if (ThemeAttributeNames.includes(name)) continue; + + const value: string | boolean | null = theme.getAttribute(name); + props[camelCase(name)] = value === '' ? true : value; + } + + return props; + } + + /** + * Set the theme attributes via a plain object. + */ + set themeProps(props) { + this.#init(); + + const themeProps = { ...this.themeProps, ...props }; + + for (const name in themeProps) { + if (ThemeAttributeNames.includes(name)) continue; + + const value: string | boolean | null | undefined = props?.[name]; + + if (typeof value === 'boolean' || value == null) { + this.mediaTheme?.toggleAttribute(kebabCase(name), Boolean(value)); + } else { + this.mediaTheme?.setAttribute(kebabCase(name), value); + } + } + } + + /** + * Get Mux asset playback id. + */ + get playbackId() { + // Don't get the mux-video attribute here because it could have the + // playback token appended to it. + return this.getAttribute(MuxVideoAttributes.PLAYBACK_ID) ?? undefined; + } + + /** + * Set Mux asset playback id. + */ + set playbackId(val) { + if (val) { + this.setAttribute(MuxVideoAttributes.PLAYBACK_ID, val); + } else { + this.removeAttribute(MuxVideoAttributes.PLAYBACK_ID); + } + } + + /** + * Get the string that reflects the src HTML attribute, which contains the URL of a media resource to use. + */ + get src() { + // Only get the internal video.src if a playbackId is present. + if (this.playbackId) { + return getVideoAttribute(this, VideoAttributes.SRC) ?? undefined; + } + return this.getAttribute(VideoAttributes.SRC) ?? undefined; + } + + /** + * Set the string that reflects the src HTML attribute, which contains the URL of a media resource to use. + */ + set src(val) { + if (val) { + this.setAttribute(VideoAttributes.SRC, val); + } else { + this.removeAttribute(VideoAttributes.SRC); + } + } + + /** + * Gets a URL of an image to display, for example, like a movie poster. This can be a still frame from the video, or another image if no video data is available. + */ + get poster() { + const val = this.getAttribute(VideoAttributes.POSTER); + if (val != null) return val; + // If a playback token but no thumbnail token is provided, + // assume a token is required for the thumbnail/poster URL and + // simply avoid requesting it in this case. + const { tokens } = this; + if (tokens.playback && !tokens.thumbnail) { + logger.warn('Missing expected thumbnail token. No poster image will be shown'); + return undefined; + } + + // Get the derived poster if a playbackId is present. + if (this.playbackId && !this.audio) { + return getPosterURLFromPlaybackId(this.playbackId, { + customDomain: this.customDomain, + thumbnailTime: this.thumbnailTime ?? this.startTime, + programTime: this.programStartTime, + token: tokens.thumbnail, + }); + } + + return undefined; + } + + /** + * Sets a URL of an image to display, for example, like a movie poster. This can be a still frame from the video, or another image if no video data is available. + */ + set poster(val) { + if (val || val === '') { + this.setAttribute(VideoAttributes.POSTER, val); + } else { + this.removeAttribute(VideoAttributes.POSTER); + } + } + + /** + * Return the storyboard-src attribute URL + */ + get storyboardSrc() { + return this.getAttribute(PlayerAttributes.STORYBOARD_SRC) ?? undefined; + } + + /** + * Set the storyboard-src attribute URL + */ + set storyboardSrc(src: string | undefined) { + if (!src) { + this.removeAttribute(PlayerAttributes.STORYBOARD_SRC); + } else { + this.setAttribute(PlayerAttributes.STORYBOARD_SRC, src); + } + } + + /** + * Return the storyboard URL when a playback ID or storyboard-src is provided, + * we aren't an audio player and the stream-type isn't live. + */ + get storyboard() { + const { tokens } = this; + // If the storyboardSrc has been explicitly set, assume it should be used + if (this.storyboardSrc && !tokens.storyboard) return this.storyboardSrc; + if ( + // NOTE: Some audio use cases may have a storyboard (e.g. it's an audio+video stream being played *as* audio) + // Consider supporting cases (CJP) + this.audio || + !this.playbackId || + !this.streamType || + [StreamTypes.LIVE, StreamTypes.UNKNOWN].includes(this.streamType as any) || + // If a playback token but no storyboard token is provided, + // assume a token is required for the storyboard URL URL and + // simply avoid requesting it in this case. + (tokens.playback && !tokens.storyboard) + ) { + return undefined; + } + return getStoryboardURLFromPlaybackId(this.playbackId, { + customDomain: this.customDomain, + token: tokens.storyboard, + programStartTime: this.programStartTime, + programEndTime: this.programEndTime, + }); + } + + /** + * Gets the boolean indicator this is an audio player. + */ + get audio() { + return this.hasAttribute(PlayerAttributes.AUDIO); + } + + /** + * Sets the boolean indicator this is an audio player. + */ + set audio(val: boolean) { + if (!val) { + this.removeAttribute(PlayerAttributes.AUDIO); + return; + } + this.setAttribute(PlayerAttributes.AUDIO, ''); + } + + get hotkeys() { + return this.#hotkeys; + } + + get nohotkeys() { + return this.hasAttribute(PlayerAttributes.NOHOTKEYS); + } + + set nohotkeys(val: boolean) { + if (!val) { + this.removeAttribute(PlayerAttributes.NOHOTKEYS); + return; + } + this.setAttribute(PlayerAttributes.NOHOTKEYS, ''); + } + + /** + * Get the thumbnailTime offset used for the poster image. + */ + get thumbnailTime() { + return toNumberOrUndefined(this.getAttribute(PlayerAttributes.THUMBNAIL_TIME)); + } + + /** + * Set the thumbnailTime offset used for the poster image. + */ + set thumbnailTime(val: number | undefined) { + this.setAttribute(PlayerAttributes.THUMBNAIL_TIME, `${val}`); + } + + /** + * Get the video title shown in the player. + */ + get videoTitle() { + return this.getAttribute(PlayerAttributes.VIDEO_TITLE) ?? this.getAttribute(PlayerAttributes.TITLE) ?? ''; + } + + /** + * Set the video title shown in the player. + */ + set videoTitle(val: string) { + if (val === this.videoTitle) return; + + if (!!val) { + this.setAttribute(PlayerAttributes.VIDEO_TITLE, val); + } else { + this.removeAttribute(PlayerAttributes.VIDEO_TITLE); + } + } + + /** + * Gets the data URL of a placeholder image shown before the thumbnail is loaded. + */ + get placeholder() { + return getVideoAttribute(this, PlayerAttributes.PLACEHOLDER) ?? ''; + } + + /** + * Sets the data URL of a placeholder image shown before the thumbnail is loaded. + */ + set placeholder(val) { + this.setAttribute(PlayerAttributes.PLACEHOLDER, `${val}`); + } + + /** + * Get the primary color used by the player. + */ + get primaryColor() { + let color = this.getAttribute(PlayerAttributes.PRIMARY_COLOR); + if (color != null) return color; + + // Fallback to computed style if no attribute is set, causes layout. + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a + if (this.mediaTheme) { + color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_primary-color')?.trim(); + if (color) return color; + } + } + + /** + * Set the primary color used by the player. + */ + set primaryColor(val: string | undefined) { + this.setAttribute(PlayerAttributes.PRIMARY_COLOR, `${val}`); + } + + /** + * Get the secondary color used by the player. + */ + get secondaryColor() { + let color = this.getAttribute(PlayerAttributes.SECONDARY_COLOR); + if (color != null) return color; + + // Fallback to computed style if no attribute is set, causes layout. + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a + if (this.mediaTheme) { + color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_secondary-color')?.trim(); + if (color) return color; + } + } + + /** + * Set the secondary color used by the player. + */ + set secondaryColor(val: string | undefined) { + this.setAttribute(PlayerAttributes.SECONDARY_COLOR, `${val}`); + } + + /** + * Get the accent color used by the player. + */ + get accentColor() { + let color = this.getAttribute(PlayerAttributes.ACCENT_COLOR); + if (color != null) return color; + + // Fallback to computed style if no attribute is set, causes layout. + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a + if (this.mediaTheme) { + color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_accent-color')?.trim(); + if (color) return color; + } + } + + /** + * Set the accent color used by the player. + */ + set accentColor(val: string | undefined) { + this.setAttribute(PlayerAttributes.ACCENT_COLOR, `${val}`); + } + + get defaultShowRemainingTime() { + return this.hasAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME); + } + + set defaultShowRemainingTime(val: boolean | undefined) { + if (!val) { + this.removeAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME); + } else { + this.setAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME, ''); + } + } + + /** + * Get the playback rates applied to the playback rate control. + */ + get playbackRates() { + if (!this.hasAttribute(PlayerAttributes.PLAYBACK_RATES)) return undefined; + // /NOTE: This is duplicating the code from Media Chrome's media-playback-rate-button (CJP) + return (this.getAttribute(PlayerAttributes.PLAYBACK_RATES) as string) + .trim() + .split(/\s*,?\s+/) + .map((str) => Number(str)) + .filter((num) => !Number.isNaN(num)) + .sort((a, b) => a - b); + } + + /** + * Set the playback rates applied to the playback rate control. + */ + set playbackRates(val: number[] | undefined) { + if (!val) { + this.removeAttribute(PlayerAttributes.PLAYBACK_RATES); + return; + } + this.setAttribute(PlayerAttributes.PLAYBACK_RATES, val.join(' ')); + } + + /** + * Get the offset applied to the forward seek button. + */ + get forwardSeekOffset() { + return toNumberOrUndefined(this.getAttribute(PlayerAttributes.FORWARD_SEEK_OFFSET)) ?? 10; + } + + /** + * Set the offset applied to the forward seek button. + */ + set forwardSeekOffset(val: number | undefined) { + this.setAttribute(PlayerAttributes.FORWARD_SEEK_OFFSET, `${val}`); + } + + /** + * Get the offset applied to the backward seek button. + */ + get backwardSeekOffset() { + return toNumberOrUndefined(this.getAttribute(PlayerAttributes.BACKWARD_SEEK_OFFSET)) ?? 10; + } + + /** + * Set the offset applied to the forward seek button. + */ + set backwardSeekOffset(val: number | undefined) { + this.setAttribute(PlayerAttributes.BACKWARD_SEEK_OFFSET, `${val}`); + } + + /** + * Get the boolean value of default hidden captions. + * By default returns false so captions are enabled on initial load. + */ + get defaultHiddenCaptions() { + return this.hasAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS); + } + + /** + * Set the default hidden captions flag. + */ + set defaultHiddenCaptions(val: boolean | undefined) { + if (!val) { + this.removeAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS); + } else { + this.setAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS, ''); + } + } + + /** + * Get the boolean value of default hidden captions. + * By default returns false so captions are enabled on initial load. + */ + get defaultDuration() { + return toNumberOrUndefined(this.getAttribute(PlayerAttributes.DEFAULT_DURATION)); + } + + /** + * Set the default hidden captions flag. + */ + set defaultDuration(val: number | undefined) { + if (val == undefined) { + this.removeAttribute(PlayerAttributes.DEFAULT_DURATION); + } else { + this.setAttribute(PlayerAttributes.DEFAULT_DURATION, `${val}`); + } + } + + get playerInitTime() { + if (!this.hasAttribute(MuxVideoAttributes.PLAYER_INIT_TIME)) return this.#defaultPlayerInitTime; + return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PLAYER_INIT_TIME)); + } + + set playerInitTime(val) { + // don't cause an infinite loop and avoid change event dispatching + if (val == this.playerInitTime) return; + + if (val == null) { + this.removeAttribute(MuxVideoAttributes.PLAYER_INIT_TIME); + } else { + this.setAttribute(MuxVideoAttributes.PLAYER_INIT_TIME, `${+val}`); + } + } + + /** + * Get the player software name. Used by Mux Data. + */ + get playerSoftwareName() { + return this.getAttribute(MuxVideoAttributes.PLAYER_SOFTWARE_NAME) ?? playerSoftwareName; + } + + /** + * Get the player software version. Used by Mux Data. + */ + get playerSoftwareVersion() { + return this.getAttribute(MuxVideoAttributes.PLAYER_SOFTWARE_VERSION) ?? playerSoftwareVersion; + } + + /** + * Get the beacon collection domain. Used by Mux Data. + */ + get beaconCollectionDomain() { + return this.getAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN) ?? undefined; + } + + /** + * Set the beacon collection domain. Used by Mux Data. + */ + set beaconCollectionDomain(val: string | undefined) { + // don't cause an infinite loop + if (val === this.beaconCollectionDomain) return; + + if (val) { + this.setAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN, val); + } else { + this.removeAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN); + } + } + + get maxResolution() { + return (this.getAttribute(MuxVideoAttributes.MAX_RESOLUTION) as MaxResolutionValue) ?? undefined; + } + + set maxResolution(val: MaxResolutionValue | undefined) { + if (val === this.maxResolution) return; + + if (val) { + this.setAttribute(MuxVideoAttributes.MAX_RESOLUTION, val); + } else { + this.removeAttribute(MuxVideoAttributes.MAX_RESOLUTION); + } + } + + get minResolution() { + return (this.getAttribute(MuxVideoAttributes.MIN_RESOLUTION) as MinResolutionValue) ?? undefined; + } + + set minResolution(val: MinResolutionValue | undefined) { + if (val === this.minResolution) return; + + if (val) { + this.setAttribute(MuxVideoAttributes.MIN_RESOLUTION, val); + } else { + this.removeAttribute(MuxVideoAttributes.MIN_RESOLUTION); + } + } + + get renditionOrder() { + return (this.getAttribute(MuxVideoAttributes.RENDITION_ORDER) as RenditionOrderValue) ?? undefined; + } + + set renditionOrder(val: RenditionOrderValue | undefined) { + if (val === this.renditionOrder) return; + + if (val) { + this.setAttribute(MuxVideoAttributes.RENDITION_ORDER, val); + } else { + this.removeAttribute(MuxVideoAttributes.RENDITION_ORDER); + } + } + + get programStartTime() { + return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PROGRAM_START_TIME)); + } + + set programStartTime(val: number | undefined) { + if (val == undefined) { + this.removeAttribute(MuxVideoAttributes.PROGRAM_START_TIME); + } else { + this.setAttribute(MuxVideoAttributes.PROGRAM_START_TIME, `${val}`); + } + } + + get programEndTime() { + return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PROGRAM_END_TIME)); + } + + set programEndTime(val: number | undefined) { + if (val == undefined) { + this.removeAttribute(MuxVideoAttributes.PROGRAM_END_TIME); + } else { + this.setAttribute(MuxVideoAttributes.PROGRAM_END_TIME, `${val}`); + } + } + + get assetStartTime() { + return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.ASSET_START_TIME)); + } + + set assetStartTime(val: number | undefined) { + if (val == undefined) { + this.removeAttribute(MuxVideoAttributes.ASSET_START_TIME); + } else { + this.setAttribute(MuxVideoAttributes.ASSET_START_TIME, `${val}`); + } + } + + get assetEndTime() { + return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.ASSET_END_TIME)); + } + + set assetEndTime(val: number | undefined) { + if (val == undefined) { + this.removeAttribute(MuxVideoAttributes.ASSET_END_TIME); + } else { + this.setAttribute(MuxVideoAttributes.ASSET_END_TIME, `${val}`); + } + } + + get extraSourceParams() { + if (!this.hasAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS)) { + return DEFAULT_EXTRA_PLAYLIST_PARAMS; + } + + return [...new URLSearchParams(this.getAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS) as string).entries()].reduce( + (paramsObj, [k, v]) => { + paramsObj[k] = v; + return paramsObj; + }, + {} as Record + ); + } + + set extraSourceParams(value: Record) { + if (value == null) { + this.removeAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS); + } else { + this.setAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS, new URLSearchParams(value).toString()); + } + } + + /** + * Get Mux asset custom domain. + */ + get customDomain() { + return this.getAttribute(MuxVideoAttributes.CUSTOM_DOMAIN) ?? undefined; + } + + /** + * Set Mux asset custom domain. + */ + set customDomain(val: string | undefined) { + // dont' cause an infinite loop + if (val === this.customDomain) return; + + if (val) { + this.setAttribute(MuxVideoAttributes.CUSTOM_DOMAIN, val); + } else { + this.removeAttribute(MuxVideoAttributes.CUSTOM_DOMAIN); + } + } + + /** + * Get Mux Data env key. + */ + get envKey() { + return getVideoAttribute(this, MuxVideoAttributes.ENV_KEY) ?? undefined; + } + + /** + * Set Mux Data env key. + */ + set envKey(val: string | undefined) { + this.setAttribute(MuxVideoAttributes.ENV_KEY, `${val}`); + } + + /** + * Get no-volume-pref flag. + */ + get noVolumePref() { + return this.hasAttribute(PlayerAttributes.NO_VOLUME_PREF); + } + + /** + * Set video engine debug flag. + */ + set noVolumePref(val) { + if (val) { + this.setAttribute(PlayerAttributes.NO_VOLUME_PREF, ''); + } else { + this.removeAttribute(PlayerAttributes.NO_VOLUME_PREF); + } + } + + /** + * Get video engine debug flag. + */ + get debug() { + return getVideoAttribute(this, MuxVideoAttributes.DEBUG) != null; + } + + /** + * Set video engine debug flag. + */ + set debug(val) { + if (val) { + this.setAttribute(MuxVideoAttributes.DEBUG, ''); + } else { + this.removeAttribute(MuxVideoAttributes.DEBUG); + } + } + + /** + * Get video engine disable tracking flag. + */ + get disableTracking() { + return getVideoAttribute(this, MuxVideoAttributes.DISABLE_TRACKING) != null; + } + + /** + * Set video engine disable tracking flag. + */ + set disableTracking(val) { + this.toggleAttribute(MuxVideoAttributes.DISABLE_TRACKING, !!val); + } + + /** + * Get video engine disable cookies flag. + */ + get disableCookies() { + return getVideoAttribute(this, MuxVideoAttributes.DISABLE_COOKIES) != null; + } + + /** + * Set video engine disable cookies flag. + */ + set disableCookies(val) { + if (val) { + this.setAttribute(MuxVideoAttributes.DISABLE_COOKIES, ''); + } else { + this.removeAttribute(MuxVideoAttributes.DISABLE_COOKIES); + } + } + + /** + * Get stream type. + */ + get streamType() { + return this.getAttribute(MuxVideoAttributes.STREAM_TYPE) ?? this.media?.streamType ?? StreamTypes.UNKNOWN; + } + + /** + * Set stream type. + */ + set streamType(val) { + this.setAttribute(MuxVideoAttributes.STREAM_TYPE, `${val}`); + } + + get defaultStreamType() { + return ( + (this.getAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE) as ValueOf) ?? + (this.mediaController?.getAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE) as ValueOf) ?? + StreamTypes.ON_DEMAND + ); + } + + set defaultStreamType(val: ValueOf | undefined) { + if (val) { + this.setAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE, val); + } else { + this.removeAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE); + } + } + + get targetLiveWindow() { + // Allow overriding inferred `targetLiveWindow` + if (this.hasAttribute(PlayerAttributes.TARGET_LIVE_WINDOW)) { + return +(this.getAttribute(PlayerAttributes.TARGET_LIVE_WINDOW) as string) as number; + } + return this.media?.targetLiveWindow ?? Number.NaN; + } + + set targetLiveWindow(val: number | undefined) { + // don't cause an infinite loop and avoid change event dispatching + if (val == this.targetLiveWindow || (Number.isNaN(val) && Number.isNaN(this.targetLiveWindow))) return; + + if (val == null) { + this.removeAttribute(PlayerAttributes.TARGET_LIVE_WINDOW); + } else { + this.setAttribute(PlayerAttributes.TARGET_LIVE_WINDOW, `${+val}`); + } + } + + get liveEdgeStart() { + return this.media?.liveEdgeStart; + } + + /** + * Get the start time. + */ + get startTime() { + return toNumberOrUndefined(getVideoAttribute(this, MuxVideoAttributes.START_TIME)); + } + + /** + * Set the start time. + */ + set startTime(val) { + this.setAttribute(MuxVideoAttributes.START_TIME, `${val}`); + } + + get preferPlayback(): ValueOf | undefined { + const val = this.getAttribute(MuxVideoAttributes.PREFER_PLAYBACK); + if (val === PlaybackTypes.MSE || val === PlaybackTypes.NATIVE) return val; + return undefined; + } + + set preferPlayback(val: ValueOf | undefined) { + if (val === this.preferPlayback) return; + + if (val === PlaybackTypes.MSE || val === PlaybackTypes.NATIVE) { + this.setAttribute(MuxVideoAttributes.PREFER_PLAYBACK, val); + } else { + this.removeAttribute(MuxVideoAttributes.PREFER_PLAYBACK); + } + } + + /** + * Get the metadata object for Mux Data. + */ + get metadata(): Readonly | undefined { + return this.media?.metadata; + } + + /** + * Set the metadata object for Mux Data. + */ + set metadata(val: Readonly | undefined) { + this.#init(); + + // NOTE: This condition should never be met. If it is, there is a bug (CJP) + if (!this.media) { + logger.error('underlying media element missing when trying to set metadata. metadata will not be set.'); + return; + } + this.media.metadata = { ...getMetadataFromAttrs(this), ...val }; + } + + /** + * Get the metadata object for Mux Data. + */ + get _hlsConfig() { + return this.media?._hlsConfig; + } + + /** + * Set the metadata object for Mux Data. + */ + set _hlsConfig(val: Readonly> | undefined) { + this.#init(); + + // NOTE: This condition should never be met. If it is, there is a bug (CJP) + if (!this.media) { + logger.error('underlying media element missing when trying to set _hlsConfig. _hlsConfig will not be set.'); + return; + } + this.media._hlsConfig = val; + } + + async addCuePoints(cuePoints: CuePoint[]) { + this.#init(); + + // NOTE: This condition should never be met. If it is, there is a bug (CJP) + if (!this.media) { + logger.error('underlying media element missing when trying to addCuePoints. cuePoints will not be added.'); + return; + } + return this.media?.addCuePoints(cuePoints); + } + + get activeCuePoint() { + return this.media?.activeCuePoint; + } + + get cuePoints() { + return this.media?.cuePoints ?? []; + } + + addChapters(chapters: Chapter[]) { + this.#init(); + + // NOTE: This condition should never be met. If it is, there is a bug (CJP) + if (!this.media) { + logger.error('underlying media element missing when trying to addChapters. chapters will not be added.'); + return; + } + + return this.media?.addChapters(chapters); + } + + get activeChapter() { + return this.media?.activeChapter; + } + + get chapters() { + return this.media?.chapters ?? []; + } + + getStartDate() { + return this.media?.getStartDate(); + } + + get currentPdt() { + return this.media?.currentPdt; + } + + /** + * Get the signing tokens for the Mux asset URL's. + */ + get tokens(): Tokens { + const playback = this.getAttribute(PlayerAttributes.PLAYBACK_TOKEN); + const drm = this.getAttribute(PlayerAttributes.DRM_TOKEN); + const thumbnail = this.getAttribute(PlayerAttributes.THUMBNAIL_TOKEN); + const storyboard = this.getAttribute(PlayerAttributes.STORYBOARD_TOKEN); + return { + ...this.#tokens, + ...(playback != null ? { playback } : {}), + ...(drm != null ? { drm } : {}), + ...(thumbnail != null ? { thumbnail } : {}), + ...(storyboard != null ? { storyboard } : {}), + }; + } + + /** + * Set the signing tokens for the Mux asset URL's. + */ + set tokens(val: Tokens | undefined) { + this.#tokens = val ?? {}; + } + + /** + * Get the playback token for signing the src URL. + */ + get playbackToken() { + return this.getAttribute(PlayerAttributes.PLAYBACK_TOKEN) ?? undefined; + } + + /** + * Set the playback token for signing the src URL. + */ + set playbackToken(val) { + this.setAttribute(PlayerAttributes.PLAYBACK_TOKEN, `${val}`); + } + + /** + * Get the playback token for signing the src URL. + */ + get drmToken() { + return this.getAttribute(PlayerAttributes.DRM_TOKEN) ?? undefined; + } + + /** + * Set the playback token for signing the src URL. + */ + set drmToken(val) { + this.setAttribute(PlayerAttributes.DRM_TOKEN, `${val}`); + } + + /** + * Get the thumbnail token for signing the poster URL. + */ + get thumbnailToken() { + return this.getAttribute(PlayerAttributes.THUMBNAIL_TOKEN) ?? undefined; + } + + /** + * Set the thumbnail token for signing the poster URL. + */ + set thumbnailToken(val) { + this.setAttribute(PlayerAttributes.THUMBNAIL_TOKEN, `${val}`); + } + + /** + * Get the storyboard token for signing the storyboard URL. + */ + get storyboardToken() { + return this.getAttribute(PlayerAttributes.STORYBOARD_TOKEN) ?? undefined; + } + + /** + * Set the storyboard token for signing the storyboard URL. + */ + set storyboardToken(val) { + this.setAttribute(PlayerAttributes.STORYBOARD_TOKEN, `${val}`); + } + + addTextTrack(kind: TextTrackKind, label: string, lang?: string, id?: string) { + const mediaEl = this.media?.nativeEl; + if (!mediaEl) return; + return addTextTrack(mediaEl, kind, label, lang, id); + } + + removeTextTrack(track: TextTrack) { + const mediaEl = this.media?.nativeEl; + if (!mediaEl) return; + return removeTextTrack(mediaEl, track); + } + + get textTracks() { + return this.media?.textTracks; + } + + get castReceiver(): string | undefined { + return this.getAttribute(PlayerAttributes.CAST_RECEIVER) ?? undefined; + } + + set castReceiver(val: string | undefined) { + if (val === this.castReceiver) return; + if (val) { + this.setAttribute(PlayerAttributes.CAST_RECEIVER, val); + } else { + this.removeAttribute(PlayerAttributes.CAST_RECEIVER); + } + } + + get castCustomData() { + return this.media?.castCustomData; + } + + set castCustomData(val) { + // NOTE: This condition should never be met. If it is, there is a bug (CJP) + if (!this.media) { + logger.error( + 'underlying media element missing when trying to set castCustomData. castCustomData will not be set.' + ); + return; + } + this.media.castCustomData = val; + } + + get noTooltips() { + return this.hasAttribute(PlayerAttributes.NO_TOOLTIPS); + } + + set noTooltips(val: boolean) { + if (!val) { + this.removeAttribute(PlayerAttributes.NO_TOOLTIPS); + return; + } + this.setAttribute(PlayerAttributes.NO_TOOLTIPS, ''); + } + + get proudlyDisplayMuxBadge() { + return this.hasAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE); + } + + set proudlyDisplayMuxBadge(val: boolean) { + if (!val) { + this.removeAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE); + } else { + this.setAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE, ''); + } + } +} + +export function getVideoAttribute(el: MuxPlayerElement, name: string) { + return el.media ? el.media.getAttribute(name) : el.getAttribute(name); +} + +export default MuxPlayerElement; diff --git a/packages/mux-player/src/index.ts b/packages/mux-player/src/index.ts index 507ca751f..43d36b62d 100644 --- a/packages/mux-player/src/index.ts +++ b/packages/mux-player/src/index.ts @@ -1,1913 +1,12 @@ -import { globalThis, document } from './polyfills'; -import { MediaController, MediaErrorDialog } from 'media-chrome'; -import { Attributes as MediaControllerAttributes } from 'media-chrome/dist/media-container.js'; -import { MediaStateChangeEvents, MediaUIAttributes, MediaUIEvents } from 'media-chrome/dist/constants.js'; -import 'media-chrome/dist/experimental/index.js'; -import { MediaThemeElement } from 'media-chrome/dist/media-theme-element.js'; -import MuxVideoElement, { MediaError, Attributes as MuxVideoAttributes } from '@mux/mux-video'; -import { - StreamTypes, - PlaybackTypes, - addTextTrack, - removeTextTrack, - CmcdTypes, - CmcdTypeValues, - i18n, - parseJwt, - MuxJWTAud, - generatePlayerInitTime, -} from '@mux/playback-core'; -import type { - ValueOf, - Metadata, - PlaybackEngine, - MaxResolutionValue, - MinResolutionValue, - RenditionOrderValue, - Chapter, - CuePoint, - Tokens, -} from '@mux/playback-core'; -import VideoApiElement from './video-api'; -import { - getPlayerVersion, - toPropName, - AttributeTokenList, - getPosterURLFromPlaybackId, - getStoryboardURLFromPlaybackId, - getStreamTypeFromAttr, -} from './helpers'; -import { template } from './template'; -import { render } from './html'; -import { muxMediaErrorToDialog, muxMediaErrorToDevlog } from './errors'; -import { toNumberOrUndefined, containsComposedNode, camelCase, kebabCase } from './utils'; -import * as logger from './logger'; -import type { MuxTemplateProps, ErrorEvent } from './types'; -import './themes/gerwig'; -import { HlsConfig } from 'hls.js'; -const DefaultThemeName = 'gerwig'; +import { globalThis } from './polyfills'; +// Register web component. +import '@mux/mux-video'; +import MuxPlayerElement from '@mux/mux-player/base'; -export type { Tokens }; - -export { MediaError, generatePlayerInitTime }; - -const VideoAttributes = { - SRC: 'src', - POSTER: 'poster', -}; - -const PlayerAttributes = { - STYLE: 'style', - DEFAULT_HIDDEN_CAPTIONS: 'default-hidden-captions', - PRIMARY_COLOR: 'primary-color', - SECONDARY_COLOR: 'secondary-color', - ACCENT_COLOR: 'accent-color', - FORWARD_SEEK_OFFSET: 'forward-seek-offset', - BACKWARD_SEEK_OFFSET: 'backward-seek-offset', - PLAYBACK_TOKEN: 'playback-token', - THUMBNAIL_TOKEN: 'thumbnail-token', - STORYBOARD_TOKEN: 'storyboard-token', - DRM_TOKEN: 'drm-token', - STORYBOARD_SRC: 'storyboard-src', - THUMBNAIL_TIME: 'thumbnail-time', - AUDIO: 'audio', - NOHOTKEYS: 'nohotkeys', - HOTKEYS: 'hotkeys', - PLAYBACK_RATES: 'playbackrates', - DEFAULT_SHOW_REMAINING_TIME: 'default-show-remaining-time', - DEFAULT_DURATION: 'default-duration', - TITLE: 'title', - VIDEO_TITLE: 'video-title', // video-title is an alternative for title which doesn't cause a tooltip. - PLACEHOLDER: 'placeholder', - THEME: 'theme', - DEFAULT_STREAM_TYPE: 'default-stream-type', - TARGET_LIVE_WINDOW: 'target-live-window', - EXTRA_SOURCE_PARAMS: 'extra-source-params', - NO_VOLUME_PREF: 'no-volume-pref', - CAST_RECEIVER: 'cast-receiver', - NO_TOOLTIPS: 'no-tooltips', - PROUDLY_DISPLAY_MUX_BADGE: 'proudly-display-mux-badge', -}; - -const ThemeAttributeNames = [ - 'audio', - 'backwardseekoffset', - 'defaultduration', - 'defaultshowremainingtime', - 'defaultsubtitles', - 'noautoseektolive', - 'disabled', - 'exportparts', - 'forwardseekoffset', - 'hideduration', - 'hotkeys', - 'nohotkeys', - 'playbackrates', - 'defaultstreamtype', - 'streamtype', - 'style', - 'targetlivewindow', - 'template', - 'title', - 'videotitle', - 'novolumepref', - 'proudlydisplaymuxbadge', -]; - -function getProps(el: MuxPlayerElement, state?: any): MuxTemplateProps { - const props = { - // Give priority to playbackId derrived asset URL's if playbackId is set. - src: !el.playbackId && el.src, - playbackId: el.playbackId, - hasSrc: !!el.playbackId || !!el.src || !!el.currentSrc, - poster: el.poster, - storyboard: el.storyboard, - storyboardSrc: el.getAttribute(PlayerAttributes.STORYBOARD_SRC), - placeholder: el.getAttribute('placeholder'), - themeTemplate: getThemeTemplate(el), - thumbnailTime: !el.tokens.thumbnail && el.thumbnailTime, - autoplay: el.autoplay, - crossOrigin: el.crossOrigin, - loop: el.loop, - // NOTE: Renaming internal prop due to state (sometimes derived from attributeChangedCallback attr values) - // overwriting prop value (type mismatch: string vs. boolean) (CJP) - noHotKeys: el.hasAttribute(PlayerAttributes.NOHOTKEYS), - hotKeys: el.getAttribute(PlayerAttributes.HOTKEYS), - muted: el.muted, - paused: el.paused, - // NOTE: Currently unsupported due to "default true attribute" problem - // playsInline: el.playsInline, - preload: el.preload, - envKey: el.envKey, - preferCmcd: el.preferCmcd, - debug: el.debug, - disableTracking: el.disableTracking, - disableCookies: el.disableCookies, - tokens: el.tokens, - beaconCollectionDomain: el.beaconCollectionDomain, - maxResolution: el.maxResolution, - minResolution: el.minResolution, - programStartTime: el.programStartTime, - programEndTime: el.programEndTime, - assetStartTime: el.assetStartTime, - assetEndTime: el.assetEndTime, - renditionOrder: el.renditionOrder, - metadata: el.metadata, - playerInitTime: el.playerInitTime, - playerSoftwareName: el.playerSoftwareName, - playerSoftwareVersion: el.playerSoftwareVersion, - startTime: el.startTime, - preferPlayback: el.preferPlayback, - audio: el.audio, - defaultStreamType: el.defaultStreamType, - targetLiveWindow: el.getAttribute(MuxVideoAttributes.TARGET_LIVE_WINDOW), - streamType: getStreamTypeFromAttr(el.getAttribute(MuxVideoAttributes.STREAM_TYPE)), - primaryColor: el.getAttribute(PlayerAttributes.PRIMARY_COLOR), - secondaryColor: el.getAttribute(PlayerAttributes.SECONDARY_COLOR), - accentColor: el.getAttribute(PlayerAttributes.ACCENT_COLOR), - forwardSeekOffset: el.forwardSeekOffset, - backwardSeekOffset: el.backwardSeekOffset, - defaultHiddenCaptions: el.defaultHiddenCaptions, - defaultDuration: el.defaultDuration, - defaultShowRemainingTime: el.defaultShowRemainingTime, - hideDuration: getHideDuration(el), - playbackRates: el.getAttribute(PlayerAttributes.PLAYBACK_RATES), - customDomain: el.getAttribute(MuxVideoAttributes.CUSTOM_DOMAIN) ?? undefined, - title: el.getAttribute(PlayerAttributes.TITLE), - videoTitle: el.getAttribute(PlayerAttributes.VIDEO_TITLE) ?? el.getAttribute(PlayerAttributes.TITLE), - novolumepref: el.hasAttribute(PlayerAttributes.NO_VOLUME_PREF), - castReceiver: el.castReceiver, - proudlyDisplayMuxBadge: el.hasAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE), - ...state, - // NOTE: since the attribute value is used as the "source of truth" for the property getter, - // moving this below the `...state` spread so it resolves to the default value when unset (CJP) - extraSourceParams: el.extraSourceParams, - }; - - return props; -} - -const baseFormatErrorMessage = MediaErrorDialog.formatErrorMessage; -MediaErrorDialog.formatErrorMessage = (error: { code: number; message: string }) => { - if (error instanceof MediaError) { - const dialog = muxMediaErrorToDialog(error, false); - return ` - ${dialog?.title ? `

${dialog.title}

` : ''} - ${ - dialog?.message || dialog?.linkUrl - ? `

- ${dialog?.message} - ${ - dialog?.linkUrl - ? `${dialog.linkText ?? dialog.linkUrl}` - : '' - } -

` - : '' - } - `; - } - return baseFormatErrorMessage(error); -}; - -function getThemeTemplate(el: MuxPlayerElement) { - let themeName = el.theme; - - if (themeName) { - const templateElement = (el.getRootNode() as ShadowRoot | Document | null)?.getElementById?.(themeName); - // NOTE: Since folks may unknowingly use matching ids for elements other than their theme - // (intending to use path two for template identification, below), make sure the matching - // element is, in fact, an HTMLTemplateElement (CJP) - if (templateElement && templateElement instanceof HTMLTemplateElement) return templateElement; - - if (!themeName.startsWith('media-theme-')) { - themeName = `media-theme-${themeName}`; - } - - const ThemeElement = globalThis.customElements.get(themeName) as MediaThemeElement | undefined; - if (ThemeElement?.template) return ThemeElement.template; - } -} - -function getHideDuration(el: MuxPlayerElement) { - const timeDisplay = el.mediaController?.querySelector('media-time-display'); - return ( - timeDisplay && - getComputedStyle(timeDisplay as unknown as HTMLElement) - .getPropertyValue('--media-duration-display-display') - .trim() === 'none' - ); -} - -function getMetadataFromAttrs(el: MuxPlayerElement) { - // Adding title defaulting, when present, as a seed value here to ensure it's - // overridden by metadata-video-title if it is also present. (CJP) - const seedValue: { [key: string]: string } = !!el.videoTitle ? { video_title: el.videoTitle } : {}; - return el - .getAttributeNames() - .filter((attrName) => attrName.startsWith('metadata-')) - .reduce((currAttrs, attrName) => { - const value = el.getAttribute(attrName); - if (value !== null) { - currAttrs[attrName.replace(/^metadata-/, '').replace(/-/g, '_')] = value; - } - return currAttrs; - }, seedValue); -} - -const MuxVideoAttributeNames = Object.values(MuxVideoAttributes); -const VideoAttributeNames = Object.values(VideoAttributes); -const PlayerAttributeNames = Object.values(PlayerAttributes); - -export const playerSoftwareVersion = getPlayerVersion(); -export const playerSoftwareName = 'mux-player'; - -const initialState = { - isDialogOpen: false, -}; - -const DEFAULT_EXTRA_PLAYLIST_PARAMS = { redundant_streams: true }; - -export interface MuxPlayerElementEventMap extends HTMLVideoElementEventMap { - cuepointchange: CustomEvent<{ time: number; value: any }>; - cuepointschange: CustomEvent>; - chapterchange: CustomEvent<{ startTime: number; endTime: number; value: string }>; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -interface MuxPlayerElement - extends Omit< - HTMLVideoElement, - | 'poster' - | 'textTracks' - | 'addTextTrack' - | 'src' - | 'videoTracks' - | 'audioTracks' - | 'audioRenditions' - | 'videoRenditions' - > { - addEventListener( - type: K, - listener: (this: HTMLMediaElement, ev: MuxPlayerElementEventMap[K]) => any, - options?: boolean | AddEventListenerOptions - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ): void; - removeEventListener( - type: K, - listener: (this: HTMLMediaElement, ev: MuxPlayerElementEventMap[K]) => any, - options?: boolean | EventListenerOptions - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions - ): void; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -class MuxPlayerElement extends VideoApiElement implements MuxPlayerElement { - #defaultPlayerInitTime: number; - #isInit = false; - #tokens: Tokens = {}; - #userInactive = true; - #hotkeys = new AttributeTokenList(this, 'hotkeys'); - #state: Partial = { - ...initialState, - onCloseErrorDialog: (event) => { - const localName = (event.composedPath()[0] as HTMLElement)?.localName; - if (localName !== 'media-error-dialog') return; - - this.#setState({ isDialogOpen: false }); - }, - onFocusInErrorDialog: (event) => { - const localName = (event.composedPath()[0] as HTMLElement)?.localName; - if (localName !== 'media-error-dialog') return; - - const isFocusedElementInPlayer = containsComposedNode(this, document.activeElement); - if (!isFocusedElementInPlayer) event.preventDefault(); - }, - }; - - static get NAME() { - return playerSoftwareName; - } - - static get VERSION() { - return playerSoftwareVersion; - } - - static get observedAttributes() { - return [ - ...(VideoApiElement.observedAttributes ?? []), - ...VideoAttributeNames, - ...MuxVideoAttributeNames, - ...PlayerAttributeNames, - ]; - } - - constructor() { - super(); - this.#defaultPlayerInitTime = generatePlayerInitTime(); - - this.attachShadow({ mode: 'open' }); - this.#setupCSSProperties(); - - // If the custom element is defined before the HTML is parsed - // no attributes will be available in the constructor (construction process). - // Wait until initializing attributes in the attributeChangedCallback. - // If this element is connected to the DOM, the attributes will be available. - if (this.isConnected) { - this.#init(); - } - } - - #init() { - if (this.#isInit) return; - this.#isInit = true; - - // The next line triggers the first render of the template. - this.#render(); - - // Fixes a bug in React where mux-player's CE children were not upgraded yet. - // These lines ensure the rendered mux-video and media-controller are upgraded, - // even before they are connected to the main document. - try { - customElements.upgrade(this.mediaTheme as Node); - if (!(this.mediaTheme instanceof globalThis.HTMLElement)) throw ''; - } catch (_error) { - logger.error(` failed to upgrade!`); - } - - try { - customElements.upgrade(this.media as Node); - if (!(this.media instanceof MuxVideoElement)) throw ''; - } catch (_error) { - logger.error(' failed to upgrade!'); - } - - try { - customElements.upgrade(this.mediaController as Node); - if (!(this.mediaController instanceof MediaController)) throw ''; - } catch (_error) { - logger.error(` failed to upgrade!`); - } - - this.init(); - - this.#setUpThemeAttributes(); - this.#setUpErrors(); - this.#setUpCaptionsButton(); - this.#userInactive = this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? true; - this.#setUpCaptionsMovement(); - - // NOTE: Make sure we re-render when stream type changes to ensure other props-driven - // template details get updated appropriately (e.g. thumbnails track) (CJP) - this.media?.addEventListener('streamtypechange', () => this.#render()); - - // NOTE: Make sure we re-render when tags are appended so hasSrc is updated. - this.media?.addEventListener('loadstart', () => this.#render()); - } - - #setupCSSProperties() { - // registerProperty will throw if the prop has already been registered - // and there's currently no way to check ahead of time. - // initialValue's are defined in the theme - try { - // @ts-ignore - window?.CSS?.registerProperty({ - name: '--media-primary-color', - syntax: '', - inherits: true, - }); - // @ts-ignore - window?.CSS?.registerProperty({ - name: '--media-secondary-color', - syntax: '', - inherits: true, - }); - } catch (_error) {} - } - - get mediaTheme(): Element | null | undefined { - return this.shadowRoot?.querySelector('media-theme'); - } - - get mediaController(): MediaController | null | undefined { - return this.mediaTheme?.shadowRoot?.querySelector('media-controller'); - } - - connectedCallback() { - const muxVideo = this.shadowRoot?.querySelector('mux-video') as MuxVideoElement; - if (muxVideo) { - muxVideo.metadata = getMetadataFromAttrs(this); - } - } - - #setState(newState: Record) { - Object.assign(this.#state, newState); - this.#render(); - } - - #render(props: Record = {}) { - render(template(getProps(this, { ...this.#state, ...props })), this.shadowRoot as Node); - } - - #setUpThemeAttributes() { - // Forward `theme-` prefixed attributes to the theme. - // e.g. `theme-control-bar-vertical` for the Micro theme. - const setThemeAttribute = (attributeName: string | null) => { - if (!attributeName?.startsWith('theme-')) return; - - const themeAttrName = attributeName.replace(/^theme-/, ''); - if (ThemeAttributeNames.includes(themeAttrName)) return; - - const value = this.getAttribute(attributeName); - if (value != null) { - this.mediaTheme?.setAttribute(themeAttrName, value); - } else { - this.mediaTheme?.removeAttribute(themeAttrName); - } - }; - - const observer = new MutationObserver((mutationList) => { - for (const { attributeName } of mutationList) { - setThemeAttribute(attributeName); - } - }); - - observer.observe(this, { attributes: true }); - this.getAttributeNames().forEach(setThemeAttribute); - } - - #setUpErrors() { - const onError = (event: Event) => { - let { detail: error }: { detail: any } = event as CustomEvent; - - if (!(error instanceof MediaError)) { - error = new MediaError(error.message, error.code, error.fatal); - } - - // Don't show an error dialog if it's not fatal. - if (!error?.fatal) { - logger.warn(error); - if (error.data) { - logger.warn(`${error.name} data:`, error.data); - } - return; - } - - const devlog = muxMediaErrorToDevlog(error, false); - - if (devlog.message) { - logger.devlog(devlog); - } - - logger.error(error); - if (error.data) { - logger.error(`${error.name} data:`, error.data); - } - - this.#setState({ isDialogOpen: true }); - }; - - // Keep this event listener on mux-player instead of calling onError directly - // from video.onerror. This allows us to simulate errors from the outside. - this.addEventListener('error', onError); - - /** @TODO Push errorTranslator logic down to playback-core. Should be able to use MediaError message + context + code (muxCode?) (CJP) */ - if (this.media) { - this.media.errorTranslator = (errorEvent: ErrorEvent = {}) => { - if (!(this.media?.error instanceof MediaError)) return errorEvent; - - const devlog = muxMediaErrorToDevlog(this.media?.error, false); - - return { - player_error_code: this.media?.error.code, - player_error_message: devlog.message ? String(devlog.message) : errorEvent.player_error_message, - player_error_context: devlog.context ? String(devlog.context) : errorEvent.player_error_context, - }; - }; - } - - this.media?.addEventListener('error', (event: Event) => { - let { detail: error }: { detail: any } = event as CustomEvent; - - // If it is a hls.js error event there will be an error object in the event. - // If it is a native video error event there will be no error object. - if (!error) { - const { message, code } = this.media?.error ?? {}; - error = new MediaError(message, code); - } - - // Don't fire a mux-player error event for non-fatal errors. - if (!error?.fatal) return; - - this.dispatchEvent( - new CustomEvent('error', { - detail: error, - }) - ); - }); - } - - #setUpCaptionsButton() { - const onTrackCountChange = () => this.#render(); - this.media?.textTracks?.addEventListener('addtrack', onTrackCountChange); - this.media?.textTracks?.addEventListener('removetrack', onTrackCountChange); - } - - #setUpCaptionsMovement() { - const isFirefox = /Firefox/i.test(navigator.userAgent); - if (!isFirefox) return; - - let selectedTrack: TextTrack; - const cuesmap = new WeakMap(); - - const shouldSkipLineToggle = () => { - // skip line toggle when: - // - streamType is live, unless secondary color is set or player size is too small - // - native fullscreen on iPhones - return this.streamType === StreamTypes.LIVE && !this.secondaryColor && this.offsetWidth >= 800; - }; - - // toggles activeCues for a particular track depending on whether the user is active or not - const toggleLines = (track: TextTrack, userInactive: boolean, force = false) => { - if (shouldSkipLineToggle()) { - return; - } - - const cues = Array.from((track && track.activeCues) || []) as VTTCue[]; - - cues.forEach((cue) => { - // ignore cues that are - // - positioned vertically via percentage. - // - cues that are not at the bottom - // - line is less than -5 - // - line is between 0 and 10 - // @ts-ignore - if (!cue.snapToLines || cue.line < -5 || (cue.line >= 0 && cue.line < 10)) { - return; - } - - // if the user is active or if the player is paused, the captions should be moved up - if (!userInactive || this.paused) { - // for cues that have more than one line, we want to push the cue further up - const lines = cue.text.split('\n').length; - // start at -3 to account for thumbnails as well. - let offset = -3; - - if (this.streamType === StreamTypes.LIVE) { - offset = -2; - } - - const setTo = offset - lines; - - // if the line is already set to -4, we don't want to update it again - // this can happen in the same tick on chrome and safari which fire a cuechange - // event when the line property is changed to a different value. - if (cue.line === setTo && !force) { - return; - } - - if (!cuesmap.has(cue)) { - cuesmap.set(cue, cue.line); - } - - cue.line = setTo; - } else { - setTimeout(() => { - cue.line = cuesmap.get(cue) || 'auto'; - }, 500); - } - }); - }; - - // this is necessary so that if a cue becomes active while the user is active, we still position it above the control bar - const cuechangeHandler = () => { - toggleLines(selectedTrack, this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? false); - }; - - const selectTrack = () => { - const tracks = Array.from(this.mediaController?.media?.textTracks || []) as TextTrack[]; - const newSelectedTrack = tracks.filter( - (t) => ['subtitles', 'captions'].includes(t.kind) && t.mode === 'showing' - )[0] as TextTrack; - - if (newSelectedTrack !== selectedTrack) { - selectedTrack?.removeEventListener('cuechange', cuechangeHandler); - } - - selectedTrack = newSelectedTrack; - selectedTrack?.addEventListener('cuechange', cuechangeHandler); - // it's possible there are currently active cues on the new track - toggleLines(selectedTrack, this.#userInactive); - }; - - selectTrack(); - // update the selected track as necessary - this.textTracks?.addEventListener('change', selectTrack); - this.textTracks?.addEventListener('addtrack', selectTrack); - - this.addEventListener('userinactivechange', () => { - const newUserInactive = this.mediaController?.hasAttribute(MediaControllerAttributes.USER_INACTIVE) ?? true; - - if (this.#userInactive === newUserInactive) { - return; - } - - this.#userInactive = newUserInactive; - - toggleLines(selectedTrack, this.#userInactive); - }); - } - - attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string) { - // Initialize right after construction when the attributes become available. - this.#init(); - - super.attributeChangedCallback(attrName, oldValue, newValue); - - switch (attrName) { - case PlayerAttributes.HOTKEYS: - this.#hotkeys.value = newValue; - break; - case PlayerAttributes.THUMBNAIL_TIME: { - if (newValue != null && this.tokens.thumbnail) { - logger.warn( - i18n( - `Use of thumbnail-time with thumbnail-token is currently unsupported. Ignore thumbnail-time.` - ).toString() - ); - } - break; - } - case PlayerAttributes.THUMBNAIL_TOKEN: { - if (newValue) { - const jwtObj = parseJwt(newValue); - /** @TODO refactor to account for other JWT-based errors (CJP) */ - if (jwtObj) { - const { aud } = jwtObj; - const expectedAud = MuxJWTAud.THUMBNAIL; - const tokenNamePrefix = 'thumbnail'; - if (aud !== expectedAud) { - logger.warn( - i18n( - `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` - ).format({ aud, expectedAud, tokenNamePrefix }) - ); - } - } - } - break; - } - case PlayerAttributes.STORYBOARD_TOKEN: { - if (newValue) { - const jwtObj = parseJwt(newValue); - /** @TODO refactor to account for other JWT-based errors (CJP) */ - if (jwtObj) { - const { aud } = jwtObj; - const expectedAud = MuxJWTAud.STORYBOARD; - const tokenNamePrefix = 'storyboard'; - if (aud !== expectedAud) { - logger.warn( - i18n( - `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` - ).format({ aud, expectedAud, tokenNamePrefix }) - ); - } - } - } - break; - } - case PlayerAttributes.DRM_TOKEN: { - if (newValue) { - const jwtObj = parseJwt(newValue); - /** @TODO refactor to account for other JWT-based errors (CJP) */ - if (jwtObj) { - const { aud } = jwtObj; - const expectedAud = MuxJWTAud.DRM; - const tokenNamePrefix = 'drm'; - if (aud !== expectedAud) { - logger.warn( - i18n( - `The {tokenNamePrefix}-token has an incorrect aud value: {aud}. aud value should be {expectedAud}.` - ).format({ aud, expectedAud, tokenNamePrefix }) - ); - } - } - } - break; - } - case MuxVideoAttributes.PLAYBACK_ID: { - if (newValue?.includes('?token')) { - logger.error( - i18n( - 'The specificed playback ID {playbackId} contains a token which must be provided via the playback-token attribute.' - ).format({ - playbackId: newValue, - }) - ); - } - break; - } - case MuxVideoAttributes.STREAM_TYPE: { - if (newValue && ![StreamTypes.LIVE, StreamTypes.ON_DEMAND, StreamTypes.UNKNOWN].includes(newValue as any)) { - // Handle deprecated values by translating to new properties for the time being. - // NOTE: The value of `streamType` / `stream-type` will be translated at the template - // level. See template.ts for more information (CJP). - if (['ll-live', 'live:dvr', 'll-live:dvr'].includes(this.streamType as any)) { - // NOTE: For now, we won't log any warnings/errors for "deprecated" stream types (CJP). - // logger.devlog({ - // file: 'deprecated-stream-type.md', - // message: i18n( - // `The stream type is deprecated: \`{streamType}\`. Please provide stream-type as either: \`on-demand\`, \`live\`. For DVR, please use \`target-live-window="Infinity"\`` - // ).format({ streamType: this.streamType }), - // }); - this.targetLiveWindow = newValue.includes('dvr') ? Number.POSITIVE_INFINITY : 0; - } else { - logger.devlog({ - file: 'invalid-stream-type.md', - message: i18n( - 'Invalid stream-type value supplied: `{streamType}`. Please provide stream-type as either: `on-demand` or `live`' - ).format({ streamType: this.streamType }), - }); - } - } else { - // NOTE: For now, since we are continuing support of the deprecated stream types (namely, "dvr" types) and not advertising the - // new APIs such as `targetLiveWindow`/`target-live-window`, we will (presumpuously) update the `targetLiveWindow` based on the - // stream type (CJP). - if (newValue === StreamTypes.LIVE) { - // Don't override if the user has already set a value. - if (this.getAttribute(PlayerAttributes.TARGET_LIVE_WINDOW) == null) { - this.targetLiveWindow = 0; - } - } else { - this.targetLiveWindow = Number.NaN; - } - } - } - } - - const shouldClearState = [ - MuxVideoAttributes.PLAYBACK_ID, - VideoAttributes.SRC, - PlayerAttributes.PLAYBACK_TOKEN, - ].includes(attrName); - - if (shouldClearState && oldValue !== newValue) { - this.#state = { ...this.#state, ...initialState }; - } - - this.#render({ [toPropName(attrName)]: newValue }); - } - - async requestFullscreen(_options?: FullscreenOptions) { - if (!this.mediaController || this.mediaController.hasAttribute(MediaUIAttributes.MEDIA_IS_FULLSCREEN)) { - return; - } - this.mediaController?.dispatchEvent( - new globalThis.CustomEvent(MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST, { - composed: true, - bubbles: true, - }) - ); - return new Promise((resolve, _reject) => { - this.mediaController?.addEventListener(MediaStateChangeEvents.MEDIA_IS_FULLSCREEN, () => resolve(), { - once: true, - }); - }); - } - - async exitFullscreen() { - if (!this.mediaController || !this.mediaController.hasAttribute(MediaUIAttributes.MEDIA_IS_FULLSCREEN)) { - return; - } - this.mediaController?.dispatchEvent( - new globalThis.CustomEvent(MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST, { - composed: true, - bubbles: true, - }) - ); - return new Promise((resolve, _reject) => { - this.mediaController?.addEventListener(MediaStateChangeEvents.MEDIA_IS_FULLSCREEN, () => resolve(), { - once: true, - }); - }); - } - - get preferCmcd() { - return (this.getAttribute(MuxVideoAttributes.PREFER_CMCD) as ValueOf) ?? undefined; - } - - set preferCmcd(value: ValueOf | undefined) { - if (value === this.preferCmcd) return; - if (!value) { - this.removeAttribute(MuxVideoAttributes.PREFER_CMCD); - } else if (CmcdTypeValues.includes(value)) { - this.setAttribute(MuxVideoAttributes.PREFER_CMCD, value); - } else { - logger.warn(`Invalid value for preferCmcd. Must be one of ${CmcdTypeValues.join()}`); - } - } - - get hasPlayed() { - return this.mediaController?.hasAttribute(MediaUIAttributes.MEDIA_HAS_PLAYED) ?? false; - } - - get inLiveWindow() { - return this.mediaController?.hasAttribute(MediaUIAttributes.MEDIA_TIME_IS_LIVE); - } - - get _hls(): PlaybackEngine | undefined { - return this.media?._hls; - } - - get mux() { - return this.media?.mux; - } - - /** - * Gets the theme. - */ - get theme() { - return this.getAttribute(PlayerAttributes.THEME) ?? DefaultThemeName; - } - - /** - * Sets the theme. - */ - set theme(val) { - this.setAttribute(PlayerAttributes.THEME, `${val}`); - } - - /** - * Get the theme attributes in a plain object (camelCase keys). - * This doesn't include already defined attributes. e.g. streamType, disabled, etc. - */ - get themeProps() { - const theme = this.mediaTheme; - if (!theme) return; - - const props: Record = {}; - - for (const name of theme.getAttributeNames()) { - if (ThemeAttributeNames.includes(name)) continue; - - const value: string | boolean | null = theme.getAttribute(name); - props[camelCase(name)] = value === '' ? true : value; - } - - return props; - } - - /** - * Set the theme attributes via a plain object. - */ - set themeProps(props) { - this.#init(); - - const themeProps = { ...this.themeProps, ...props }; - - for (const name in themeProps) { - if (ThemeAttributeNames.includes(name)) continue; - - const value: string | boolean | null | undefined = props?.[name]; - - if (typeof value === 'boolean' || value == null) { - this.mediaTheme?.toggleAttribute(kebabCase(name), Boolean(value)); - } else { - this.mediaTheme?.setAttribute(kebabCase(name), value); - } - } - } - - /** - * Get Mux asset playback id. - */ - get playbackId() { - // Don't get the mux-video attribute here because it could have the - // playback token appended to it. - return this.getAttribute(MuxVideoAttributes.PLAYBACK_ID) ?? undefined; - } - - /** - * Set Mux asset playback id. - */ - set playbackId(val) { - if (val) { - this.setAttribute(MuxVideoAttributes.PLAYBACK_ID, val); - } else { - this.removeAttribute(MuxVideoAttributes.PLAYBACK_ID); - } - } - - /** - * Get the string that reflects the src HTML attribute, which contains the URL of a media resource to use. - */ - get src() { - // Only get the internal video.src if a playbackId is present. - if (this.playbackId) { - return getVideoAttribute(this, VideoAttributes.SRC) ?? undefined; - } - return this.getAttribute(VideoAttributes.SRC) ?? undefined; - } - - /** - * Set the string that reflects the src HTML attribute, which contains the URL of a media resource to use. - */ - set src(val) { - if (val) { - this.setAttribute(VideoAttributes.SRC, val); - } else { - this.removeAttribute(VideoAttributes.SRC); - } - } - - /** - * Gets a URL of an image to display, for example, like a movie poster. This can be a still frame from the video, or another image if no video data is available. - */ - get poster() { - const val = this.getAttribute(VideoAttributes.POSTER); - if (val != null) return val; - // If a playback token but no thumbnail token is provided, - // assume a token is required for the thumbnail/poster URL and - // simply avoid requesting it in this case. - const { tokens } = this; - if (tokens.playback && !tokens.thumbnail) { - logger.warn('Missing expected thumbnail token. No poster image will be shown'); - return undefined; - } - - // Get the derived poster if a playbackId is present. - if (this.playbackId && !this.audio) { - return getPosterURLFromPlaybackId(this.playbackId, { - customDomain: this.customDomain, - thumbnailTime: this.thumbnailTime ?? this.startTime, - programTime: this.programStartTime, - token: tokens.thumbnail, - }); - } - - return undefined; - } - - /** - * Sets a URL of an image to display, for example, like a movie poster. This can be a still frame from the video, or another image if no video data is available. - */ - set poster(val) { - if (val || val === '') { - this.setAttribute(VideoAttributes.POSTER, val); - } else { - this.removeAttribute(VideoAttributes.POSTER); - } - } - - /** - * Return the storyboard-src attribute URL - */ - get storyboardSrc() { - return this.getAttribute(PlayerAttributes.STORYBOARD_SRC) ?? undefined; - } - - /** - * Set the storyboard-src attribute URL - */ - set storyboardSrc(src: string | undefined) { - if (!src) { - this.removeAttribute(PlayerAttributes.STORYBOARD_SRC); - } else { - this.setAttribute(PlayerAttributes.STORYBOARD_SRC, src); - } - } - - /** - * Return the storyboard URL when a playback ID or storyboard-src is provided, - * we aren't an audio player and the stream-type isn't live. - */ - get storyboard() { - const { tokens } = this; - // If the storyboardSrc has been explicitly set, assume it should be used - if (this.storyboardSrc && !tokens.storyboard) return this.storyboardSrc; - if ( - // NOTE: Some audio use cases may have a storyboard (e.g. it's an audio+video stream being played *as* audio) - // Consider supporting cases (CJP) - this.audio || - !this.playbackId || - !this.streamType || - [StreamTypes.LIVE, StreamTypes.UNKNOWN].includes(this.streamType as any) || - // If a playback token but no storyboard token is provided, - // assume a token is required for the storyboard URL URL and - // simply avoid requesting it in this case. - (tokens.playback && !tokens.storyboard) - ) { - return undefined; - } - return getStoryboardURLFromPlaybackId(this.playbackId, { - customDomain: this.customDomain, - token: tokens.storyboard, - programStartTime: this.programStartTime, - programEndTime: this.programEndTime, - }); - } - - /** - * Gets the boolean indicator this is an audio player. - */ - get audio() { - return this.hasAttribute(PlayerAttributes.AUDIO); - } - - /** - * Sets the boolean indicator this is an audio player. - */ - set audio(val: boolean) { - if (!val) { - this.removeAttribute(PlayerAttributes.AUDIO); - return; - } - this.setAttribute(PlayerAttributes.AUDIO, ''); - } - - get hotkeys() { - return this.#hotkeys; - } - - get nohotkeys() { - return this.hasAttribute(PlayerAttributes.NOHOTKEYS); - } - - set nohotkeys(val: boolean) { - if (!val) { - this.removeAttribute(PlayerAttributes.NOHOTKEYS); - return; - } - this.setAttribute(PlayerAttributes.NOHOTKEYS, ''); - } - - /** - * Get the thumbnailTime offset used for the poster image. - */ - get thumbnailTime() { - return toNumberOrUndefined(this.getAttribute(PlayerAttributes.THUMBNAIL_TIME)); - } - - /** - * Set the thumbnailTime offset used for the poster image. - */ - set thumbnailTime(val: number | undefined) { - this.setAttribute(PlayerAttributes.THUMBNAIL_TIME, `${val}`); - } - - /** - * Get the video title shown in the player. - */ - get videoTitle() { - return this.getAttribute(PlayerAttributes.VIDEO_TITLE) ?? this.getAttribute(PlayerAttributes.TITLE) ?? ''; - } - - /** - * Set the video title shown in the player. - */ - set videoTitle(val: string) { - if (val === this.videoTitle) return; - - if (!!val) { - this.setAttribute(PlayerAttributes.VIDEO_TITLE, val); - } else { - this.removeAttribute(PlayerAttributes.VIDEO_TITLE); - } - } - - /** - * Gets the data URL of a placeholder image shown before the thumbnail is loaded. - */ - get placeholder() { - return getVideoAttribute(this, PlayerAttributes.PLACEHOLDER) ?? ''; - } - - /** - * Sets the data URL of a placeholder image shown before the thumbnail is loaded. - */ - set placeholder(val) { - this.setAttribute(PlayerAttributes.PLACEHOLDER, `${val}`); - } - - /** - * Get the primary color used by the player. - */ - get primaryColor() { - let color = this.getAttribute(PlayerAttributes.PRIMARY_COLOR); - if (color != null) return color; - - // Fallback to computed style if no attribute is set, causes layout. - // https://gist.github.com/paulirish/5d52fb081b3570c81e3a - if (this.mediaTheme) { - color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_primary-color')?.trim(); - if (color) return color; - } - } - - /** - * Set the primary color used by the player. - */ - set primaryColor(val: string | undefined) { - this.setAttribute(PlayerAttributes.PRIMARY_COLOR, `${val}`); - } - - /** - * Get the secondary color used by the player. - */ - get secondaryColor() { - let color = this.getAttribute(PlayerAttributes.SECONDARY_COLOR); - if (color != null) return color; - - // Fallback to computed style if no attribute is set, causes layout. - // https://gist.github.com/paulirish/5d52fb081b3570c81e3a - if (this.mediaTheme) { - color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_secondary-color')?.trim(); - if (color) return color; - } - } - - /** - * Set the secondary color used by the player. - */ - set secondaryColor(val: string | undefined) { - this.setAttribute(PlayerAttributes.SECONDARY_COLOR, `${val}`); - } - - /** - * Get the accent color used by the player. - */ - get accentColor() { - let color = this.getAttribute(PlayerAttributes.ACCENT_COLOR); - if (color != null) return color; - - // Fallback to computed style if no attribute is set, causes layout. - // https://gist.github.com/paulirish/5d52fb081b3570c81e3a - if (this.mediaTheme) { - color = globalThis.getComputedStyle(this.mediaTheme)?.getPropertyValue('--_accent-color')?.trim(); - if (color) return color; - } - } - - /** - * Set the accent color used by the player. - */ - set accentColor(val: string | undefined) { - this.setAttribute(PlayerAttributes.ACCENT_COLOR, `${val}`); - } - - get defaultShowRemainingTime() { - return this.hasAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME); - } - - set defaultShowRemainingTime(val: boolean | undefined) { - if (!val) { - this.removeAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME); - } else { - this.setAttribute(PlayerAttributes.DEFAULT_SHOW_REMAINING_TIME, ''); - } - } - - /** - * Get the playback rates applied to the playback rate control. - */ - get playbackRates() { - if (!this.hasAttribute(PlayerAttributes.PLAYBACK_RATES)) return undefined; - // /NOTE: This is duplicating the code from Media Chrome's media-playback-rate-button (CJP) - return (this.getAttribute(PlayerAttributes.PLAYBACK_RATES) as string) - .trim() - .split(/\s*,?\s+/) - .map((str) => Number(str)) - .filter((num) => !Number.isNaN(num)) - .sort((a, b) => a - b); - } - - /** - * Set the playback rates applied to the playback rate control. - */ - set playbackRates(val: number[] | undefined) { - if (!val) { - this.removeAttribute(PlayerAttributes.PLAYBACK_RATES); - return; - } - this.setAttribute(PlayerAttributes.PLAYBACK_RATES, val.join(' ')); - } - - /** - * Get the offset applied to the forward seek button. - */ - get forwardSeekOffset() { - return toNumberOrUndefined(this.getAttribute(PlayerAttributes.FORWARD_SEEK_OFFSET)) ?? 10; - } - - /** - * Set the offset applied to the forward seek button. - */ - set forwardSeekOffset(val: number | undefined) { - this.setAttribute(PlayerAttributes.FORWARD_SEEK_OFFSET, `${val}`); - } - - /** - * Get the offset applied to the backward seek button. - */ - get backwardSeekOffset() { - return toNumberOrUndefined(this.getAttribute(PlayerAttributes.BACKWARD_SEEK_OFFSET)) ?? 10; - } - - /** - * Set the offset applied to the forward seek button. - */ - set backwardSeekOffset(val: number | undefined) { - this.setAttribute(PlayerAttributes.BACKWARD_SEEK_OFFSET, `${val}`); - } - - /** - * Get the boolean value of default hidden captions. - * By default returns false so captions are enabled on initial load. - */ - get defaultHiddenCaptions() { - return this.hasAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS); - } - - /** - * Set the default hidden captions flag. - */ - set defaultHiddenCaptions(val: boolean | undefined) { - if (!val) { - this.removeAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS); - } else { - this.setAttribute(PlayerAttributes.DEFAULT_HIDDEN_CAPTIONS, ''); - } - } - - /** - * Get the boolean value of default hidden captions. - * By default returns false so captions are enabled on initial load. - */ - get defaultDuration() { - return toNumberOrUndefined(this.getAttribute(PlayerAttributes.DEFAULT_DURATION)); - } - - /** - * Set the default hidden captions flag. - */ - set defaultDuration(val: number | undefined) { - if (val == undefined) { - this.removeAttribute(PlayerAttributes.DEFAULT_DURATION); - } else { - this.setAttribute(PlayerAttributes.DEFAULT_DURATION, `${val}`); - } - } - - get playerInitTime() { - if (!this.hasAttribute(MuxVideoAttributes.PLAYER_INIT_TIME)) return this.#defaultPlayerInitTime; - return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PLAYER_INIT_TIME)); - } - - set playerInitTime(val) { - // don't cause an infinite loop and avoid change event dispatching - if (val == this.playerInitTime) return; - - if (val == null) { - this.removeAttribute(MuxVideoAttributes.PLAYER_INIT_TIME); - } else { - this.setAttribute(MuxVideoAttributes.PLAYER_INIT_TIME, `${+val}`); - } - } - - /** - * Get the player software name. Used by Mux Data. - */ - get playerSoftwareName() { - return this.getAttribute(MuxVideoAttributes.PLAYER_SOFTWARE_NAME) ?? playerSoftwareName; - } - - /** - * Get the player software version. Used by Mux Data. - */ - get playerSoftwareVersion() { - return this.getAttribute(MuxVideoAttributes.PLAYER_SOFTWARE_VERSION) ?? playerSoftwareVersion; - } - - /** - * Get the beacon collection domain. Used by Mux Data. - */ - get beaconCollectionDomain() { - return this.getAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN) ?? undefined; - } - - /** - * Set the beacon collection domain. Used by Mux Data. - */ - set beaconCollectionDomain(val: string | undefined) { - // don't cause an infinite loop - if (val === this.beaconCollectionDomain) return; - - if (val) { - this.setAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN, val); - } else { - this.removeAttribute(MuxVideoAttributes.BEACON_COLLECTION_DOMAIN); - } - } - - get maxResolution() { - return (this.getAttribute(MuxVideoAttributes.MAX_RESOLUTION) as MaxResolutionValue) ?? undefined; - } - - set maxResolution(val: MaxResolutionValue | undefined) { - if (val === this.maxResolution) return; - - if (val) { - this.setAttribute(MuxVideoAttributes.MAX_RESOLUTION, val); - } else { - this.removeAttribute(MuxVideoAttributes.MAX_RESOLUTION); - } - } - - get minResolution() { - return (this.getAttribute(MuxVideoAttributes.MIN_RESOLUTION) as MinResolutionValue) ?? undefined; - } - - set minResolution(val: MinResolutionValue | undefined) { - if (val === this.minResolution) return; - - if (val) { - this.setAttribute(MuxVideoAttributes.MIN_RESOLUTION, val); - } else { - this.removeAttribute(MuxVideoAttributes.MIN_RESOLUTION); - } - } - - get renditionOrder() { - return (this.getAttribute(MuxVideoAttributes.RENDITION_ORDER) as RenditionOrderValue) ?? undefined; - } - - set renditionOrder(val: RenditionOrderValue | undefined) { - if (val === this.renditionOrder) return; - - if (val) { - this.setAttribute(MuxVideoAttributes.RENDITION_ORDER, val); - } else { - this.removeAttribute(MuxVideoAttributes.RENDITION_ORDER); - } - } - - get programStartTime() { - return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PROGRAM_START_TIME)); - } - - set programStartTime(val: number | undefined) { - if (val == undefined) { - this.removeAttribute(MuxVideoAttributes.PROGRAM_START_TIME); - } else { - this.setAttribute(MuxVideoAttributes.PROGRAM_START_TIME, `${val}`); - } - } - - get programEndTime() { - return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.PROGRAM_END_TIME)); - } - - set programEndTime(val: number | undefined) { - if (val == undefined) { - this.removeAttribute(MuxVideoAttributes.PROGRAM_END_TIME); - } else { - this.setAttribute(MuxVideoAttributes.PROGRAM_END_TIME, `${val}`); - } - } - - get assetStartTime() { - return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.ASSET_START_TIME)); - } - - set assetStartTime(val: number | undefined) { - if (val == undefined) { - this.removeAttribute(MuxVideoAttributes.ASSET_START_TIME); - } else { - this.setAttribute(MuxVideoAttributes.ASSET_START_TIME, `${val}`); - } - } - - get assetEndTime() { - return toNumberOrUndefined(this.getAttribute(MuxVideoAttributes.ASSET_END_TIME)); - } - - set assetEndTime(val: number | undefined) { - if (val == undefined) { - this.removeAttribute(MuxVideoAttributes.ASSET_END_TIME); - } else { - this.setAttribute(MuxVideoAttributes.ASSET_END_TIME, `${val}`); - } - } - - get extraSourceParams() { - if (!this.hasAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS)) { - return DEFAULT_EXTRA_PLAYLIST_PARAMS; - } - - return [...new URLSearchParams(this.getAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS) as string).entries()].reduce( - (paramsObj, [k, v]) => { - paramsObj[k] = v; - return paramsObj; - }, - {} as Record - ); - } - - set extraSourceParams(value: Record) { - if (value == null) { - this.removeAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS); - } else { - this.setAttribute(PlayerAttributes.EXTRA_SOURCE_PARAMS, new URLSearchParams(value).toString()); - } - } - - /** - * Get Mux asset custom domain. - */ - get customDomain() { - return this.getAttribute(MuxVideoAttributes.CUSTOM_DOMAIN) ?? undefined; - } - - /** - * Set Mux asset custom domain. - */ - set customDomain(val: string | undefined) { - // dont' cause an infinite loop - if (val === this.customDomain) return; - - if (val) { - this.setAttribute(MuxVideoAttributes.CUSTOM_DOMAIN, val); - } else { - this.removeAttribute(MuxVideoAttributes.CUSTOM_DOMAIN); - } - } - - /** - * Get Mux Data env key. - */ - get envKey() { - return getVideoAttribute(this, MuxVideoAttributes.ENV_KEY) ?? undefined; - } - - /** - * Set Mux Data env key. - */ - set envKey(val: string | undefined) { - this.setAttribute(MuxVideoAttributes.ENV_KEY, `${val}`); - } - - /** - * Get no-volume-pref flag. - */ - get noVolumePref() { - return this.hasAttribute(PlayerAttributes.NO_VOLUME_PREF); - } - - /** - * Set video engine debug flag. - */ - set noVolumePref(val) { - if (val) { - this.setAttribute(PlayerAttributes.NO_VOLUME_PREF, ''); - } else { - this.removeAttribute(PlayerAttributes.NO_VOLUME_PREF); - } - } - - /** - * Get video engine debug flag. - */ - get debug() { - return getVideoAttribute(this, MuxVideoAttributes.DEBUG) != null; - } - - /** - * Set video engine debug flag. - */ - set debug(val) { - if (val) { - this.setAttribute(MuxVideoAttributes.DEBUG, ''); - } else { - this.removeAttribute(MuxVideoAttributes.DEBUG); - } - } - - /** - * Get video engine disable tracking flag. - */ - get disableTracking() { - return getVideoAttribute(this, MuxVideoAttributes.DISABLE_TRACKING) != null; - } - - /** - * Set video engine disable tracking flag. - */ - set disableTracking(val) { - this.toggleAttribute(MuxVideoAttributes.DISABLE_TRACKING, !!val); - } - - /** - * Get video engine disable cookies flag. - */ - get disableCookies() { - return getVideoAttribute(this, MuxVideoAttributes.DISABLE_COOKIES) != null; - } - - /** - * Set video engine disable cookies flag. - */ - set disableCookies(val) { - if (val) { - this.setAttribute(MuxVideoAttributes.DISABLE_COOKIES, ''); - } else { - this.removeAttribute(MuxVideoAttributes.DISABLE_COOKIES); - } - } - - /** - * Get stream type. - */ - get streamType() { - return this.getAttribute(MuxVideoAttributes.STREAM_TYPE) ?? this.media?.streamType ?? StreamTypes.UNKNOWN; - } - - /** - * Set stream type. - */ - set streamType(val) { - this.setAttribute(MuxVideoAttributes.STREAM_TYPE, `${val}`); - } - - get defaultStreamType() { - return ( - (this.getAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE) as ValueOf) ?? - (this.mediaController?.getAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE) as ValueOf) ?? - StreamTypes.ON_DEMAND - ); - } - - set defaultStreamType(val: ValueOf | undefined) { - if (val) { - this.setAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE, val); - } else { - this.removeAttribute(PlayerAttributes.DEFAULT_STREAM_TYPE); - } - } - - get targetLiveWindow() { - // Allow overriding inferred `targetLiveWindow` - if (this.hasAttribute(PlayerAttributes.TARGET_LIVE_WINDOW)) { - return +(this.getAttribute(PlayerAttributes.TARGET_LIVE_WINDOW) as string) as number; - } - return this.media?.targetLiveWindow ?? Number.NaN; - } - - set targetLiveWindow(val: number | undefined) { - // don't cause an infinite loop and avoid change event dispatching - if (val == this.targetLiveWindow || (Number.isNaN(val) && Number.isNaN(this.targetLiveWindow))) return; - - if (val == null) { - this.removeAttribute(PlayerAttributes.TARGET_LIVE_WINDOW); - } else { - this.setAttribute(PlayerAttributes.TARGET_LIVE_WINDOW, `${+val}`); - } - } - - get liveEdgeStart() { - return this.media?.liveEdgeStart; - } - - /** - * Get the start time. - */ - get startTime() { - return toNumberOrUndefined(getVideoAttribute(this, MuxVideoAttributes.START_TIME)); - } - - /** - * Set the start time. - */ - set startTime(val) { - this.setAttribute(MuxVideoAttributes.START_TIME, `${val}`); - } - - get preferPlayback(): ValueOf | undefined { - const val = this.getAttribute(MuxVideoAttributes.PREFER_PLAYBACK); - if (val === PlaybackTypes.MSE || val === PlaybackTypes.NATIVE) return val; - return undefined; - } - - set preferPlayback(val: ValueOf | undefined) { - if (val === this.preferPlayback) return; - - if (val === PlaybackTypes.MSE || val === PlaybackTypes.NATIVE) { - this.setAttribute(MuxVideoAttributes.PREFER_PLAYBACK, val); - } else { - this.removeAttribute(MuxVideoAttributes.PREFER_PLAYBACK); - } - } - - /** - * Get the metadata object for Mux Data. - */ - get metadata(): Readonly | undefined { - return this.media?.metadata; - } - - /** - * Set the metadata object for Mux Data. - */ - set metadata(val: Readonly | undefined) { - this.#init(); - - // NOTE: This condition should never be met. If it is, there is a bug (CJP) - if (!this.media) { - logger.error('underlying media element missing when trying to set metadata. metadata will not be set.'); - return; - } - this.media.metadata = { ...getMetadataFromAttrs(this), ...val }; - } - - /** - * Get the metadata object for Mux Data. - */ - get _hlsConfig() { - return this.media?._hlsConfig; - } - - /** - * Set the metadata object for Mux Data. - */ - set _hlsConfig(val: Readonly> | undefined) { - this.#init(); - - // NOTE: This condition should never be met. If it is, there is a bug (CJP) - if (!this.media) { - logger.error('underlying media element missing when trying to set _hlsConfig. _hlsConfig will not be set.'); - return; - } - this.media._hlsConfig = val; - } - - async addCuePoints(cuePoints: CuePoint[]) { - this.#init(); - - // NOTE: This condition should never be met. If it is, there is a bug (CJP) - if (!this.media) { - logger.error('underlying media element missing when trying to addCuePoints. cuePoints will not be added.'); - return; - } - return this.media?.addCuePoints(cuePoints); - } - - get activeCuePoint() { - return this.media?.activeCuePoint; - } - - get cuePoints() { - return this.media?.cuePoints ?? []; - } - - addChapters(chapters: Chapter[]) { - this.#init(); - - // NOTE: This condition should never be met. If it is, there is a bug (CJP) - if (!this.media) { - logger.error('underlying media element missing when trying to addChapters. chapters will not be added.'); - return; - } - - return this.media?.addChapters(chapters); - } - - get activeChapter() { - return this.media?.activeChapter; - } - - get chapters() { - return this.media?.chapters ?? []; - } - - getStartDate() { - return this.media?.getStartDate(); - } - - get currentPdt() { - return this.media?.currentPdt; - } - - /** - * Get the signing tokens for the Mux asset URL's. - */ - get tokens(): Tokens { - const playback = this.getAttribute(PlayerAttributes.PLAYBACK_TOKEN); - const drm = this.getAttribute(PlayerAttributes.DRM_TOKEN); - const thumbnail = this.getAttribute(PlayerAttributes.THUMBNAIL_TOKEN); - const storyboard = this.getAttribute(PlayerAttributes.STORYBOARD_TOKEN); - return { - ...this.#tokens, - ...(playback != null ? { playback } : {}), - ...(drm != null ? { drm } : {}), - ...(thumbnail != null ? { thumbnail } : {}), - ...(storyboard != null ? { storyboard } : {}), - }; - } - - /** - * Set the signing tokens for the Mux asset URL's. - */ - set tokens(val: Tokens | undefined) { - this.#tokens = val ?? {}; - } - - /** - * Get the playback token for signing the src URL. - */ - get playbackToken() { - return this.getAttribute(PlayerAttributes.PLAYBACK_TOKEN) ?? undefined; - } - - /** - * Set the playback token for signing the src URL. - */ - set playbackToken(val) { - this.setAttribute(PlayerAttributes.PLAYBACK_TOKEN, `${val}`); - } - - /** - * Get the playback token for signing the src URL. - */ - get drmToken() { - return this.getAttribute(PlayerAttributes.DRM_TOKEN) ?? undefined; - } - - /** - * Set the playback token for signing the src URL. - */ - set drmToken(val) { - this.setAttribute(PlayerAttributes.DRM_TOKEN, `${val}`); - } - - /** - * Get the thumbnail token for signing the poster URL. - */ - get thumbnailToken() { - return this.getAttribute(PlayerAttributes.THUMBNAIL_TOKEN) ?? undefined; - } - - /** - * Set the thumbnail token for signing the poster URL. - */ - set thumbnailToken(val) { - this.setAttribute(PlayerAttributes.THUMBNAIL_TOKEN, `${val}`); - } - - /** - * Get the storyboard token for signing the storyboard URL. - */ - get storyboardToken() { - return this.getAttribute(PlayerAttributes.STORYBOARD_TOKEN) ?? undefined; - } - - /** - * Set the storyboard token for signing the storyboard URL. - */ - set storyboardToken(val) { - this.setAttribute(PlayerAttributes.STORYBOARD_TOKEN, `${val}`); - } - - addTextTrack(kind: TextTrackKind, label: string, lang?: string, id?: string) { - const mediaEl = this.media?.nativeEl; - if (!mediaEl) return; - return addTextTrack(mediaEl, kind, label, lang, id); - } - - removeTextTrack(track: TextTrack) { - const mediaEl = this.media?.nativeEl; - if (!mediaEl) return; - return removeTextTrack(mediaEl, track); - } - - get textTracks() { - return this.media?.textTracks; - } - - get castReceiver(): string | undefined { - return this.getAttribute(PlayerAttributes.CAST_RECEIVER) ?? undefined; - } - - set castReceiver(val: string | undefined) { - if (val === this.castReceiver) return; - if (val) { - this.setAttribute(PlayerAttributes.CAST_RECEIVER, val); - } else { - this.removeAttribute(PlayerAttributes.CAST_RECEIVER); - } - } - - get castCustomData() { - return this.media?.castCustomData; - } - - set castCustomData(val) { - // NOTE: This condition should never be met. If it is, there is a bug (CJP) - if (!this.media) { - logger.error( - 'underlying media element missing when trying to set castCustomData. castCustomData will not be set.' - ); - return; - } - this.media.castCustomData = val; - } - - get noTooltips() { - return this.hasAttribute(PlayerAttributes.NO_TOOLTIPS); - } - - set noTooltips(val: boolean) { - if (!val) { - this.removeAttribute(PlayerAttributes.NO_TOOLTIPS); - return; - } - this.setAttribute(PlayerAttributes.NO_TOOLTIPS, ''); - } - - get proudlyDisplayMuxBadge() { - return this.hasAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE); - } - - set proudlyDisplayMuxBadge(val: boolean) { - if (!val) { - this.removeAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE); - } else { - this.setAttribute(PlayerAttributes.PROUDLY_DISPLAY_MUX_BADGE, ''); - } - } -} - -export function getVideoAttribute(el: MuxPlayerElement, name: string) { - return el.media ? el.media.getAttribute(name) : el.getAttribute(name); -} - -/** @TODO Refactor once using `globalThis` polyfills */ if (!globalThis.customElements.get('mux-player')) { globalThis.customElements.define('mux-player', MuxPlayerElement); - /** @TODO consider externalizing this (breaks standard modularity) */ (globalThis as any).MuxPlayerElement = MuxPlayerElement; } +export * from '@mux/mux-player/base'; export default MuxPlayerElement; diff --git a/packages/mux-player/src/themes/news/index.ts b/packages/mux-player/src/themes/news/index.ts new file mode 100644 index 000000000..dfaf12fd8 --- /dev/null +++ b/packages/mux-player/src/themes/news/index.ts @@ -0,0 +1,18 @@ +// @ts-ignore +import theme from './news.html'; +import { document, globalThis } from '../../polyfills'; +import { MediaThemeElement } from 'media-chrome/dist/media-theme-element.js'; +import 'media-chrome/dist/menu'; + +const template = document.createElement('template'); +if ('innerHTML' in template) template.innerHTML = theme; + +class MediaThemeNews extends MediaThemeElement { + static template = template.content?.children?.[0] as HTMLTemplateElement; +} + +if (!globalThis.customElements.get('media-theme-news')) { + globalThis.customElements.define('media-theme-news', MediaThemeNews); +} + +export default MediaThemeNews; diff --git a/packages/mux-player/src/themes/news/news.html b/packages/mux-player/src/themes/news/news.html new file mode 100644 index 000000000..9bb026c6c --- /dev/null +++ b/packages/mux-player/src/themes/news/news.html @@ -0,0 +1,565 @@ + diff --git a/packages/mux-player/src/types.d.ts b/packages/mux-player/src/types.ts similarity index 63% rename from packages/mux-player/src/types.d.ts rename to packages/mux-player/src/types.ts index 1f56f2ae8..140a9a1a2 100644 --- a/packages/mux-player/src/types.d.ts +++ b/packages/mux-player/src/types.ts @@ -1,5 +1,6 @@ import type MuxVideoElement from '@mux/mux-video'; import type { MediaError } from '@mux/mux-video'; +import type { EventMap as MuxVideoEventMap } from '@mux/mux-video'; import type { MaxResolutionValue, MinResolutionValue, @@ -9,6 +10,7 @@ import type { } from '@mux/playback-core'; import type { AttributeTokenList } from './helpers'; +export type Props = MuxPlayerProps; export type MuxPlayerProps = Partial & { nohotkeys?: boolean; hotkeys?: AttributeTokenList; @@ -57,6 +59,10 @@ export type MuxTemplateProps = Partial & { defaultStreamType?: ValueOf; castReceiver: string | undefined; proudlyDisplayMuxBadge?: boolean; + adTagUrl: string | undefined; + adBreak: boolean; + /** Allow playback with ad blocker */ + allowAdBlocker?: boolean; }; export type DialogOptions = { @@ -78,3 +84,31 @@ export type ErrorEvent = { player_error_message?: string; player_error_context?: string; }; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; + +export type EventMap = MuxPlayerElementEventMap; +export type MuxPlayerElementEventMap = Expand; + +export interface IMuxPlayerElement { + addEventListener( + type: K, + listener: (this: HTMLMediaElement, ev: MuxPlayerElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener( + type: K, + listener: (this: HTMLMediaElement, ev: MuxPlayerElementEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; +} diff --git a/packages/mux-player/src/video-api.ts b/packages/mux-player/src/video-api.ts index 60f840e89..3ffde6b48 100644 --- a/packages/mux-player/src/video-api.ts +++ b/packages/mux-player/src/video-api.ts @@ -1,8 +1,7 @@ import { globalThis } from './polyfills'; -import { VideoEvents } from '@mux/mux-video'; -import type MuxVideoElement from '@mux/mux-video'; import * as logger from './logger'; import { toNumberOrUndefined } from './utils'; +import type MuxVideoElement from '@mux/mux-video/ads'; export type CastOptions = { receiverApplicationId: string; @@ -12,10 +11,6 @@ export type CastOptions = { resumeSavedSession: boolean; }; -export type MuxVideoElementExt = MuxVideoElement & { - requestCast(options: CastOptions): Promise; -}; - const AllowedVideoAttributes = { AUTOPLAY: 'autoplay', CROSSORIGIN: 'crossorigin', @@ -23,7 +18,7 @@ const AllowedVideoAttributes = { MUTED: 'muted', PLAYSINLINE: 'playsinline', PRELOAD: 'preload', -}; +} as const; const CustomVideoAttributes = { VOLUME: 'volume', @@ -33,6 +28,11 @@ const CustomVideoAttributes = { MUTED: 'muted', }; +export const Attributes = { + ...AllowedVideoAttributes, + ...CustomVideoAttributes, +} as const; + const emptyTimeRanges: TimeRanges = Object.freeze({ length: 0, start(index: number) { @@ -55,12 +55,13 @@ const emptyTimeRanges: TimeRanges = Object.freeze({ }, }); -const AllowedVideoEvents = VideoEvents.filter((type) => type !== 'error'); const AllowedVideoAttributeNames = Object.values(AllowedVideoAttributes).filter( - (name) => ![AllowedVideoAttributes.PLAYSINLINE].includes(name) + (name) => AllowedVideoAttributes.PLAYSINLINE !== name ); const CustomVideoAttributesNames = Object.values(CustomVideoAttributes); +export const AttributeNames = [...AllowedVideoAttributeNames, ...CustomVideoAttributesNames]; + // NOTE: Some of these are defined in MuxPlayerElement. We may want to apply a // `Pick<>` on these to also enforce consistency (CJP). type PartialHTMLVideoElement = Omit< @@ -117,6 +118,11 @@ interface VideoApiElement extends PartialHTMLVideoElement, HTMLElement { listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void; + addEventListener( + type: string, + listener: (event: CustomEvent) => void, + options?: boolean | AddEventListenerOptions + ): void; removeEventListener( type: K, listener: (this: HTMLVideoElement, ev: HTMLVideoElementEventMap[K]) => any, @@ -127,12 +133,17 @@ interface VideoApiElement extends PartialHTMLVideoElement, HTMLElement { listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions ): void; + removeEventListener( + type: string, + listener: (event: CustomEvent) => void, + options?: boolean | EventListenerOptions + ): void; } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class VideoApiElement extends globalThis.HTMLElement implements VideoApiElement { static get observedAttributes() { - return [...AllowedVideoAttributeNames, ...CustomVideoAttributesNames]; + return AttributeNames as string[]; } /** @@ -144,20 +155,6 @@ class VideoApiElement extends globalThis.HTMLElement implements VideoApiElement super(); } - /** - * Gets called from mux-player when mux-video is rendered and upgraded. - * We might just merge VideoApiElement in MuxPlayerElement and remove this? - */ - init() { - // The video events are dispatched on the VideoApiElement instance. - // This makes it possible to add event listeners before the element is upgraded. - AllowedVideoEvents.forEach((type) => { - this.media?.addEventListener(type, (evt) => { - this.dispatchEvent(new Event(evt.type)); - }); - }); - } - attributeChangedCallback(attrName: string, _oldValue: string | null, newValue: string) { switch (attrName) { case CustomVideoAttributes.MUTED: { @@ -197,11 +194,7 @@ class VideoApiElement extends globalThis.HTMLElement implements VideoApiElement this.media?.load(); } - requestCast(options: CastOptions) { - return this.media?.requestCast(options); - } - - get media(): MuxVideoElementExt | null | undefined { + get media(): MuxVideoElement | undefined | null { return this.shadowRoot?.querySelector('mux-video'); } diff --git a/packages/mux-video/README.md b/packages/mux-video/README.md index 4d2a3434f..14e97dc16 100644 --- a/packages/mux-video/README.md +++ b/packages/mux-video/README.md @@ -78,7 +78,7 @@ Now you are free to use this web component in your HTML, just as you would with ``` -Attributes: +### Attributes - `playback-id`: This is the playback ID for your Mux Asset or Mux Live Stream. The playback-id is the variable you may have used before to construct a `.m3u8` hls url like this:`https://stream.mux.com/{PLAYBACK_ID}.m3u8`. [Mux Docs](https://docs.mux.com/guides/video/play-your-videos#1-get-your-playback-id) - `env-key`: This is the environment key for Mux Data. Note that this is different than your API Key. Get your env key from the "Mux Data" part of the Mux Dashboard. If undefined and you are playing a Mux Video asset, the environment will be inferred. @@ -100,6 +100,16 @@ Attributes: All the other attributes that you would use on a `