diff --git a/.gitignore b/.gitignore index 436b4e301..9eded4334 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ packages/vidstack/player/styles/default/theme.css packages/react/src/icons.ts packages/react/index.d.ts packages/react/icons.d.ts +packages/react/dom.d.ts +packages/react/google-cast.d.ts packages/react/player/ packages/react/tailwind* diff --git a/package.json b/package.json index 4b4152d8d..c8d1b6663 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "clean": "turbo run clean", "format": "turbo run format --parallel", "test": "turbo run test --parallel", - "preinstall": "npx only-allow pnpm && pnpx simple-git-hooks", + "preinstall": "npx only-allow pnpm", "release": "pnpm build && node .scripts/release.js --next", "release:dry": "pnpm run release --dry", "validate": "turbo run build format test" diff --git a/packages/react/package.json b/packages/react/package.json index bc543f864..8822a14f2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -91,6 +91,8 @@ }, "./player/styles/*": "./player/styles/*", "./dist/types/*": "./dist/types/*", + "./dom.d.ts": "./dom.d.ts", + "./google-cast.d.ts": "./google-cast.d.ts", "./package.json": "./package.json", "./tailwind.cjs": { "types": "./tailwind.d.cts", diff --git a/packages/react/rollup.config.js b/packages/react/rollup.config.js index c87f49a2b..d1d370aa0 100644 --- a/packages/react/rollup.config.js +++ b/packages/react/rollup.config.js @@ -32,7 +32,7 @@ const EXTERNAL_PACKAGES = [ /^remotion/, ], NPM_BUNDLES = [define({ dev: true }), define({ dev: false })], - TYPES_BUNDLES = [defineTypes()]; + TYPES_BUNDLES = defineTypes(); // Styles if (!MODE_TYPES) { @@ -53,38 +53,60 @@ export default defineConfig( ); /** - * @returns {import('rollup').RollupOptions} + * @returns {import('rollup').RollupOptions[]} * */ function defineTypes() { - return { - input: { - index: 'types/react/src/index.d.ts', - icons: 'types/react/src/icons.d.ts', - 'player/remotion': 'types/react/src/providers/remotion/index.d.ts', - 'player/layouts/default': 'types/react/src/components/layouts/default/index.d.ts', - }, - output: { - dir: '.', - chunkFileNames: 'dist/types/[name].d.ts', - manualChunks(id) { - if (id.includes('react/src')) return 'vidstack-react'; - if (id.includes('maverick')) return 'vidstack-framework'; - if (id.includes('vidstack')) return 'vidstack'; + return [ + { + input: { + index: 'types/react/src/index.d.ts', + icons: 'types/react/src/icons.d.ts', + 'player/remotion': 'types/react/src/providers/remotion/index.d.ts', + 'player/layouts/default': 'types/react/src/components/layouts/default/index.d.ts', }, - }, - external: EXTERNAL_PACKAGES, - plugins: [ - { - name: 'resolve-vidstack-types', - resolveId(id) { - if (id === 'vidstack') { - return 'types/vidstack/src/index.d.ts'; - } + output: { + dir: '.', + chunkFileNames: 'dist/types/[name].d.ts', + manualChunks(id) { + if (id.includes('react/src')) return 'vidstack-react'; + if (id.includes('maverick')) return 'vidstack-framework'; + if (id.includes('vidstack')) return 'vidstack'; }, }, - dts({ respectExternal: true }), - ], - }; + external: EXTERNAL_PACKAGES, + plugins: [ + { + name: 'resolve-vidstack-types', + resolveId(id) { + if (id === 'vidstack') { + return 'types/vidstack/src/index.d.ts'; + } + }, + }, + dts({ + respectExternal: true, + }), + { + name: 'globals', + generateBundle(_, bundle) { + const indexFile = Object.values(bundle).find((file) => file.fileName === 'index.d.ts'), + globalFiles = ['dom.d.ts', 'google-cast.d.ts'], + references = globalFiles + .map((path) => `/// `) + .join('\n'); + + for (const file of globalFiles) { + fs.copyFileSync(path.resolve(`../vidstack/${file}`), file); + } + + if (indexFile?.type === 'chunk') { + indexFile.code = references + `\n\n${indexFile.code}`; + } + }, + }, + ], + }, + ]; } /** diff --git a/packages/react/src/components/layouts/default/context.ts b/packages/react/src/components/layouts/default/context.ts index aa01bdbdc..2a8e0efc4 100644 --- a/packages/react/src/components/layouts/default/context.ts +++ b/packages/react/src/components/layouts/default/context.ts @@ -7,21 +7,21 @@ import type { DefaultLayoutIcons } from './icons'; export const DefaultLayoutContext = React.createContext({} as any); interface DefaultLayoutContext { - thumbnails: ThumbnailSrc; - menuContainer?: React.RefObject; - translations?: DefaultLayoutTranslations | null; - isSmallLayout: boolean; - showMenuDelay?: number; - showTooltipDelay: number; + disableTimeSlider: boolean; hideQualityBitrate: boolean; - menuGroup: 'top' | 'bottom'; - noModal: boolean; Icons: DefaultLayoutIcons; - slots?: unknown; - sliderChaptersMinWidth: number; - disableTimeSlider: boolean; + isSmallLayout: boolean; + menuContainer?: React.RefObject; + menuGroup: 'top' | 'bottom'; noGestures: boolean; noKeyboardActionDisplay: boolean; + noModal: boolean; + showMenuDelay?: number; + showTooltipDelay: number; + sliderChaptersMinWidth: number; + slots?: unknown; + thumbnails: ThumbnailSrc; + translations?: DefaultLayoutTranslations | null; } export function useDefaultLayoutLang(word: keyof DefaultLayoutTranslations) { diff --git a/packages/react/src/components/layouts/default/icons.tsx b/packages/react/src/components/layouts/default/icons.tsx index be672e792..fe191973e 100644 --- a/packages/react/src/components/layouts/default/icons.tsx +++ b/packages/react/src/components/layouts/default/icons.tsx @@ -4,6 +4,7 @@ import airPlayPaths from 'media-icons/dist/icons/airplay.js'; import arrowLeftPaths from 'media-icons/dist/icons/arrow-left.js'; import chaptersIconPaths from 'media-icons/dist/icons/chapters.js'; import arrowRightPaths from 'media-icons/dist/icons/chevron-right.js'; +import googleCastPaths from 'media-icons/dist/icons/chromecast.js'; import ccOnIconPaths from 'media-icons/dist/icons/closed-captions-on.js'; import ccIconPaths from 'media-icons/dist/icons/closed-captions.js'; import exitFullscreenIconPaths from 'media-icons/dist/icons/fullscreen-exit.js'; @@ -38,6 +39,9 @@ export const defaultLayoutIcons: DefaultLayoutIcons = { AirPlayButton: { Default: createIcon(airPlayPaths), }, + GoogleCastButton: { + Default: createIcon(googleCastPaths), + }, PlayButton: { Play: createIcon(playIconPaths), Pause: createIcon(pauseIconPaths), @@ -102,6 +106,11 @@ export interface DefaultLayoutIcons { Connecting?: DefaultLayoutIcon; Connected?: DefaultLayoutIcon; }; + GoogleCastButton: { + Default: DefaultLayoutIcon; + Connecting?: DefaultLayoutIcon; + Connected?: DefaultLayoutIcon; + }; PlayButton: { Play: DefaultLayoutIcon; Pause: DefaultLayoutIcon; diff --git a/packages/react/src/components/layouts/default/shared-layout.tsx b/packages/react/src/components/layouts/default/shared-layout.tsx index 1449c99b1..1d936184a 100644 --- a/packages/react/src/components/layouts/default/shared-layout.tsx +++ b/packages/react/src/components/layouts/default/shared-layout.tsx @@ -23,6 +23,7 @@ import type { PrimitivePropsWithRef } from '../../primitives/nodes'; import { AirPlayButton } from '../../ui/buttons/airplay-button'; import { CaptionButton } from '../../ui/buttons/caption-button'; import { FullscreenButton } from '../../ui/buttons/fullscreen-button'; +import { GoogleCastButton } from '../../ui/buttons/google-cast-button'; import { LiveButton } from '../../ui/buttons/live-button'; import { MuteButton } from '../../ui/buttons/mute-button'; import { PIPButton } from '../../ui/buttons/pip-button'; @@ -296,6 +297,34 @@ function DefaultAirPlayButton({ tooltip }: DefaultMediaButtonProps) { DefaultAirPlayButton.displayName = 'DefaultAirPlayButton'; export { DefaultAirPlayButton }; +/* ------------------------------------------------------------------------------------------------- + * DefaultGoogleCastButton + * -----------------------------------------------------------------------------------------------*/ + +function DefaultGoogleCastButton({ tooltip }: DefaultMediaButtonProps) { + const { Icons } = React.useContext(DefaultLayoutContext), + googleCastText = useDefaultLayoutLang('Google Cast'), + state = useMediaState('remotePlaybackState'), + stateText = useDefaultLayoutLang(uppercaseFirstChar(state) as Capitalize), + label = `${googleCastText} ${stateText}`, + Icon = + (state === 'connecting' + ? Icons.GoogleCastButton.Connecting + : state === 'connected' + ? Icons.GoogleCastButton.Connected + : null) ?? Icons.GoogleCastButton.Default; + return ( + + + {React.createElement(Icon, { className: 'vds-icon' })} + + + ); +} + +DefaultGoogleCastButton.displayName = 'DefaultGoogleCastButton'; +export { DefaultGoogleCastButton }; + /* ------------------------------------------------------------------------------------------------- * DefaultPlayButton * -----------------------------------------------------------------------------------------------*/ diff --git a/packages/react/src/components/layouts/default/slots.tsx b/packages/react/src/components/layouts/default/slots.tsx index 481587cd0..936a44515 100644 --- a/packages/react/src/components/layouts/default/slots.tsx +++ b/packages/react/src/components/layouts/default/slots.tsx @@ -26,6 +26,7 @@ export type DefaultLayoutSlotName = | 'muteButton' | 'pipButton' | 'airPlayButton' + | 'googleCastButton' | 'playButton' | 'loadButton' | 'seekBackwardButton' diff --git a/packages/react/src/components/layouts/default/video-layout.tsx b/packages/react/src/components/layouts/default/video-layout.tsx index 07720ab83..1c647e1be 100644 --- a/packages/react/src/components/layouts/default/video-layout.tsx +++ b/packages/react/src/components/layouts/default/video-layout.tsx @@ -15,6 +15,7 @@ import { DefaultChaptersMenu, DefaultChapterTitle, DefaultFullscreenButton, + DefaultGoogleCastButton, DefaultMuteButton, DefaultPIPButton, DefaultPlayButton, @@ -104,6 +105,7 @@ function DefaultVideoLargeLayout() { {slot(slots, 'captionButton', )} {menuGroup === 'bottom' && } {slot(slots, 'airPlayButton', )} + {slot(slots, 'googleCastButton', )} {slot(slots, 'pipButton', )} {slot(slots, 'fullscreenButton', )} @@ -129,6 +131,7 @@ function DefaultVideoSmallLayout() { {slot(slots, 'airPlayButton', )} + {slot(slots, 'googleCastButton', )}
{slot(slots, 'captionButton', )} diff --git a/packages/react/src/components/primitives/instances.ts b/packages/react/src/components/primitives/instances.ts index d8c32fad2..782f51f4d 100644 --- a/packages/react/src/components/primitives/instances.ts +++ b/packages/react/src/components/primitives/instances.ts @@ -6,6 +6,7 @@ import { ControlsGroup, FullscreenButton, Gesture, + GoogleCastButton, LiveButton, MediaPlayer, MediaProvider, @@ -52,6 +53,7 @@ export class MuteButtonInstance extends MuteButton {} export class PIPButtonInstance extends PIPButton {} export class PlayButtonInstance extends PlayButton {} export class AirPlayButtonInstance extends AirPlayButton {} +export class GoogleCastButtonInstance extends GoogleCastButton {} export class SeekButtonInstance extends SeekButton {} // Tooltip export class TooltipInstance extends Tooltip {} diff --git a/packages/react/src/components/provider.tsx b/packages/react/src/components/provider.tsx index 2a2f38705..af52e401c 100644 --- a/packages/react/src/components/provider.tsx +++ b/packages/react/src/components/provider.tsx @@ -65,12 +65,8 @@ interface MediaOutletProps extends React.HTMLAttributes { provider: MediaProviderInstance; } -const YOUTUBE_TYPE = { src: '', type: 'video/youtube' }, - VIMEO_TYPE = { src: '', type: 'video/vimeo' }, - REMOTION_TYPE = { src: '', type: 'video/remotion' }; - function MediaOutlet({ provider, ...props }: MediaOutletProps) { - const { controls, crossorigin, poster } = useStateContext(mediaState), + const { controls, crossOrigin, poster } = useStateContext(mediaState), { loader } = provider.$state, { $iosControls: $$iosControls, @@ -80,17 +76,23 @@ function MediaOutlet({ provider, ...props }: MediaOutletProps) { $controls = useSignal(controls), $iosControls = useSignal($$iosControls), $nativeControls = $controls || $iosControls, - $crossorigin = useSignal(crossorigin), + $crossOrigin = useSignal(crossOrigin), $poster = useSignal(poster), $loader = useSignal(loader), $provider = useSignal($$provider), $providerSetup = useSignal($$providerSetup), $mediaType = $loader?.mediaType(), - isYouTubeEmbed = $loader?.canPlay(YOUTUBE_TYPE), - isVimeoEmbed = $loader?.canPlay(VIMEO_TYPE), - isEmbed = isYouTubeEmbed || isVimeoEmbed; + isYouTubeEmbed = $loader?.name === 'youtube', + isVimeoEmbed = $loader?.name === 'vimeo', + isEmbed = isYouTubeEmbed || isVimeoEmbed, + isRemotion = $loader?.name === 'remotion', + isGoogleCast = $loader?.name === 'google-cast'; + + if (isGoogleCast) { + return
; + } - if ($loader?.canPlay(REMOTION_TYPE)) { + if (isRemotion) { return (
{ + asChild?: boolean; + children?: React.ReactNode; + ref?: React.Ref; +} + +/** + * A button for requesting Google Cast. + * + * @see {@link https://developers.google.com/cast/docs/overview} + * @docs {@link https://www.vidstack.io/docs/player/components/buttons/google-cast-button} + * @example + * ```tsx + * + * + * + * ``` + */ +const GoogleCastButton = React.forwardRef( + ({ children, ...props }, forwardRef) => { + return ( + )}> + {(props) => ( + + {children} + + )} + + ); + }, +); + +GoogleCastButton.displayName = 'GoogleCastButton'; +export { GoogleCastButton }; diff --git a/packages/react/src/globals.d.ts b/packages/react/src/globals.d.ts index 19d91a1ac..b773fc1db 100644 --- a/packages/react/src/globals.d.ts +++ b/packages/react/src/globals.d.ts @@ -1,6 +1,3 @@ -declare global { - const __DEV__: boolean; - const __SERVER__: boolean; -} +/// export {}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index abb030859..3ccb268ba 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -26,6 +26,10 @@ export type { // Buttons export { type ToggleButtonProps, ToggleButton } from './components/ui/buttons/toggle-button'; export { type AirPlayButtonProps, AirPlayButton } from './components/ui/buttons/airplay-button'; +export { + type GoogleCastButtonProps, + GoogleCastButton, +} from './components/ui/buttons/google-cast-button'; export { type PlayButtonProps, PlayButton } from './components/ui/buttons/play-button'; export { type CaptionButtonProps, CaptionButton } from './components/ui/buttons/caption-button'; export { diff --git a/packages/react/src/providers/remotion/loader.ts b/packages/react/src/providers/remotion/loader.ts index 5d031d7b7..c9c9a24a8 100644 --- a/packages/react/src/providers/remotion/loader.ts +++ b/packages/react/src/providers/remotion/loader.ts @@ -1,10 +1,18 @@ import * as React from 'react'; -import type { MediaProviderAdapter, MediaProviderLoader, MediaSrc, MediaType } from 'vidstack'; +import type { + MediaContext, + MediaProviderAdapter, + MediaProviderLoader, + MediaSrc, + MediaType, +} from 'vidstack'; import * as UI from '../../components/layouts/remotion-ui'; export class RemotionProviderLoader implements MediaProviderLoader { + readonly name = 'remotion'; + target!: HTMLElement; constructor() { @@ -20,7 +28,7 @@ export class RemotionProviderLoader implements MediaProviderLoader { return 'video'; } - async load(): Promise { - return new (await import('./provider')).RemotionProvider(this.target); + async load(ctx: MediaContext): Promise { + return new (await import('./provider')).RemotionProvider(this.target, ctx); } } diff --git a/packages/react/src/providers/remotion/provider.tsx b/packages/react/src/providers/remotion/provider.tsx index b3a9f5d33..75cc23c63 100644 --- a/packages/react/src/providers/remotion/provider.tsx +++ b/packages/react/src/providers/remotion/provider.tsx @@ -11,12 +11,7 @@ import { type SetTimelineContextValue, type TimelineContextValue, } from 'remotion'; -import { - TimeRange, - type MediaProviderAdapter, - type MediaSetupContext, - type MediaSrc, -} from 'vidstack'; +import { TimeRange, type MediaContext, type MediaProviderAdapter, type MediaSrc } from 'vidstack'; import { RemotionLayoutEngine } from './layout-engine'; import { RemotionPlaybackEngine } from './playback-engine'; @@ -31,7 +26,6 @@ export class RemotionProvider implements MediaProviderAdapter { readonly scope = createScope(); - protected _ctx!: MediaSetupContext; protected _src = signal(null); protected _setup = false; protected _played = 0; @@ -74,12 +68,14 @@ export class RemotionProvider implements MediaProviderAdapter { return this._frame(); } - constructor(readonly container: HTMLElement) { + constructor( + readonly container: HTMLElement, + protected readonly _ctx: MediaContext, + ) { this._layoutEngine.setContainer(container); } - setup(ctx: MediaSetupContext) { - this._ctx = ctx; + setup() { effect(this._watchWaiting.bind(this)); effect(this._watchMediaTags.bind(this)); effect(this._watchMediaElements.bind(this)); @@ -229,10 +225,10 @@ export class RemotionProvider implements MediaProviderAdapter { this._notify('rate-change', rate); } - protected _getPlayedRange(currentTime: number) { - return this._played >= currentTime + protected _getPlayedRange(time: number) { + return this._played >= time ? this._playedRange - : (this._playedRange = new TimeRange(0, (this._played = currentTime))); + : (this._playedRange = new TimeRange(0, (this._played = time))); } async loadSource(src: MediaSrc) { @@ -252,7 +248,7 @@ export class RemotionProvider implements MediaProviderAdapter { onError: (error) => { if (__DEV__) { this._ctx.logger - ?.errorGroup(error.message) + ?.errorGroup(`[vidstack] ${error.message}`) .labelledLog('Source', peek(this._src)) .labelledLog('Error', error) .dispatch(); @@ -315,8 +311,8 @@ export class RemotionProvider implements MediaProviderAdapter { if (!$src) { throw Error( __DEV__ - ? '[vidstack]: attempting to render remotion provider without src' - : '[vidstack]: no src', + ? '[vidstack] attempting to render remotion provider without src' + : '[vidstack] no src', ); } diff --git a/packages/react/src/providers/remotion/validate.ts b/packages/react/src/providers/remotion/validate.ts index 97f9061db..77f7a1b28 100644 --- a/packages/react/src/providers/remotion/validate.ts +++ b/packages/react/src/providers/remotion/validate.ts @@ -47,7 +47,7 @@ export function validateInitialFrame(initialFrame: number | undefined, frames: n if (!isNumber(frames)) { throw new Error( - `[vidstack]: \`durationInFrames\` must be a number, but is ${JSON.stringify(frames)}`, + `[vidstack] \`durationInFrames\` must be a number, but is ${JSON.stringify(frames)}`, ); } @@ -57,27 +57,27 @@ export function validateInitialFrame(initialFrame: number | undefined, frames: n if (!isNumber(initialFrame)) { throw new Error( - `[vidstack]: \`initialFrame\` must be a number, but is ${JSON.stringify(initialFrame)}`, + `[vidstack] \`initialFrame\` must be a number, but is ${JSON.stringify(initialFrame)}`, ); } if (Number.isNaN(initialFrame)) { - throw new Error(`[vidstack]: \`initialFrame\` must be a number, but is NaN`); + throw new Error(`[vidstack] \`initialFrame\` must be a number, but is NaN`); } if (!Number.isFinite(initialFrame)) { - throw new Error(`[vidstack]: \`initialFrame\` must be a number, but is Infinity`); + throw new Error(`[vidstack] \`initialFrame\` must be a number, but is Infinity`); } if (initialFrame % 1 !== 0) { throw new Error( - `[vidstack]: \`initialFrame\` must be an integer, but is ${JSON.stringify(initialFrame)}`, + `[vidstack] \`initialFrame\` must be an integer, but is ${JSON.stringify(initialFrame)}`, ); } if (initialFrame > frames - 1) { throw new Error( - `[vidstack]: \`initialFrame\` must be less or equal than \`durationInFrames - 1\`, but is ${JSON.stringify( + `[vidstack] \`initialFrame\` must be less or equal than \`durationInFrames - 1\`, but is ${JSON.stringify( initialFrame, )}`, ); @@ -93,25 +93,25 @@ export function validateSingleFrame(frame: unknown, variableName: string): numbe if (!isNumber(frame)) { throw new TypeError( - `[vidstack]: \`${variableName}\` must be a number, but is ${JSON.stringify(frame)}`, + `[vidstack] \`${variableName}\` must be a number, but is ${JSON.stringify(frame)}`, ); } if (Number.isNaN(frame)) { throw new TypeError( - `[vidstack]: \`${variableName}\` must not be NaN, but is ${JSON.stringify(frame)}`, + `[vidstack] \`${variableName}\` must not be NaN, but is ${JSON.stringify(frame)}`, ); } if (!Number.isFinite(frame)) { throw new TypeError( - `[vidstack]: \`${variableName}\` must be finite, but is ${JSON.stringify(frame)}`, + `[vidstack] \`${variableName}\` must be finite, but is ${JSON.stringify(frame)}`, ); } if (frame % 1 !== 0) { throw new TypeError( - `[vidstack]: \`${variableName}\` must be an integer, but is ${JSON.stringify(frame)}`, + `[vidstack] \`${variableName}\` must be an integer, but is ${JSON.stringify(frame)}`, ); } @@ -135,26 +135,26 @@ export function validateInOutFrames( // Must not be over the duration if (!isNull(validatedInFrame) && validatedInFrame > frames - 1) { throw new Error( - `[vidstack]: \`inFrame\` must be less than (durationInFrames - 1), but is \`${validatedInFrame}\``, + `[vidstack] \`inFrame\` must be less than (durationInFrames - 1), but is \`${validatedInFrame}\``, ); } if (!isNull(validatedOutFrame) && validatedOutFrame > frames) { throw new Error( - `[vidstack]: \`outFrame\` must be less than (durationInFrames), but is \`${validatedOutFrame}\``, + `[vidstack] \`outFrame\` must be less than (durationInFrames), but is \`${validatedOutFrame}\``, ); } // Must not be under 0 if (!isNull(validatedInFrame) && validatedInFrame < 0) { throw new Error( - `[vidstack]: \`inFrame\` must be greater than 0, but is \`${validatedInFrame}\``, + `[vidstack] \`inFrame\` must be greater than 0, but is \`${validatedInFrame}\``, ); } if (!isNull(validatedOutFrame) && validatedOutFrame <= 0) { throw new Error( - `[vidstack]: \`outFrame\` must be greater than 0, but is \`${validatedOutFrame}\`. If you want to render a single frame, use \`\` instead.`, + `[vidstack] \`outFrame\` must be greater than 0, but is \`${validatedOutFrame}\`. If you want to render a single frame, use \`\` instead.`, ); } @@ -164,7 +164,7 @@ export function validateInOutFrames( validatedOutFrame <= validatedInFrame ) { throw new Error( - '[vidstack]: `outFrame` must be greater than `inFrame`, but is ' + + '[vidstack] `outFrame` must be greater than `inFrame`, but is ' + validatedOutFrame + ' <= ' + validatedInFrame, @@ -177,7 +177,7 @@ export function validateSharedNumberOfAudioTags(tags: number | undefined) { if (tags % 1 !== 0 || !Number.isFinite(tags) || Number.isNaN(tags) || tags < 0) { throw new TypeError( - `[vidstack]: \`numberOfSharedAudioTags\` must be an integer but got \`${tags}\` instead`, + `[vidstack] \`numberOfSharedAudioTags\` must be an integer but got \`${tags}\` instead`, ); } } @@ -187,18 +187,18 @@ export function validatePlaybackRate(playbackRate: number) { if (playbackRate > 4) { throw new Error( - `[vidstack]: The highest possible playback rate with Remotion is 4. You passed: ${playbackRate}`, + `[vidstack] The highest possible playback rate with Remotion is 4. You passed: ${playbackRate}`, ); } if (playbackRate < -4) { throw new Error( - `[vidstack]: The lowest possible playback rate with Remotion is -4. You passed: ${playbackRate}`, + `[vidstack] The lowest possible playback rate with Remotion is -4. You passed: ${playbackRate}`, ); } if (playbackRate === 0) { - throw new Error(`[vidstack]: A playback rate of 0 is not supported.`); + throw new Error(`[vidstack] A playback rate of 0 is not supported.`); } } @@ -208,13 +208,13 @@ export function validateComponent(src: RemotionMediaResource['src']) { // @ts-expect-error if (src.type === Composition) { throw new TypeError( - `[vidstack]: \`src\` should not be an instance of \`\`. Pass the React component directly, and set the duration, fps and dimensions as source props.`, + `[vidstack] \`src\` should not be an instance of \`\`. Pass the React component directly, and set the duration, fps and dimensions as source props.`, ); } if (src === Composition) { throw new TypeError( - `[vidstack]: \`src\` must not be the \`Composition\` component. Pass your own React component directly, and set the duration, fps and dimensions as source props.`, + `[vidstack] \`src\` must not be the \`Composition\` component. Pass your own React component directly, and set the duration, fps and dimensions as source props.`, ); } } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index fa092f4e3..0218f8da2 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -6,7 +6,7 @@ "vidstack": ["../vidstack/src"], "@vidstack/react": ["./src/index.ts"] }, - "types": ["@types/node", "../vidstack/dom.d.ts"] + "types": ["@types/node"] }, "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/vidstack/.templates/sandbox/main.ts b/packages/vidstack/.templates/sandbox/main.ts index b6904e032..2563606d8 100644 --- a/packages/vidstack/.templates/sandbox/main.ts +++ b/packages/vidstack/.templates/sandbox/main.ts @@ -8,6 +8,12 @@ import { isHLSProvider, type TextTrackInit } from '../src'; const player = document.querySelector('media-player')!; +Object.defineProperty(window, 'player', { + get() { + return player; + }, +}); + player.addEventListener('provider-change', (event) => { const provider = event.detail; // We can configure provider's here. diff --git a/packages/vidstack/google-cast.d.ts b/packages/vidstack/google-cast.d.ts new file mode 100644 index 000000000..9b2d03876 --- /dev/null +++ b/packages/vidstack/google-cast.d.ts @@ -0,0 +1,1422 @@ +interface Window { + cast?: typeof cast; + __onGCastApiAvailable?(available: boolean, reason?: string): void; +} + +declare namespace chrome.cast { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.AutoJoinPolicy + */ + export enum AutoJoinPolicy { + CUSTOM_CONTROLLER_SCOPED = 'custom_controller_scoped', + TAB_AND_ORIGIN_SCOPED = 'tab_and_origin_scoped', + ORIGIN_SCOPED = 'origin_scoped', + PAGE_SCOPED = 'page_scoped', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.DefaultActionPolicy + */ + export enum DefaultActionPolicy { + CREATE_SESSION = 'create_session', + CAST_THIS_TAB = 'cast_this_tab', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.Capability + */ + export enum Capability { + VIDEO_OUT = 'video_out', + AUDIO_OUT = 'audio_out', + VIDEO_IN = 'video_in', + AUDIO_IN = 'audio_in', + MULTIZONE_GROUP = 'multizone_group', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.ErrorCode + */ + export enum ErrorCode { + CANCEL = 'cancel', + TIMEOUT = 'timeout', + API_NOT_INITIALIZED = 'api_not_initialized', + INVALID_PARAMETER = 'invalid_parameter', + EXTENSION_NOT_COMPATIBLE = 'extension_not_compatible', + EXTENSION_MISSING = 'extension_missing', + RECEIVER_UNAVAILABLE = 'receiver_unavailable', + SESSION_ERROR = 'session_error', + CHANNEL_ERROR = 'channel_error', + LOAD_MEDIA_FAILED = 'load_media_failed', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.ReceiverAvailability + */ + export enum ReceiverAvailability { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.SenderPlatform + */ + export enum SenderPlatform { + CHROME = 'chrome', + IOS = 'ios', + ANDROID = 'android', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.ReceiverType + */ + export enum ReceiverType { + CAST = 'cast', + DIAL = 'dial', + HANGOUT = 'hangout', + CUSTOM = 'custom', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.ReceiverAction + */ + export enum ReceiverAction { + CAST = 'cast', + STOP = 'stop', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.SessionStatus + */ + export enum SessionStatus { + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + STOPPED = 'stopped', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.VERSION + */ + export var VERSION: number[]; + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.isAvailable + */ + export var isAvailable: boolean; + + /** + * @param apiConfig + * @param successCallback + * @param errorCallback + */ + export function initialize( + apiConfig: chrome.cast.ApiConfig, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param successCallback + * @param errorCallback + * @param opt_sessionRequest + * @param opt_label + */ + export function requestSession( + successCallback: (session: chrome.cast.Session) => void, + errorCallback: (error: chrome.cast.Error) => void, + sessionRequest?: chrome.cast.SessionRequest, + label?: string, + ): void; + + /** + * @param sessionId The id of the session to join. + */ + export function requestSessionById(sessionId: string): void; + + /** + * @param listener + */ + export function addReceiverActionListener( + listener: (receiver: chrome.cast.Receiver, receiverAction: chrome.cast.ReceiverAction) => void, + ): void; + + /** + * @param listener + */ + export function removeReceiverActionListener( + listener: (receiver: chrome.cast.Receiver, receiverAction: chrome.cast.ReceiverAction) => void, + ): void; + + /** + * @param message The message to log. + */ + export function logMessage(message: string): void; + + /** + * @param receivers + * @param successCallback + * @param errorCallback + */ + export function setCustomReceivers( + receivers: chrome.cast.Receiver[], + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param receiver + * @param successCallback + * @param errorCallback + */ + export function setReceiverDisplayStatus( + receiver: chrome.cast.Receiver, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param escaped A string to unescape. + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.unescape + */ + export function unescape(escaped: string): string; + + export class ApiConfig { + /** + * @param sessionRequest + * @param sessionListener + * @param receiverListener + * @param opt_autoJoinPolicy + * @param opt_defaultActionPolicy + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.ApiConfig + */ + constructor( + sessionRequest: chrome.cast.SessionRequest, + sessionListener: (session: chrome.cast.Session) => void, + receiverListener: (receiverAvailability: chrome.cast.ReceiverAvailability) => void, + autoJoinPolicy?: chrome.cast.AutoJoinPolicy, + defaultActionPolicy?: chrome.cast.DefaultActionPolicy, + ); + + sessionRequest: chrome.cast.SessionRequest; + sessionListener: (session: chrome.cast.Session) => void; + receiverListener: (receiverAvailability: chrome.cast.ReceiverAvailability) => void; + autoJoinPolicy: chrome.cast.AutoJoinPolicy; + defaultActionPolicy: chrome.cast.DefaultActionPolicy; + } + + export class Error { + /** + * @param code + * @param opt_description + * @param opt_details + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Error + */ + constructor(code: chrome.cast.ErrorCode, description?: string, details?: Object); + + code: chrome.cast.ErrorCode; + description: string | null; + details: object; + } + + export class Image { + /** + * @param url + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Image + */ + constructor(url: string); + + url: string; + height: number | null; + width: number | null; + } + + export class SenderApplication { + /** + * @param platform + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.SenderApplication + */ + constructor(platform: chrome.cast.SenderPlatform); + + platform: chrome.cast.SenderPlatform; + url: string | null; + packageId: string | null; + } + + export class SessionRequest { + /** + * @param appId + * @param opt_capabilities + * @param opt_timeout + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.SessionRequest + */ + constructor(appId: string, capabilities?: chrome.cast.Capability[], timeout?: number); + + appId: string; + capabilities: chrome.cast.Capability[]; + requestSessionTimeout: number; + language: string | null; + } + + export class Session { + /** + * @param sessionId + * @param appId + * @param displayName + * @param appImages + * @param receiver + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Session + */ + constructor( + sessionId: string, + appId: string, + displayName: string, + appImages: chrome.cast.Image[], + receiver: chrome.cast.Receiver, + ); + + sessionId: string; + appId: string; + displayName: string; + appImages: chrome.cast.Image[]; + receiver: chrome.cast.Receiver; + senderApps: chrome.cast.SenderApplication[]; + namespaces: Array<{ name: string }>; + media: chrome.cast.media.Media[]; + status: chrome.cast.SessionStatus; + statusText: string | null; + transportId: string; + + /** + * @param newLevel + * @param successCallback + * @param errorCallback + */ + setReceiverVolumeLevel( + newLevel: number, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param muted + * @param successCallback + * @param errorCallback + */ + setReceiverMuted( + muted: boolean, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param successCallback + * @param errorCallback + */ + leave(successCallback: Function, errorCallback: (error: chrome.cast.Error) => void): void; + + /** + * @param successCallback + * @param errorCallback + */ + stop(successCallback: Function, errorCallback: (error: chrome.cast.Error) => void): void; + + /** + * @param namespace + * @param message + * @param successCallback + * @param errorCallback + */ + sendMessage( + namespace: string, + message: string | object, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param listener + */ + addUpdateListener(listener: (isAlive: boolean) => void): void; + + /** + * @param listener + */ + removeUpdateListener(listener: (isAlive: boolean) => void): void; + + /** + * @param namespace + * @param listener + */ + addMessageListener( + namespace: string, + listener: (namespace: string, message: string) => void, + ): void; + + /** + * @param namespace + * @param listener + */ + removeMessageListener( + namespace: string, + listener: (namespace: string, message: string) => void, + ): void; + + /** + * @param listener + */ + addMediaListener(listener: (media: chrome.cast.media.Media) => void): void; + + /** + * @param listener + */ + removeMediaListener(listener: (media: chrome.cast.media.Media) => void): void; + + /** + * @param loadRequest + * @param successCallback + * @param errorCallback + */ + loadMedia( + loadRequest: chrome.cast.media.LoadRequest, + successCallback: (media: chrome.cast.media.Media) => void, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param queueLoadRequest + * @param successCallback + * @param errorCallback + */ + queueLoad( + queueLoadRequest: chrome.cast.media.QueueLoadRequest, + successCallback: (media: chrome.cast.media.Media) => void, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + } + + export class Receiver { + /** + * @param label + * @param friendlyName + * @param opt_capabilities + * @param opt_volume + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Receiver + */ + constructor( + label: string, + friendlyName: string, + capabilities?: chrome.cast.Capability[], + volume?: chrome.cast.Volume, + ); + + label: string; + friendlyName: string; + capabilities: chrome.cast.Capability[]; + volume: chrome.cast.Volume; + receiverType: chrome.cast.ReceiverType; + displayStatus: chrome.cast.ReceiverDisplayStatus; + } + + export class ReceiverDisplayStatus { + /** + * @param statusText + * @param appImages + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.ReceiverDisplayStatus + */ + constructor(statusText: string, appImages: chrome.cast.Image[]); + + statusText: string; + appImages: chrome.cast.Image[]; + } + + export class Volume { + /** + * @param opt_level + * @param opt_muted + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Volume + */ + constructor(level?: number, muted?: boolean); + + level: number | null; + muted: boolean | null; + } +} + +declare namespace chrome.cast.media { + export var DEFAULT_MEDIA_RECEIVER_APP_ID: string; + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.MediaCommand + */ + export enum MediaCommand { + PAUSE = 'pause', + SEEK = 'seek', + STREAM_VOLUME = 'stream_volume', + STREAM_MUTE = 'stream_mute', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.MetadataType + */ + export enum MetadataType { + GENERIC, + TV_SHOW, + MOVIE, + MUSIC_TRACK, + PHOTO, + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.PlayerState + */ + export enum PlayerState { + IDLE = 'IDLE', + PLAYING = 'PLAYING', + PAUSED = 'PAUSED', + BUFFERING = 'BUFFERING', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.ResumeState + */ + export enum ResumeState { + PLAYBACK_START = 'PLAYBACK_START', + PLAYBACK_PAUSE = 'PLAYBACK_PAUSE', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.StreamType + */ + export enum StreamType { + BUFFERED = 'BUFFERED', + LIVE = 'LIVE', + OTHER = 'OTHER', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.IdleReason + */ + export enum IdleReason { + CANCELLED = 'CANCELLED', + INTERRUPTED = 'INTERRUPTED', + FINISHED = 'FINISHED', + ERROR = 'ERROR', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.RepeatMode + */ + export enum RepeatMode { + OFF = 'REPEAT_OFF', + ALL = 'REPEAT_ALL', + SINGLE = 'REPEAT_SINGLE', + ALL_AND_SHUFFLE = 'REPEAT_ALL_AND_SHUFFLE', + } + + export class QueueItem { + /** + * @param mediaInfo + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueItem + */ + constructor(mediaInfo: chrome.cast.media.MediaInfo); + + activeTrackIds: Number[]; + autoplay: boolean; + customData: Object; + itemId: number; + media: chrome.cast.media.MediaInfo; + preloadTime: number; + startTime: number; + } + + export class QueueLoadRequest { + /** + * @param items + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueLoadRequest + */ + constructor(items: chrome.cast.media.QueueItem[]); + + customData: Object; + items: chrome.cast.media.QueueItem[]; + repeatMode: chrome.cast.media.RepeatMode; + startIndex: number; + } + + export class QueueInsertItemsRequest { + /** + * @param itemsToInsert + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueInsertItemsRequest + */ + constructor(itemsToInsert: chrome.cast.media.QueueItem[]); + + customData: Object; + insertBefore: number; + items: chrome.cast.media.QueueItem[]; + } + + export class QueueRemoveItemsRequest { + /** + * @param itemIdsToRemove + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueRemoveItemsRequest + */ + constructor(itemIdsToRemove: number[]); + + customData: Object; + itemIds: number[]; + } + + export class QueueReorderItemsRequest { + /** + * @param itemIdsToReorder + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueReorderItemsRequest + */ + constructor(itemIdsToReorder: number[]); + + customData: Object; + insertBefore: number; + itemIds: number[]; + } + + export class QueueUpdateItemsRequest { + /** + * @param itemsToUpdate + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueUpdateItemsRequest + */ + constructor(itemsToUpdate: chrome.cast.media.QueueItem[]); + + customData: Object; + item: chrome.cast.media.QueueItem[]; + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TrackType + */ + export enum TrackType { + TEXT = 'TEXT', + AUDIO = 'AUDIO', + VIDEO = 'VIDEO', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TextTrackType + */ + export enum TextTrackType { + SUBTITLES = 'SUBTITLES', + CAPTIONS = 'CAPTIONS', + DESCRIPTIONS = 'DESCRIPTIONS', + CHAPTERS = 'CHAPTERS', + METADATA = 'METADATA', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TextTrackEdgeType + */ + export enum TextTrackEdgeType { + NONE = 'NONE', + OUTLINE = 'OUTLINE', + DROP_SHADOW = 'DROP_SHADOW', + RAISED = 'RAISED', + DEPRESSED = 'DEPRESSED', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TextTrackWindowType + */ + export enum TextTrackWindowType { + NONE = 'NONE', + NORMAL = 'NORMAL', + ROUNDED_CORNERS = 'ROUNDED_CORNERS', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TextTrackFontGenericFamily + */ + export enum TextTrackFontGenericFamily { + SANS_SERIF = 'SANS_SERIF', + MONOSPACED_SANS_SERIF = 'MONOSPACED_SANS_SERIF', + SERIF = 'SERIF', + MONOSPACED_SERIF = 'MONOSPACED_SERIF', + CASUAL = 'CASUAL', + CURSIVE = 'CURSIVE', + SMALL_CAPITALS = 'SMALL_CAPITALS', + } + + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media#.TextTrackFontStyle + */ + export enum TextTrackFontStyle { + NORMAL = 'NORMAL', + BOLD = 'BOLD', + BOLD_ITALIC = 'BOLD_ITALIC', + ITALIC = 'ITALIC', + } + + export class GetStatusRequest { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.GetStatusRequest + */ + constructor(); + + customData: Object; + } + + export class PauseRequest { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.PauseRequest + */ + constructor(); + + customData: Object; + } + + export class PlayRequest { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.PlayRequest + */ + constructor(); + + customData: Object; + } + + export class SeekRequest { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.SeekRequest + */ + constructor(); + + currentTime: number; + resumeState: chrome.cast.media.ResumeState; + customData: Object; + } + + export class StopRequest { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.StopRequest + */ + constructor(); + + customData: Object; + } + + export class VolumeRequest { + /** + * @param volume + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.VolumeRequest + */ + constructor(volume: chrome.cast.Volume); + + volume: chrome.cast.Volume; + customData: Object; + } + + export class LoadRequest { + /** + * @param mediaInfo + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.LoadRequest + */ + constructor(mediaInfo: chrome.cast.media.MediaInfo); + + activeTrackIds: number[]; + autoplay: boolean; + currentTime: number; + customData: Object; + media: chrome.cast.media.MediaInfo; + playbackRate?: number | undefined; + } + + export class EditTracksInfoRequest { + /** + * @param opt_activeTrackIds + * @param opt_textTrackStyle + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.EditTracksInfoRequest + */ + constructor(activeTrackIds?: number[], textTrackStyle?: chrome.cast.media.TextTrackStyle); + + activeTrackIds: number[]; + textTrackStyle: chrome.cast.media.TextTrackStyle; + } + + export class GenericMediaMetadata { + images: chrome.cast.Image[]; + metadataType: chrome.cast.media.MetadataType; + releaseDate: string; + /** @deprecated Use releaseDate instead. */ + releaseYear: number; + subtitle: string; + title: string; + /** @deprecated Use metadataType instead. */ + type: chrome.cast.media.MetadataType; + } + + export class MovieMediaMetadata { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.MovieMediaMetadata + */ + constructor(); + + images: chrome.cast.Image[]; + metadataType: chrome.cast.media.MetadataType; + releaseDate: string; + /** @deprecated Use releaseDate instead. */ + releaseYear: number; + subtitle: string; + title: string; + studio: string; + /** @deprecated Use metadataType instead. */ + type: chrome.cast.media.MetadataType; + } + + export class TvShowMediaMetadata { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.TvShowMediaMetadata + */ + constructor(); + + metadataType: chrome.cast.media.MetadataType; + seriesTitle: string; + title: string; + season: number; + episode: number; + images: chrome.cast.Image[]; + originalAirdate: string; + + /** @deprecated Use metadataType instead. */ + type: chrome.cast.media.MetadataType; + /** @deprecated Use title instead. */ + episodeTitle: string; + /** @deprecated Use season instead. */ + seasonNumber: number; + /** @deprecated Use episode instead. */ + episodeNumber: number; + /** @deprecated Use originalAirdate instead. */ + releaseYear: number; + } + + export class MusicTrackMediaMetadata { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.MusicTrackMediaMetadata + */ + constructor(); + + metadataType: chrome.cast.media.MetadataType; + albumName: string; + title: string; + albumArtist: string; + artist: string; + composer: string; + songName: string; + trackNumber: number; + discNumber: number; + images: chrome.cast.Image[]; + releaseDate: string; + + /** @deprecated Use metadataType instead. */ + type: chrome.cast.media.MetadataType; + /** @deprecated Use artist instead. */ + artistName: string; + /** @deprecated Use releaseDate instead. */ + releaseYear: number; + } + + export class PhotoMediaMetadata { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.PhotoMediaMetadata + */ + constructor(); + + metadataType: chrome.cast.media.MetadataType; + title: string; + artist: string; + location: string; + images: chrome.cast.Image[]; + latitude: number; + longitude: number; + width: number; + height: number; + creationDateTime: string; + + /** @deprecated Use metadataType instead. */ + type: chrome.cast.media.MetadataType; + } + + export class MediaInfo { + /** + * @param contentId + * @param contentType + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.MediaInfo + */ + constructor(contentId: string, contentType: string); + + contentId: string; + streamType: chrome.cast.media.StreamType; + contentType: string; + metadata: any; + duration: number; + tracks: chrome.cast.media.Track[]; + textTrackStyle: chrome.cast.media.TextTrackStyle; + customData: Object; + } + + export class Media { + /** + * @param sessionId + * @param mediaSessionId + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.Media + */ + constructor(sessionId: string, mediaSessionId: number); + + activeTrackIds: number[]; + currentItemId: number; + customData: Object; + idleReason: chrome.cast.media.IdleReason | null; + items: chrome.cast.media.QueueItem[]; + liveSeekableRange?: chrome.cast.media.LiveSeekableRange | undefined; + loadingItemId: number; + media: chrome.cast.media.MediaInfo; + mediaSessionId: number; + playbackRate: number; + playerState: chrome.cast.media.PlayerState; + preloadedItemId: number; + repeatMode: chrome.cast.media.RepeatMode; + sessionId: string; + supportedMediaCommands: chrome.cast.media.MediaCommand[]; + volume: chrome.cast.Volume; + + /** @deprecated Use getEstimatedTime instead */ + currentTime: number; + + /** + * @param getStatusRequest + * @param successCallback + * @param errorCallback + */ + getStatus( + getStatusRequest: chrome.cast.media.GetStatusRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param playRequest + * @param successCallback + * @param errorCallback + */ + play( + playRequest: chrome.cast.media.PlayRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param pauseRequest + * @param successCallback + * @param errorCallback + */ + pause( + pauseRequest: chrome.cast.media.PauseRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param seekRequest + * @param successCallback + * @param errorCallback + */ + seek( + seekRequest: chrome.cast.media.SeekRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param stopRequest + * @param successCallback + * @param errorCallback + */ + stop( + stopRequest: chrome.cast.media.StopRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param volumeRequest + * @param successCallback + * @param errorCallback + */ + setVolume( + volumeRequest: chrome.cast.media.VolumeRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param editTracksInfoRequest + * @param successCallback + * @param errorCallback + */ + editTracksInfo( + editTracksInfoRequest: chrome.cast.media.EditTracksInfoRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param command + * @return whether or not the receiver supports the given chrome.cast.media.MediaCommand + */ + supportsCommand(command: chrome.cast.media.MediaCommand): boolean; + + /** + * @param listener + */ + addUpdateListener(listener: (isAlive: boolean) => void): void; + + /** + * @param listener + */ + removeUpdateListener(listener: (isAlive: boolean) => void): void; + + /** + * @return + * @suppress {deprecated} Uses currentTime member to compute estimated time. + */ + getEstimatedTime(): number; + + /** + * @param item + * @param successCallback + * @param errorCallback + */ + queueAppendItem( + item: chrome.cast.media.QueueItem, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param queueInsertItemsRequest + * @param successCallback + * @param errorCallback + */ + queueInsertItems( + queueInsertItemsRequest: chrome.cast.media.QueueInsertItemsRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param itemId + * @param successCallback + * @param errorCallback + */ + queueJumpToItem( + itemId: number, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param itemId + * @param newIndex + * @param successCallback + * @param errorCallback + */ + queueMoveItemToNewIndex( + itemId: number, + newIndex: number, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param successCallback + * @param errorCallback + */ + queueNext(successCallback: Function, errorCallback: (error: chrome.cast.Error) => void): void; + + /** + * @param successCallback + * @param errorCallback + */ + queuePrev(successCallback: Function, errorCallback: (error: chrome.cast.Error) => void): void; + + /** + * @param itemId + * @param successCallback + * @param errorCallback + */ + queueRemoveItem( + itemId: number, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param queueReorderItemsRequest + * @param successCallback + * @param errorCallback + */ + queueReorderItems( + queueReorderItemsRequest: chrome.cast.media.QueueReorderItemsRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param repeatMode + * @param successCallback + * @param errorCallback + */ + queueSetRepeatMode( + repeatMode: chrome.cast.media.RepeatMode, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + + /** + * @param queueUpdateItemsRequest + * @param successCallback + * @param errorCallback + */ + queueUpdateItems( + queueUpdateItemsRequest: chrome.cast.media.QueueUpdateItemsRequest, + successCallback: Function, + errorCallback: (error: chrome.cast.Error) => void, + ): void; + } + + export class Track { + /** + * @param trackId + * @param trackType + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.Track + */ + constructor(trackId: number, trackType: chrome.cast.media.TrackType); + + trackId: number; + trackContentId: string; + trackContentType: string; + type: chrome.cast.media.TrackType; + name: string; + language: string; + subtype: chrome.cast.media.TextTrackType; + customData: Object; + } + + export class TextTrackStyle { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.TextTrackStyle + */ + constructor(); + + foregroundColor: string; + backgroundColor: string; + edgeType: chrome.cast.media.TextTrackEdgeType; + edgeColor: string; + windowType: chrome.cast.media.TextTrackWindowType; + windowColor: string; + windowRoundedCornerRadius: number; + fontScale: number; + fontFamily: string; + fontGenericFamily: chrome.cast.media.TextTrackFontGenericFamily; + fontStyle: chrome.cast.media.TextTrackFontStyle; + customData: Object; + } + + export class LiveSeekableRange { + /** + * @param start + * @param end + * @param isMovingWindow + * @param isLiveDone + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.LiveSeekableRange + */ + constructor(start?: number, end?: number, isMovingWindow?: boolean, isLiveDone?: boolean); + + start?: number | undefined; + end?: number | undefined; + isMovingWindow?: boolean | undefined; + isLiveDone?: boolean | undefined; + } +} + +/** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.timeout + */ +declare namespace chrome.cast.media.timeout { + export var load: number; + export var getStatus: number; + export var play: number; + export var pause: number; + export var seek: number; + export var stop: number; + export var setVolume: number; + export var editTracksInfo: number; + export var queueInsert: number; + export var queueLoad: number; + export var queueRemove: number; + export var queueReorder: number; + export var queueUpdate: number; +} + +/** + * Cast Application Framework + * + * @see https://developers.google.com/cast/docs/reference/chrome/cast.framework + */ +declare namespace cast.framework { + export enum LoggerLevel { + DEBUG, + INFO, + WARNING, + ERROR, + NONE, + } + + export enum CastState { + NO_DEVICES_AVAILABLE = 'NO_DEVICES_AVAILABLE', + NOT_CONNECTED = 'NOT_CONNECTED', + CONNECTING = 'CONNECTING', + CONNECTED = 'CONNECTED', + } + + export enum SessionState { + NO_SESSION = 'NO_SESSION', + SESSION_STARTING = 'SESSION_STARTING', + SESSION_STARTED = 'SESSION_STARTED', + SESSION_START_FAILED = 'SESSION_START_FAILED', + SESSION_ENDING = 'SESSION_ENDING', + SESSION_ENDED = 'SESSION_ENDED', + SESSION_RESUMED = 'SESSION_RESUMED', + } + + export enum CastContextEventType { + CAST_STATE_CHANGED = 'caststatechanged', + SESSION_STATE_CHANGED = 'sessionstatechanged', + } + + export enum SessionEventType { + APPLICATION_STATUS_CHANGED = 'applicationstatuschanged', + APPLICATION_METADATA_CHANGED = 'applicationmetadatachanged', + ACTIVE_INPUT_STATE_CHANGED = 'activeinputstatechanged', + VOLUME_CHANGED = 'volumechanged', + MEDIA_SESSION = 'mediasession', + } + + export enum RemotePlayerEventType { + ANY_CHANGE = 'anyChanged', + IS_CONNECTED_CHANGED = 'isConnectedChanged', + IS_MEDIA_LOADED_CHANGED = 'isMediaLoadedChanged', + DURATION_CHANGED = 'durationChanged', + CURRENT_TIME_CHANGED = 'currentTimeChanged', + IS_PAUSED_CHANGED = 'isPausedChanged', + VOLUME_LEVEL_CHANGED = 'volumeLevelChanged', + CAN_CONTROL_VOLUME_CHANGED = 'canControlVolumeChanged', + IS_MUTED_CHANGED = 'isMutedChanged', + CAN_PAUSE_CHANGED = 'canPauseChanged', + CAN_SEEK_CHANGED = 'canSeekChanged', + DISPLAY_NAME_CHANGED = 'displayNameChanged', + STATUS_TEXT_CHANGED = 'statusTextChanged', + TITLE_CHANGED = 'titleChanged', + DISPLAY_STATUS_CHANGED = 'displayStatusChanged', + MEDIA_INFO_CHANGED = 'mediaInfoChanged', + IMAGE_URL_CHANGED = 'imageUrlChanged', + PLAYER_STATE_CHANGED = 'playerStateChanged', + LIVE_SEEKABLE_RANGE_CHANGED = 'liveSeekableRange', + } + + export enum ActiveInputState { + ACTIVE_INPUT_STATE_UNKNOWN = -1, + ACTIVE_INPUT_STATE_NO = 0, + ACTIVE_INPUT_STATE_YES = 1, + } + + export interface CastOptions { + autoJoinPolicy: chrome.cast.AutoJoinPolicy; + language?: string | undefined; + receiverApplicationId?: string | undefined; + resumeSavedSession?: boolean | undefined; + /** The following flag enables Cast Connect(requires Chrome 87 or higher) */ + androidReceiverCompatible?: boolean | undefined; + } + + export const VERSION: string; + export function setLoggerLevel(level: LoggerLevel): void; + + export class CastContext { + static getInstance(): CastContext; + setOptions(options: CastOptions): void; + getCastState(): CastState; + getSessionState(): SessionState; + requestSession(): Promise; + getCurrentSession(): CastSession | null; + endCurrentSession(stopCasting: boolean): void; + addEventListener( + type: T, + handler: (event: CastContextEvents[T]) => void, + ): void; + removeEventListener( + type: T, + handler: (event: CastContextEvents[T]) => void, + ): void; + } + + export interface CastContextEvents { + [CastContextEventType.CAST_STATE_CHANGED]: CastStateEventData; + [CastContextEventType.SESSION_STATE_CHANGED]: SessionStateEventData; + } + + export class CastSession { + constructor(sessionObj: chrome.cast.Session, state: SessionState); + getSessionObj(): chrome.cast.Session; + getSessionId(): string; + getSessionState(): SessionState; + getCastDevice(): chrome.cast.Receiver; + getApplicationMetadata(): ApplicationMetadata; + getApplicationStatus(): string; + getActiveInputState(): ActiveInputState; + endSession(stopCasting: boolean): void; + setVolume(volume: number): Promise; + getVolume(): number; + setMute(mute: boolean): Promise; + isMute(): boolean; + sendMessage(namespace: string, data: any): Promise; + addMessageListener( + namespace: string, + listener: (namespace: string, message: string) => void, + ): void; + removeMessageListener( + namespace: string, + listener: (namespace: string, message: string) => void, + ): void; + loadMedia(request: chrome.cast.media.LoadRequest): Promise; + getMediaSession(): chrome.cast.media.Media | null; + addEventListener( + type: T, + handler: (event: CastSessionEvents[T]) => void, + ): void; + removeEventListener( + type: T, + handler: (event: CastSessionEvents[T]) => void, + ): void; + } + + export interface CastSessionEvents { + [SessionEventType.ACTIVE_INPUT_STATE_CHANGED]: ActiveInputStateEventData; + [SessionEventType.APPLICATION_METADATA_CHANGED]: ApplicationMetadataEventData; + [SessionEventType.APPLICATION_STATUS_CHANGED]: ApplicationStatusEventData; + [SessionEventType.MEDIA_SESSION]: MediaSessionEventData; + [SessionEventType.VOLUME_CHANGED]: VolumeEventData; + } + + export class RemotePlayerController { + constructor(player: RemotePlayer); + playOrPause(): void; + stop(): void; + seek(): void; + muteOrUnmute(): void; + setVolumeLevel(): void; + getFormattedTime(timeInSec: number): string; + getSeekPosition(currentTime: number, duration: number): number; + getSeekTime(currentPosition: number, duration: number): number; + addEventListener( + type: RemotePlayerEventType, + handler: (event: RemotePlayerChangedEvent) => void, + ): void; + removeEventListener( + type: RemotePlayerEventType, + handler: (event: RemotePlayerChangedEvent) => void, + ): void; + } + + export interface SavedPlayerState { + mediaInfo: chrome.cast.media.PlayerState | null; + currentTime: number; + isPaused: boolean; + } + + export class RemotePlayer { + isConnected: boolean; + isMediaLoaded: boolean; + duration: number; + currentTime: number; + volumeLevel: number; + canControlVolume: boolean; + isPaused: boolean; + isMuted: boolean; + canPause: boolean; + canSeek: boolean; + displayName: string; + statusText: string; + title: string; + displayStatus: string; + liveSeekableRange?: chrome.cast.media.LiveSeekableRange | undefined; + mediaInfo?: chrome.cast.media.MediaInfo | undefined; + imageUrl: string | null; + playerState: chrome.cast.media.PlayerState | null; + savedPlayerState: SavedPlayerState | null; + controller: RemotePlayerController | null; + } + + export class ApplicationMetadata { + constructor(sessionObj: chrome.cast.Session); + applicationId: string; + images: chrome.cast.Image[]; + name: string; + namespaces: string[]; + } + + export abstract class EventData { + constructor(type: string); + type: string; + } + + export class ActiveInputStateEventData extends EventData { + constructor(activeInputState: ActiveInputState); + activeInputState: ActiveInputState; + } + + export class ApplicationMetadataEventData extends EventData { + constructor(metadata: ApplicationMetadata); + metadata: ApplicationMetadata; + } + + export class ApplicationStatusEventData extends EventData { + constructor(status: string); + status: string; + } + + export class CastStateEventData extends EventData { + constructor(castState: CastState); + castState: CastState; + } + + export class MediaSessionEventData extends EventData { + constructor(mediaSession: chrome.cast.media.Media); + mediaSession: chrome.cast.media.Media; + } + + export class RemotePlayerChangedEvent extends EventData { + constructor(type: RemotePlayerEventType, field: string, value: T); + field: string; + value: T; + } + + export class SessionStateEventData extends EventData { + constructor( + session: CastSession, + sessionState: SessionState, + opt_errorCode: chrome.cast.ErrorCode, + ); + errorCode: chrome.cast.ErrorCode; + session: CastSession; + sessionState: SessionState; + } + + export class VolumeEventData extends EventData { + constructor(volume: number, isMute: boolean); + isMute: boolean; + volume: number; + } +} diff --git a/packages/vidstack/mangle.json b/packages/vidstack/mangle.json index ede5b511b..4a49d757a 100644 --- a/packages/vidstack/mangle.json +++ b/packages/vidstack/mangle.json @@ -660,5 +660,56 @@ "_peek": "kl", "_clear": "pl", "_requestAirPlay": "ol", - "_logError": "ql" + "_logError": "ql", + "_canPrompt": "sl", + "_connect": "vl", + "_connectDisposal": "rl", + "_getRemotePlaybackState": "xl", + "_onCastStateChanged": "tl", + "_onSessionResumed": "yl", + "_onSessionStateChanged": "wl", + "_requestGoogleCast": "ul", + "_addMediaMetadata": "Hl", + "_addMediaStreamType": "Fl", + "_addMediaTracks": "Gl", + "_buildCastTrack": "Il", + "_buildMediaInfo": "Dl", + "_getActiveCastTrackIds": "El", + "_getDefaultLabel": "zl", + "_getValidTracks": "Bl", + "_onGoogleCastSupportChange": "Ll", + "_onMediaLoadedChange": "Jl", + "_syncWithCastState": "Kl", + "_throwIfPlayerNotReady": "Al", + "_watchLoad": "Cl", + "_connectToReceiver": "Ol", + "_disconnectFromReceiver": "Ml", + "_loadCastFramework": "Pl", + "_mediaInfoBuilder": "Nl", + "_setCastOptions": "Ql", + "_showPrompt": "Rl", + "_throwNotAvailable": "Sl", + "_setOptions": "Tl", + "_attachCastEvents": "_l", + "_attachPlayerEvents": "$l", + "_buildLoadRequest": "am", + "_createEvent": "Vl", + "_getActiveRemoteTrackIds": "bm", + "_getRemoteTracks": "Wl", + "_info": "Ul", + "_setMetadata": "Zl", + "_setStreamType": "Xl", + "_setTracks": "Yl", + "_attachCastPlayerEvents": "hm", + "_getPlayedRange": "cm", + "_getSeekableRange": "gm", + "_getStreamType": "mm", + "_onCanControlVolumeChange": "em", + "_onCanSeekChange": "fm", + "_onCurrentTimeChange": "im", + "_onPausedChange": "jm", + "_onPlayerStateChange": "km", + "_onRemotePlayerEvent": "lm", + "_playerEventHandlers": "dm", + "_watchCanSetVolume": "nm" } diff --git a/packages/vidstack/package.json b/packages/vidstack/package.json index 140c789ac..7bfc8a59d 100644 --- a/packages/vidstack/package.json +++ b/packages/vidstack/package.json @@ -153,6 +153,8 @@ "types": "./solid.d.ts", "default": "./solid.d.ts" }, + "./dom.d.ts": "./dom.d.ts", + "./google-cast.d.ts": "./google-cast.d.ts", "./vscode.html-data.json": "./vscode.html-data.json" }, "publishConfig": { diff --git a/packages/vidstack/player/index.d.ts b/packages/vidstack/player/index.d.ts index 395930b49..7bfe56848 100644 --- a/packages/vidstack/player/index.d.ts +++ b/packages/vidstack/player/index.d.ts @@ -1,4 +1,3 @@ -/// /// export {}; diff --git a/packages/vidstack/player/layouts.d.ts b/packages/vidstack/player/layouts.d.ts index 395930b49..7bfe56848 100644 --- a/packages/vidstack/player/layouts.d.ts +++ b/packages/vidstack/player/layouts.d.ts @@ -1,4 +1,3 @@ -/// /// export {}; diff --git a/packages/vidstack/player/ui.d.ts b/packages/vidstack/player/ui.d.ts index 395930b49..7bfe56848 100644 --- a/packages/vidstack/player/ui.d.ts +++ b/packages/vidstack/player/ui.d.ts @@ -1,4 +1,3 @@ -/// /// export {}; diff --git a/packages/vidstack/rollup.config.js b/packages/vidstack/rollup.config.js index bcf877a62..97838508b 100644 --- a/packages/vidstack/rollup.config.js +++ b/packages/vidstack/rollup.config.js @@ -20,7 +20,7 @@ const NPM_EXTERNAL_PACKAGES = ['hls.js', 'media-captions', 'media-icons'], CDN_EXTERNAL_PACKAGES = ['media-captions', 'media-icons'], NPM_BUNDLES = [define({ type: 'server' }), define({ type: 'dev' }), define({ type: 'prod' })], CDN_BUNDLES = [defineCDN({ dev: true }), defineCDN(), defineCDN({ layouts: true })], - TYPES_BUNDLES = [defineTypes()]; + TYPES_BUNDLES = defineTypes(); // Styles if (!MODE_TYPES) { @@ -46,34 +46,51 @@ export default defineConfig( ); /** - * @returns {import('rollup').RollupOptions} + * @returns {import('rollup').RollupOptions[]} * */ function defineTypes() { /** @type {Record} */ - let input = { - ['index']: 'src/index.ts', - elements: 'src/elements/index.ts', - icons: 'src/elements/bundles/icons.ts', + const input = { + index: 'types/index.d.ts', + elements: 'types/elements/index.d.ts', + icons: 'types/elements/bundles/icons.d.ts', }; - for (const key of Object.keys(input)) { - input[key] = input[key].replace(/^src/, 'types').replace(/\.ts$/, '.d.ts'); - } - - return { - input, - output: { - dir: '.', - chunkFileNames: 'dist/types/vidstack-[hash].d.ts', - manualChunks(id) { - if (id.includes('maverick') || id.includes('lit-html') || id.includes('@floating-ui')) { - return 'framework.d'; - } + return [ + { + input, + output: { + dir: '.', + chunkFileNames: 'dist/types/vidstack-[hash].d.ts', + manualChunks(id) { + if (id.includes('maverick') || id.includes('lit-html') || id.includes('@floating-ui')) { + return 'framework.d'; + } + }, }, + external: NPM_EXTERNAL_PACKAGES, + plugins: [ + dts({ + respectExternal: true, + }), + { + name: 'globals', + generateBundle(_, bundle) { + const files = new Set(['index.d.ts', 'elements.d.ts']), + references = ['dom.d.ts', 'google-cast.d.ts'] + .map((path) => `/// `) + .join('\n'); + + for (const file of Object.values(bundle)) { + if (files.has(file.fileName) && file.type === 'chunk' && file.isEntry) { + file.code = references + `\n\n${file.code}`; + } + } + }, + }, + ], }, - external: NPM_EXTERNAL_PACKAGES, - plugins: [dts({ respectExternal: true })], - }; + ]; } /** @@ -105,12 +122,7 @@ function define({ target, type, minify }) { if (!isServer) { input = { ...input, - [`providers/vidstack-html`]: 'src/providers/html/provider.ts', - [`providers/vidstack-audio`]: 'src/providers/audio/provider.ts', - [`providers/vidstack-video`]: 'src/providers/video/provider.ts', - [`providers/vidstack-hls`]: 'src/providers/hls/provider.ts', - [`providers/vidstack-youtube`]: 'src/providers/youtube/provider.ts', - [`providers/vidstack-vimeo`]: 'src/providers/vimeo/provider.ts', + ...getProviderInputs(), }; input['define/vidstack-audio-layout'] = @@ -191,12 +203,7 @@ function defineCDN({ dev = false, layouts = false } = {}) { }), input: { [output]: input, - [`providers/vidstack-html`]: 'src/providers/html/provider.ts', - [`providers/vidstack-audio`]: 'src/providers/audio/provider.ts', - [`providers/vidstack-video`]: 'src/providers/video/provider.ts', - [`providers/vidstack-hls`]: 'src/providers/hls/provider.ts', - [`providers/vidstack-youtube`]: 'src/providers/youtube/provider.ts', - [`providers/vidstack-vimeo`]: 'src/providers/vimeo/provider.ts', + ...getProviderInputs(), }, output: { format: 'esm', @@ -264,3 +271,15 @@ export async function buildMangleCache() { return mangleCache; } + +function getProviderInputs() { + return { + [`providers/vidstack-html`]: 'src/providers/html/provider.ts', + [`providers/vidstack-audio`]: 'src/providers/audio/provider.ts', + [`providers/vidstack-video`]: 'src/providers/video/provider.ts', + [`providers/vidstack-hls`]: 'src/providers/hls/provider.ts', + [`providers/vidstack-youtube`]: 'src/providers/youtube/provider.ts', + [`providers/vidstack-vimeo`]: 'src/providers/vimeo/provider.ts', + [`providers/vidstack-google-cast`]: 'src/providers/google-cast/provider.ts', + }; +} diff --git a/packages/vidstack/src/components/index.ts b/packages/vidstack/src/components/index.ts index 7db5740a5..e5f8fb3c5 100644 --- a/packages/vidstack/src/components/index.ts +++ b/packages/vidstack/src/components/index.ts @@ -11,6 +11,7 @@ export * from './ui/tooltip/tooltip-trigger'; export * from './ui/tooltip/tooltip-content'; export * from './ui/buttons/toggle-button'; export * from './ui/buttons/airplay-button'; +export * from './ui/buttons/google-cast-button'; export * from './ui/buttons/play-button'; export * from './ui/buttons/caption-button'; export * from './ui/buttons/fullscreen-button'; diff --git a/packages/vidstack/src/components/layouts/default-layout.ts b/packages/vidstack/src/components/layouts/default-layout.ts index 94cd38ae7..83abb6bf6 100644 --- a/packages/vidstack/src/components/layouts/default-layout.ts +++ b/packages/vidstack/src/components/layouts/default-layout.ts @@ -203,6 +203,7 @@ export interface DefaultLayoutTranslations { Connecting: string; Disconnected: string; Default: string; + 'Google Cast': string; LIVE: string; Mute: string; Normal: string; diff --git a/packages/vidstack/src/components/player.ts b/packages/vidstack/src/components/player.ts index 846aee612..b8e5e958a 100644 --- a/packages/vidstack/src/components/player.ts +++ b/packages/vidstack/src/components/player.ts @@ -63,7 +63,7 @@ import { RequestQueue } from '../foundation/queue/request-queue'; import type { AnyMediaProvider, MediaProviderAdapter } from '../providers'; import { setAttributeIfEmpty } from '../utils/dom'; import { clampNumber } from '../utils/number'; -import { canChangeVolume, IS_IPHONE } from '../utils/support'; +import { IS_IPHONE } from '../utils/support'; declare global { interface HTMLElementEventMap { @@ -148,7 +148,6 @@ export class MediaPlayer const context = { player: this, - scope: getScope(), qualities: new VideoQualityList(), audioTracks: new AudioTrackList(), storage, @@ -224,8 +223,6 @@ export class MediaPlayer protected override onConnect(el: HTMLElement) { if (IS_IPHONE) setAttribute(el, 'data-iphone', ''); - canChangeVolume().then(this.$state.canSetVolume.set); - const pointerQuery = window.matchMedia('(pointer: coarse)'); this._onPointerChange(pointerQuery); pointerQuery.onchange = this._onPointerChange.bind(this); @@ -706,6 +703,15 @@ export class MediaPlayer return this._requestMgr._requestAirPlay(trigger); } + /** + * Request Google Cast device picker to open. The Google Cast framework will be loaded if it + * hasn't yet. + */ + @method + requestGoogleCast(trigger?: Event) { + return this._requestMgr._requestGoogleCast(trigger); + } + /** * Returns a new `PlayerQueryList` object that can then be used to determine if the * player and document matches the query string, as well as to monitor any changes to detect diff --git a/packages/vidstack/src/components/provider/source-select.ts b/packages/vidstack/src/components/provider/source-select.ts index 50d1d59d1..e7d0bc04d 100644 --- a/packages/vidstack/src/components/provider/source-select.ts +++ b/packages/vidstack/src/components/provider/source-select.ts @@ -49,9 +49,13 @@ export class SourceSelection { EMBED_LOADERS = [YOUTUBE_LOADER, VIMEO_LOADER]; this._loaders = computed(() => { - return _media.$props.preferNativeHLS() + const remoteLoader = _media.$state.remotePlaybackLoader(); + + const loaders = _media.$props.preferNativeHLS() ? [VIDEO_LOADER, AUDIO_LOADER, HLS_LOADER, ...EMBED_LOADERS, ...customLoaders] : [HLS_LOADER, VIDEO_LOADER, AUDIO_LOADER, ...EMBED_LOADERS, ...customLoaders]; + + return remoteLoader ? [remoteLoader, ...loaders] : loaders; }); const { $state } = _media; @@ -149,10 +153,11 @@ export class SourceSelection { protected _findNewSource(currentSource: MediaSrc, sources: MediaSrc[]) { let newSource: MediaSrc = { src: '', type: '' }, - newLoader: MediaProviderLoader | null = null; + newLoader: MediaProviderLoader | null = null, + loaders = this._loaders(); for (const src of sources) { - const loader = peek(this._loaders).find((loader) => loader.canPlay(src)); + const loader = loaders.find((loader) => loader.canPlay(src)); if (loader) { newSource = src; newLoader = loader; @@ -185,15 +190,16 @@ export class SourceSelection { private _onSetup() { const provider = this._media.$provider(); + if (!provider || peek(this._media.$providerSetup)) return; if (this._media.$state.canLoad()) { - scoped(() => provider.setup(this._media), provider.scope); + scoped(() => provider.setup(), provider.scope); this._media.$providerSetup.set(true); return; } - peek(() => provider.preconnect?.(this._media)); + peek(() => provider.preconnect?.()); } private _onLoadSource() { @@ -226,13 +232,26 @@ export class SourceSelection { this._notify('stream-type-change', 'on-demand'); } - peek(() => provider?.loadSource(source, peek(this._media.$state.preload))); + peek(() => { + const preload = peek(this._media.$state.preload); + return provider?.loadSource(source, preload).catch((error) => { + if (__DEV__) { + this._media.logger + ?.errorGroup('[vidstack] failed to load source') + .labelledLog('Error', error) + .labelledLog('Source', source) + .labelledLog('Provider', provider) + .labelledLog('Media Context', { ...this._media }) + .dispatch(); + } + }); + }); return () => abort.abort(); } try { - isString(source.src) && preconnect(new URL(source.src).origin, 'preconnect'); + isString(source.src) && preconnect(new URL(source.src).origin); } catch (error) { if (__DEV__) { this._media.logger diff --git a/packages/vidstack/src/components/ui/buttons/airplay-button.ts b/packages/vidstack/src/components/ui/buttons/airplay-button.ts index 31716d74f..e921dd1b9 100644 --- a/packages/vidstack/src/components/ui/buttons/airplay-button.ts +++ b/packages/vidstack/src/components/ui/buttons/airplay-button.ts @@ -46,7 +46,7 @@ export class AirPlayButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-tooltip', 'airplay'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -64,7 +64,7 @@ export class AirPlayButton extends Component { return remotePlaybackType() === 'airplay' && remotePlaybackState(); } - private _getLabel() { + private _getDefaultLabel() { const { remotePlaybackState } = this._media.$state; return `AirPlay ${remotePlaybackState()}`; } diff --git a/packages/vidstack/src/components/ui/buttons/caption-button.ts b/packages/vidstack/src/components/ui/buttons/caption-button.ts index b173e7c5a..abb534aa5 100644 --- a/packages/vidstack/src/components/ui/buttons/caption-button.ts +++ b/packages/vidstack/src/components/ui/buttons/caption-button.ts @@ -44,7 +44,7 @@ export class CaptionButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-tooltip', 'caption'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -62,7 +62,7 @@ export class CaptionButton extends Component { return !hasCaptions(); } - private _getLabel() { + private _getDefaultLabel() { const { textTrack } = this._media.$state; return textTrack() ? 'Closed-Captions Off' : 'Closed-Captions On'; } diff --git a/packages/vidstack/src/components/ui/buttons/fullscreen-button.ts b/packages/vidstack/src/components/ui/buttons/fullscreen-button.ts index 4f72b0ab9..a945a5274 100644 --- a/packages/vidstack/src/components/ui/buttons/fullscreen-button.ts +++ b/packages/vidstack/src/components/ui/buttons/fullscreen-button.ts @@ -58,7 +58,7 @@ export class FullscreenButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-tooltip', 'fullscreen'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -79,7 +79,7 @@ export class FullscreenButton extends Component { return canFullscreen(); } - private _getLabel() { + private _getDefaultLabel() { const { fullscreen } = this._media.$state; return fullscreen() ? 'Exit Fullscreen' : 'Enter Fullscreen'; } diff --git a/packages/vidstack/src/components/ui/buttons/google-cast-button.ts b/packages/vidstack/src/components/ui/buttons/google-cast-button.ts new file mode 100644 index 000000000..69c59dd36 --- /dev/null +++ b/packages/vidstack/src/components/ui/buttons/google-cast-button.ts @@ -0,0 +1,71 @@ +import { Component } from 'maverick.js'; + +import { useMediaContext, type MediaContext } from '../../../core/api/media-context'; +import { $ariaBool } from '../../../utils/aria'; +import { setARIALabel } from '../../../utils/dom'; +import { + ToggleButtonController, + type ToggleButtonControllerProps, +} from './toggle-button-controller'; + +export interface GoogleCastButtonProps extends ToggleButtonControllerProps {} + +/** + * A button for requesting Google Cast. + * + * @attr data-active - Whether Google Cast is connected. + * @attr data-supported - Whether Google Cast is available. + * @attr data-state - Current connection state. + * @see {@link https://developers.google.com/cast/docs/overview} + * @docs {@link https://www.vidstack.io/docs/player/components/buttons/google-cast-button} + */ +export class GoogleCastButton extends Component { + static props: GoogleCastButtonProps = ToggleButtonController.props; + + private _media!: MediaContext; + + constructor() { + super(); + new ToggleButtonController({ + _isPressed: this._isPressed.bind(this), + _onPress: this._onPress.bind(this), + }); + } + + protected override onSetup(): void { + this._media = useMediaContext(); + + const { canGoogleCast, isGoogleCastConnected } = this._media.$state; + this.setAttributes({ + 'data-active': isGoogleCastConnected, + 'data-supported': canGoogleCast, + 'data-state': this._getState.bind(this), + 'aria-hidden': $ariaBool(() => !canGoogleCast()), + }); + } + + protected override onAttach(el: HTMLElement): void { + el.setAttribute('data-media-tooltip', 'google-cast'); + setARIALabel(el, this._getDefaultLabel.bind(this)); + } + + private _onPress(event: Event) { + const remote = this._media.remote; + remote.requestGoogleCast(event); + } + + private _isPressed() { + const { remotePlaybackType, remotePlaybackState } = this._media.$state; + return remotePlaybackType() === 'google-cast' && remotePlaybackState() !== 'disconnected'; + } + + private _getState() { + const { remotePlaybackType, remotePlaybackState } = this._media.$state; + return remotePlaybackType() === 'google-cast' && remotePlaybackState(); + } + + private _getDefaultLabel() { + const { remotePlaybackState } = this._media.$state; + return `Google Cast ${remotePlaybackState()}`; + } +} diff --git a/packages/vidstack/src/components/ui/buttons/mute-button.ts b/packages/vidstack/src/components/ui/buttons/mute-button.ts index d3235d992..aecd93b66 100644 --- a/packages/vidstack/src/components/ui/buttons/mute-button.ts +++ b/packages/vidstack/src/components/ui/buttons/mute-button.ts @@ -42,7 +42,7 @@ export class MuteButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-mute-button', ''); el.setAttribute('data-media-tooltip', 'mute'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -55,7 +55,7 @@ export class MuteButton extends Component { return muted() || volume() === 0; } - private _getLabel() { + private _getDefaultLabel() { return this._isPressed() ? 'Unmute' : 'Mute'; } diff --git a/packages/vidstack/src/components/ui/buttons/pip-button.ts b/packages/vidstack/src/components/ui/buttons/pip-button.ts index da7f4fc38..ab6337f16 100644 --- a/packages/vidstack/src/components/ui/buttons/pip-button.ts +++ b/packages/vidstack/src/components/ui/buttons/pip-button.ts @@ -47,7 +47,7 @@ export class PIPButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-tooltip', 'pip'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -65,7 +65,7 @@ export class PIPButton extends Component { return canPictureInPicture(); } - private _getLabel() { + private _getDefaultLabel() { const { pictureInPicture } = this._media.$state; return pictureInPicture() ? 'Exit Picture In Picture' : 'Enter Picture In Picture'; } diff --git a/packages/vidstack/src/components/ui/buttons/play-button.ts b/packages/vidstack/src/components/ui/buttons/play-button.ts index 9b5d61969..73feabe11 100644 --- a/packages/vidstack/src/components/ui/buttons/play-button.ts +++ b/packages/vidstack/src/components/ui/buttons/play-button.ts @@ -43,7 +43,7 @@ export class PlayButton extends Component { protected override onAttach(el: HTMLElement): void { el.setAttribute('data-media-tooltip', 'play'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } private _onPress(event: Event) { @@ -56,7 +56,7 @@ export class PlayButton extends Component { return !paused(); } - private _getLabel() { + private _getDefaultLabel() { const { paused } = this._media.$state; return paused() ? 'Play' : 'Pause'; } diff --git a/packages/vidstack/src/components/ui/buttons/seek-button.ts b/packages/vidstack/src/components/ui/buttons/seek-button.ts index e5b188621..1a03a44e7 100644 --- a/packages/vidstack/src/components/ui/buttons/seek-button.ts +++ b/packages/vidstack/src/components/ui/buttons/seek-button.ts @@ -59,7 +59,7 @@ export class SeekButton extends Component { setAttributeIfEmpty(el, 'role', 'button'); setAttributeIfEmpty(el, 'type', 'button'); el.setAttribute('data-media-tooltip', 'seek'); - setARIALabel(el, this._getLabel.bind(this)); + setARIALabel(el, this._getDefaultLabel.bind(this)); } protected override onConnect(el: HTMLElement) { @@ -71,7 +71,7 @@ export class SeekButton extends Component { return canSeek(); } - protected _getLabel() { + protected _getDefaultLabel() { const { seconds } = this.$props; return `Seek ${seconds() > 0 ? 'forward' : 'backward'} ${seconds()} seconds`; } diff --git a/packages/vidstack/src/components/ui/thumbnails/thumbnail-loader.ts b/packages/vidstack/src/components/ui/thumbnails/thumbnail-loader.ts index cc85c901a..85a9b4793 100644 --- a/packages/vidstack/src/components/ui/thumbnails/thumbnail-loader.ts +++ b/packages/vidstack/src/components/ui/thumbnails/thumbnail-loader.ts @@ -265,7 +265,7 @@ export class ThumbnailsLoader { if (!__DEV__ || warned?.has(src)) return; this._media.logger - ?.errorGroup('Failed to load thumbnails.') + ?.errorGroup('[vidstack] failed to load thumbnails') .labelledLog('Src', src) .labelledLog('Error', error) .dispatch(); diff --git a/packages/vidstack/src/core/api/media-context.ts b/packages/vidstack/src/core/api/media-context.ts index 207b02279..439222ad8 100644 --- a/packages/vidstack/src/core/api/media-context.ts +++ b/packages/vidstack/src/core/api/media-context.ts @@ -3,7 +3,6 @@ import { useContext, type ReadSignal, type ReadSignalRecord, - type Scope, type WriteSignal, } from 'maverick.js'; @@ -23,7 +22,6 @@ import type { PlayerStore } from './player-state'; export interface MediaContext { player: MediaPlayer; - scope: Scope; storage: MediaStorage; remote: MediaRemoteControl; delegate: MediaPlayerDelegate; diff --git a/packages/vidstack/src/core/api/player-events.ts b/packages/vidstack/src/core/api/player-events.ts index eed6ad6a0..fd8e8a139 100644 --- a/packages/vidstack/src/core/api/player-events.ts +++ b/packages/vidstack/src/core/api/player-events.ts @@ -2,6 +2,7 @@ import type { DOMEvent } from 'maverick.js/std'; import type { MediaPlayer } from '../../components'; import type { LoggerEvents } from '../../foundation/logger/events'; +import type { GoogleCastEvents } from '../../providers/google-cast/events'; import type { HLSProviderEvents } from '../../providers/hls/events'; import type { VideoPresentationEvents } from '../../providers/video/presentation/events'; import type { MediaEvents } from './media-events'; @@ -13,7 +14,8 @@ export interface MediaPlayerEvents MediaUserEvents, LoggerEvents, VideoPresentationEvents, - HLSProviderEvents { + HLSProviderEvents, + GoogleCastEvents { 'media-player-connect': MediaPlayerConnectEvent; /* @internal */ 'find-media-player': FindMediaPlayerEvent; diff --git a/packages/vidstack/src/core/api/player-props.ts b/packages/vidstack/src/core/api/player-props.ts index 8d943b383..81b75598c 100644 --- a/packages/vidstack/src/core/api/player-props.ts +++ b/packages/vidstack/src/core/api/player-props.ts @@ -1,5 +1,6 @@ import type { LogLevel } from '../../foundation/logger/log-level'; import type { ScreenOrientationLockType } from '../../foundation/orientation/types'; +import type { GoogleCastOptions } from '../../providers/google-cast/types'; import { MEDIA_KEY_SHORTCUTS } from '../keyboard/controller'; import type { MediaKeyShortcuts, MediaKeyTarget } from '../keyboard/types'; import type { MediaState } from './player-state'; @@ -14,6 +15,7 @@ export const mediaPlayerProps: MediaPlayerProps = { crossorigin: null, crossOrigin: null, fullscreenOrientation: 'landscape', + googleCast: {}, load: 'visible', posterLoad: 'visible', logLevel: __DEV__ ? 'warn' : 'silent', @@ -134,6 +136,12 @@ export interface MediaPlayerProps * the Screen Orientation API is available. */ fullscreenOrientation: ScreenOrientationLockType | undefined; + /** + * Google Cast options. + * + * @see {@link https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastOptions} + */ + googleCast: GoogleCastOptions; /** * Whether native HLS support is preferred over using `hls.js`. We recommend setting this to * `false` to ensure a consistent and configurable experience across browsers. In addition, our diff --git a/packages/vidstack/src/core/api/player-state.ts b/packages/vidstack/src/core/api/player-state.ts index 9f2b4eb6c..e30d11d2a 100644 --- a/packages/vidstack/src/core/api/player-state.ts +++ b/packages/vidstack/src/core/api/player-state.ts @@ -1,6 +1,7 @@ import { State, tick, type Store } from 'maverick.js'; import type { LogLevel } from '../../foundation/logger/log-level'; +import type { MediaProviderLoader } from '../../providers/types'; import { canOrientScreen } from '../../utils/support'; import type { VideoQuality } from '../quality/video-quality'; import { getTimeRangesEnd, getTimeRangesStart, TimeRange } from '../time-ranges'; @@ -13,6 +14,7 @@ import type { MediaStreamType, MediaType, MediaViewType, + RemotePlaybackInfo, RemotePlaybackType, } from './types'; @@ -124,6 +126,8 @@ export const mediaState = new State({ canGoogleCast: false, remotePlaybackState: 'disconnected', remotePlaybackType: 'none', + remotePlaybackLoader: null, + remotePlaybackInfo: null, get isAirPlayConnected() { return this.remotePlaybackType === 'airplay' && this.remotePlaybackState === 'connected'; }, @@ -174,7 +178,7 @@ export const mediaState = new State({ }, // ~~ internal props ~~ - autoplaying: false, + autoPlaying: false, providedTitle: '', inferredTitle: '', providedPoster: '', @@ -186,54 +190,40 @@ export const mediaState = new State({ liveSyncPosition: null, }); -const DO_NOT_RESET_ON_SRC_CHANGE = new Set([ - 'autoplay', - 'canFullscreen', - 'canLoad', - 'canLoadPoster', - 'canPictureInPicture', - 'canAirPlay', - 'canGoogleCast', - 'remotePlaybackState', - 'remotePlaybackType', - 'canSetVolume', - 'clipEndTime', - 'clipStartTime', - 'controls', - 'crossorigin', - 'crossOrigin', - 'fullscreen', - 'height', - 'inferredViewType', - 'lastKeyboardAction', - 'logLevel', - 'loop', - 'mediaHeight', - 'mediaType', - 'mediaWidth', - 'muted', - 'orientation', - 'pictureInPicture', - 'playsinline', - 'pointer', - 'preload', - 'providedPoster', - 'providedStreamType', - 'providedTitle', - 'providedViewType', - 'source', - 'sources', - 'textTrack', - 'textTracks', - 'volume', - 'width', +const RESET_ON_SRC_CHANGE = new Set([ + 'audioTrack', + 'audioTracks', + 'autoplayError', + 'autoPlaying', + 'autoQuality', + 'buffered', + 'canPlay', + 'ended', + 'error', + 'inferredPoster', + 'inferredStreamType', + 'inferredTitle', + 'intrinsicDuration', + 'liveSyncPosition', + 'paused', + 'playbackRate', + 'played', + 'playing', + 'qualities', + 'quality', + 'realCurrentTime', + 'seekable', + 'seeking', + 'started', + 'userBehindLiveEdge', + 'waiting', ]); /** * Resets all media state and leaves general player state intact (i.e., `autoplay`, `volume`, etc.). */ export function softResetMediaState($media: MediaStore) { - mediaState.reset($media, (prop) => !DO_NOT_RESET_ON_SRC_CHANGE.has(prop)); + mediaState.reset($media, (prop) => RESET_ON_SRC_CHANGE.has(prop)); tick(); } @@ -316,6 +306,14 @@ export interface MediaState { * The type of remote playback that is currently connecting or connected. */ remotePlaybackType: RemotePlaybackType; + /** + * An active remote playback loader such as the `GoogleCastLoader`. + */ + remotePlaybackLoader: MediaProviderLoader | null; + /** + * Information about the current remote playback. + */ + remotePlaybackInfo: RemotePlaybackInfo | null; /** * Whether AirPlay is connected. */ @@ -792,7 +790,7 @@ export interface MediaState { // !!! INTERNALS !!! /* @internal */ - autoplaying: boolean; + autoPlaying: boolean; /* @internal */ providedTitle: string; /* @internal */ diff --git a/packages/vidstack/src/core/api/types.ts b/packages/vidstack/src/core/api/types.ts index fe0192a1d..bc414a66d 100644 --- a/packages/vidstack/src/core/api/types.ts +++ b/packages/vidstack/src/core/api/types.ts @@ -18,6 +18,14 @@ export type MediaCrossOrigin = '' | 'anonymous' | 'use-credentials'; export type RemotePlaybackType = 'airplay' | 'google-cast' | 'none'; +export interface RemotePlaybackInfo { + deviceName?: string; + savedState?: { + paused?: boolean; + currentTime?: number; + }; +} + /** * Indicates the current view type which determines how the media will be presented. */ @@ -59,7 +67,7 @@ export type MediaErrorCode = 1 | 2 | 3 | 4; export interface MediaErrorDetail { message: string; - code: MediaErrorCode; + code?: MediaErrorCode; error?: Error; mediaError?: MediaError; } diff --git a/packages/vidstack/src/core/state/media-player-delegate.ts b/packages/vidstack/src/core/state/media-player-delegate.ts index 7a3a34749..3b4130e4a 100644 --- a/packages/vidstack/src/core/state/media-player-delegate.ts +++ b/packages/vidstack/src/core/state/media-player-delegate.ts @@ -63,7 +63,11 @@ export class MediaPlayerDelegate { const provider = peek(this._media.$provider), { storage } = this._media, { muted, volume, playsinline, clipStartTime } = this._media.$props, - startTime = storage.data.time ?? clipStartTime(); + { remotePlaybackInfo } = this._media.$state, + remotePlaybackTime = remotePlaybackInfo()?.savedState?.currentTime, + wasRemotePlaying = remotePlaybackInfo()?.savedState?.paused === false, + startTime = remotePlaybackTime ?? storage.data.time ?? clipStartTime(), + shouldAutoPlay = wasRemotePlaying || $state.autoplay(); if (provider) { provider.setVolume(storage.data.volume ?? peek(volume)); @@ -72,15 +76,17 @@ export class MediaPlayerDelegate { if (startTime > 0) provider.setCurrentTime(startTime); } - if ($state.canPlay() && $state.autoplay() && !$state.started()) { + if ($state.canPlay() && shouldAutoPlay && !$state.started()) { await this._attemptAutoplay(trigger); } + + remotePlaybackInfo.set(null); } private async _attemptAutoplay(trigger?: Event) { const { player, $state } = this._media; - $state.autoplaying.set(true); + $state.autoPlaying.set(true); const attemptEvent = new DOMEvent('autoplay-attempt', { trigger }); @@ -93,7 +99,7 @@ export class MediaPlayerDelegate { : ''; this._media.logger - ?.errorGroup('autoplay request failed') + ?.errorGroup('[vidstack] autoplay request failed') .labelledLog( 'Message', `Autoplay was requested but failed most likely due to browser autoplay policies.${muteMsg}`, diff --git a/packages/vidstack/src/core/state/media-request-manager.ts b/packages/vidstack/src/core/state/media-request-manager.ts index 76edf206f..1c99b02fe 100644 --- a/packages/vidstack/src/core/state/media-request-manager.ts +++ b/packages/vidstack/src/core/state/media-request-manager.ts @@ -8,8 +8,12 @@ import { import { ScreenOrientationController } from '../../foundation/orientation/controller'; import { Queue } from '../../foundation/queue/queue'; import { RequestQueue } from '../../foundation/queue/request-queue'; +import type { GoogleCastLoader } from '../../providers/google-cast/loader'; import type { MediaProviderAdapter } from '../../providers/types'; import { coerceToError } from '../../utils/error'; +import { canGoogleCastSrc } from '../../utils/mime'; +import { preconnect } from '../../utils/network'; +import { IS_CHROME, IS_IOS } from '../../utils/support'; import type { MediaContext } from '../api/media-context'; import type { MediaFullscreenChangeEvent } from '../api/media-events'; import * as RE from '../api/media-request-events'; @@ -57,9 +61,11 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR } this._attachLoadPlayListener(); + effect(this._watchProvider.bind(this)); effect(this._onControlsDelayChange.bind(this)); effect(this._onAirPlaySupportChange.bind(this)); + effect(this._onGoogleCastSupportChange.bind(this)); effect(this._onFullscreenSupportChange.bind(this)); effect(this._onPiPSupportChange.bind(this)); } @@ -117,7 +123,7 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR async _play(trigger?: Event) { if (__SERVER__) return; - const { canPlay, paused, autoplaying } = this.$state; + const { canPlay, paused, autoPlaying } = this.$state; if (this._handleLoadPlayStrategy(trigger)) return; @@ -137,7 +143,7 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR trigger, }); - errorEvent.autoplay = autoplaying(); + errorEvent.autoplay = autoPlaying(); this._stateMgr._handle(errorEvent); throw error; @@ -316,6 +322,12 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR canAirPlay.set(supported); } + private _onGoogleCastSupportChange() { + const { canGoogleCast, source } = this.$state, + supported = IS_CHROME && !IS_IOS && canGoogleCastSrc(source()); + canGoogleCast.set(supported); + } + private _onFullscreenSupportChange() { const { canFullscreen } = this.$state, supported = this._fullscreen.supported || !!this._$provider()?.fullscreen?.supported; @@ -340,7 +352,7 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR try { const adapter = this._$provider()?.airPlay; - if (!adapter) { + if (!adapter?.supported) { throw Error(__DEV__ ? 'AirPlay adapter not available on provider.' : 'No AirPlay adapter.'); } @@ -348,7 +360,7 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR this._request._queue._enqueue('media-airplay-request', trigger); } - return await adapter.request(); + return await adapter.prompt(); } catch (error) { this._request._queue._delete('media-airplay-request'); @@ -361,7 +373,59 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR } async ['media-google-cast-request'](event: RE.MediaGoogleCastRequestEvent) { - // TODO: Implement google cast. + try { + await this._requestGoogleCast(event); + } catch (error) { + // no-op + } + } + + protected _googleCastLoader?: GoogleCastLoader; + async _requestGoogleCast(trigger?: Event) { + try { + const { canGoogleCast } = this.$state; + + if (!peek(canGoogleCast)) { + throw new Error( + __DEV__ ? 'Google Cast not available on this platform.' : 'Cast not available.', + ); + } + + preconnect('https://www.gstatic.com'); + + if (!this._googleCastLoader) { + const $module = await import('../../providers/google-cast/loader'); + this._googleCastLoader = new $module.GoogleCastLoader(); + } + + await this._googleCastLoader.prompt(this._media); + + if (trigger) { + this._request._queue._enqueue('media-google-cast-request', trigger); + } + + const isConnecting = peek(this.$state.remotePlaybackState) !== 'disconnected'; + + if (isConnecting) { + this.$state.remotePlaybackInfo.set((info) => ({ + ...info, + savedState: { + paused: peek(this.$state.paused), + currentTime: peek(this.$state.currentTime), + }, + })); + } + + this.$state.remotePlaybackLoader.set(isConnecting ? this._googleCastLoader : null); + } catch (error) { + this._request._queue._delete('media-google-cast-request'); + + if (__DEV__ && (error as Error).message !== '"cancel"') { + this._logError('google cast request failed', error, trigger); + } + + throw error; + } } ['media-audio-track-change-request'](event: RE.MediaAudioTrackChangeRequestEvent) { @@ -703,7 +767,7 @@ export class MediaRequestManager extends MediaPlayerController implements MediaR private _logError(title: string, error: unknown, request?: Event) { if (!__DEV__) return; this._media.logger - ?.errorGroup(`[vidstack]: ${title}`) + ?.errorGroup(`[vidstack] ${title}`) .labelledLog('Error', error) .labelledLog('Media Context', { ...this._media }) .labelledLog('Trigger Event', request) diff --git a/packages/vidstack/src/core/state/media-state-manager.ts b/packages/vidstack/src/core/state/media-state-manager.ts index 49b26cb67..72c3a97b5 100644 --- a/packages/vidstack/src/core/state/media-state-manager.ts +++ b/packages/vidstack/src/core/state/media-state-manager.ts @@ -1,9 +1,10 @@ import debounce from 'just-debounce-it'; import throttle from 'just-throttle'; -import { onDispose, peek } from 'maverick.js'; +import { effect, onDispose, peek } from 'maverick.js'; import { DOMEvent, listenEvent } from 'maverick.js/std'; import { ListSymbol } from '../../foundation/list/symbols'; +import { canChangeVolume } from '../../utils/support'; import type { MediaContext } from '../api/media-context'; import * as ME from '../api/media-events'; import { MediaPlayerController } from '../api/player-controller'; @@ -53,6 +54,7 @@ export class MediaStateManager extends MediaPlayerController { } protected override onConnect(el: HTMLElement) { + effect(this._watchCanSetVolume.bind(this)); this._addTextTrackListeners(); this._addQualityListeners(); this._addAudioTrackListeners(); @@ -74,12 +76,10 @@ export class MediaStateManager extends MediaPlayerController { private _resumePlaybackOnConnect() { if (!this._isPlayingOnDisconnect) return; - if (this._media.$provider()?.paused) { - requestAnimationFrame(() => { - if (!this.scope) return; - this._media.remote.play(new DOMEvent('dom-connect')); - }); - } + requestAnimationFrame(() => { + if (!this.scope) return; + this._media.remote.play(new DOMEvent('dom-connect')); + }); this._isPlayingOnDisconnect = false; } @@ -207,6 +207,18 @@ export class MediaStateManager extends MediaPlayerController { this.$state.canSetQuality.set(!this._media.qualities.readonly); } + protected _watchCanSetVolume() { + const { canSetVolume, isGoogleCastConnected } = this.$state; + + if (isGoogleCastConnected()) { + // The provider will set this value accordingly. + canSetVolume.set(false); + return; + } + + canChangeVolume().then(canSetVolume.set); + } + ['provider-change'](event: ME.MediaProviderChangeEvent) { const prevProvider = this._media.$provider(), newProvider = event.detail; @@ -422,7 +434,7 @@ export class MediaStateManager extends MediaPlayerController { } ['play'](event: ME.MediaPlayEvent) { - const { paused, autoplayError, ended, autoplaying, playsinline, pointer, muted, viewType } = + const { paused, autoplayError, ended, autoPlaying, playsinline, pointer, muted, viewType } = this.$state; this._resetPlaybackIfNeeded(); @@ -432,7 +444,7 @@ export class MediaStateManager extends MediaPlayerController { return; } - event.autoplay = autoplaying(); + event.autoplay = autoPlaying(); const waitingEvent = this._trackedEvents.get('waiting'); if (waitingEvent) event.triggers.add(waitingEvent); @@ -451,7 +463,7 @@ export class MediaStateManager extends MediaPlayerController { }), ); - autoplaying.set(false); + autoPlaying.set(false); } if (ended() || this._request._replaying) { @@ -493,7 +505,7 @@ export class MediaStateManager extends MediaPlayerController { } ['play-fail'](event: ME.MediaPlayFailEvent) { - const { muted, autoplaying } = this.$state; + const { muted, autoPlaying } = this.$state; const playEvent = this._trackedEvents.get('play'); if (playEvent) event.triggers.add(playEvent); @@ -518,7 +530,7 @@ export class MediaStateManager extends MediaPlayerController { }), ); - autoplaying.set(false); + autoPlaying.set(false); } } diff --git a/packages/vidstack/src/core/state/remote-control.ts b/packages/vidstack/src/core/state/remote-control.ts index 47531cf4e..6b688c7e0 100644 --- a/packages/vidstack/src/core/state/remote-control.ts +++ b/packages/vidstack/src/core/state/remote-control.ts @@ -96,6 +96,15 @@ export class MediaRemoteControl { this._dispatchRequest('media-airplay-request', trigger); } + /** + * Dispatch a request to connect Google Cast. + * + * @see {@link https://developers.google.com/cast/docs/overview} + */ + requestGoogleCast(trigger?: Event) { + this._dispatchRequest('media-google-cast-request', trigger); + } + /** * Dispatch a request to begin/resume media playback. */ diff --git a/packages/vidstack/src/core/tracks/text/text-track.ts b/packages/vidstack/src/core/tracks/text/text-track.ts index 6c7cf78c5..56767668e 100644 --- a/packages/vidstack/src/core/tracks/text/text-track.ts +++ b/packages/vidstack/src/core/tracks/text/text-track.ts @@ -130,7 +130,7 @@ export class TextTrack extends EventsTarget { } else if (!init.src) this[TextTrackSymbol._readyState] = 2; if (__DEV__ && isTrackCaptionKind(this) && !this.label) { - throw Error(`[vidstack]: captions text track created without label: \`${this.src}\``); + throw Error(`[vidstack] captions text track created without label: \`${this.src}\``); } } diff --git a/packages/vidstack/src/elements/bundles/player-ui.ts b/packages/vidstack/src/elements/bundles/player-ui.ts index e6e535231..3e521f1f5 100644 --- a/packages/vidstack/src/elements/bundles/player-ui.ts +++ b/packages/vidstack/src/elements/bundles/player-ui.ts @@ -3,6 +3,7 @@ import { defineCustomElement } from 'maverick.js/element'; import { MediaAirPlayButtonElement } from '../define/buttons/airplay-button-element'; import { MediaCaptionButtonElement } from '../define/buttons/caption-button-element'; import { MediaFullscreenButtonElement } from '../define/buttons/fullscreen-button-element'; +import { MediaGoogleCastButtonElement } from '../define/buttons/google-cast-button-element'; import { MediaLiveButtonElement } from '../define/buttons/live-button-element'; import { MediaMuteButtonElement } from '../define/buttons/mute-button-element'; import { MediaPIPButtonElement } from '../define/buttons/pip-button-element'; @@ -56,6 +57,7 @@ defineCustomElement(MediaPlayButtonElement); defineCustomElement(MediaSeekButtonElement); defineCustomElement(MediaToggleButtonElement); defineCustomElement(MediaAirPlayButtonElement); +defineCustomElement(MediaGoogleCastButtonElement); // Sliders defineCustomElement(MediaSliderElement); defineCustomElement(MediaVolumeSliderElement); diff --git a/packages/vidstack/src/elements/define/buttons/google-cast-button-element.ts b/packages/vidstack/src/elements/define/buttons/google-cast-button-element.ts new file mode 100644 index 000000000..afb5b8bbe --- /dev/null +++ b/packages/vidstack/src/elements/define/buttons/google-cast-button-element.ts @@ -0,0 +1,21 @@ +import { Host } from 'maverick.js/element'; + +import { GoogleCastButton } from '../../../components'; + +/** + * @example + * ```html + * + * + * + * ``` + */ +export class MediaGoogleCastButtonElement extends Host(HTMLElement, GoogleCastButton) { + static tagName = 'media-google-cast-button'; +} + +declare global { + interface HTMLElementTagNameMap { + 'media-google-cast-button': MediaGoogleCastButtonElement; + } +} diff --git a/packages/vidstack/src/elements/define/layouts/default/icons.ts b/packages/vidstack/src/elements/define/layouts/default/icons.ts index 30ce5ca83..fc2ebeb29 100644 --- a/packages/vidstack/src/elements/define/layouts/default/icons.ts +++ b/packages/vidstack/src/elements/define/layouts/default/icons.ts @@ -2,6 +2,7 @@ import airplay from 'media-icons/dist/icons/airplay.js'; import menuArrowLeft from 'media-icons/dist/icons/arrow-left.js'; import chapters from 'media-icons/dist/icons/chapters.js'; import menuArrowRight from 'media-icons/dist/icons/chevron-right.js'; +import googleCast from 'media-icons/dist/icons/chromecast.js'; import ccOn from 'media-icons/dist/icons/closed-captions-on.js'; import ccOff from 'media-icons/dist/icons/closed-captions.js'; import menuCaptions from 'media-icons/dist/icons/closed-captions.js'; @@ -28,6 +29,7 @@ export const icons = { pause, replay, mute, + 'google-cast': googleCast, 'volume-low': volumeLow, 'volume-high': volumeHigh, 'cc-on': ccOn, diff --git a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts index f16c0a763..f5c2a9726 100644 --- a/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/shared-layout.ts @@ -42,6 +42,28 @@ export function DefaultAirPlayButton({ tooltip }: { tooltip: TooltipPlacement }) `; } +export function DefaultGoogleCastButton({ tooltip }: { tooltip: TooltipPlacement }) { + const { translations } = useDefaultLayoutContext(), + { remotePlaybackState } = useMediaContext().$state, + $label = $computed(() => { + const googleCastText = getDefaultLayoutLang(translations, 'Google Cast'), + stateText = uppercaseFirstChar(remotePlaybackState()) as Capitalize; + return `${googleCastText} ${stateText}`; + }); + return html` + + + + + + + + ${$i18n(translations, 'Google Cast')} + + + `; +} + export function DefaultPlayButton({ tooltip }: { tooltip: TooltipPlacement }) { const { translations } = useDefaultLayoutContext(), { paused } = useMediaContext().$state, diff --git a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts index 9d61ed4c9..d7a0aa309 100644 --- a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts @@ -10,6 +10,7 @@ import { DefaultCaptionButton, DefaultChaptersMenu, DefaultFullscreenButton, + DefaultGoogleCastButton, DefaultMuteButton, DefaultPIPButton, DefaultPlayButton, @@ -40,7 +41,7 @@ export function DefaultVideoLayoutLarge() { ${$computed(DefaultTimeInfo)} ${DefaultCaptionButton({ tooltip: 'top' })}${$computed(DefaultBottomMenuGroup)} - ${DefaultAirPlayButton({ tooltip: 'top' })} + ${DefaultAirPlayButton({ tooltip: 'top' })} ${DefaultGoogleCastButton({ tooltip: 'top' })} ${DefaultPIPButton()}${DefaultFullscreenButton({ tooltip: 'top end' })} @@ -77,6 +78,7 @@ export function DefaultVideoLayoutSmall() { ${DefaultAirPlayButton({ tooltip: 'top start' })} + ${DefaultGoogleCastButton({ tooltip: 'top start' })}
${DefaultCaptionButton({ tooltip: 'bottom' })} ${DefaultVideoMenus()}${DefaultMuteButton({ tooltip: 'bottom end' })} diff --git a/packages/vidstack/src/elements/define/provider-element.ts b/packages/vidstack/src/elements/define/provider-element.ts index 8a473244c..b122e9328 100644 --- a/packages/vidstack/src/elements/define/provider-element.ts +++ b/packages/vidstack/src/elements/define/provider-element.ts @@ -37,16 +37,19 @@ export class MediaProviderElement extends Host(HTMLElement, MediaProvider) { protected onConnect(): void { effect(() => { const loader = this.$state.loader(), - isYouTubeEmbed = loader?.canPlay({ src: '', type: 'video/youtube' }), - isVimeoEmbed = loader?.canPlay({ src: '', type: 'video/vimeo' }), - isEmbed = isYouTubeEmbed || isVimeoEmbed; + isYouTubeEmbed = loader?.name === 'youtube', + isVimeoEmbed = loader?.name === 'vimeo', + isEmbed = isYouTubeEmbed || isVimeoEmbed, + isGoogleCast = loader?.name === 'google-cast'; const target = loader - ? isEmbed - ? this._createIFrame() - : loader.mediaType() === 'audio' - ? this._createAudio() - : this._createVideo() + ? isGoogleCast + ? document.createElement('div') + : isEmbed + ? this._createIFrame() + : loader.mediaType() === 'audio' + ? this._createAudio() + : this._createVideo() : null; if (this._target !== target) { @@ -78,6 +81,7 @@ export class MediaProviderElement extends Host(HTMLElement, MediaProvider) { if (isYouTubeEmbed) target?.classList.add('vds-youtube'); else if (isVimeoEmbed) target?.classList.add('vds-vimeo'); + else if (isGoogleCast) target?.classList.add('vds-google-cast'); if (!isEmbed) { this._blocker?.remove(); diff --git a/packages/vidstack/src/elements/index.ts b/packages/vidstack/src/elements/index.ts index 7df6b0122..e1c810930 100644 --- a/packages/vidstack/src/elements/index.ts +++ b/packages/vidstack/src/elements/index.ts @@ -21,14 +21,15 @@ export { MediaVideoLayoutElement } from './define/layouts/default/video-layout-e // Buttons export { MediaAirPlayButtonElement } from './define/buttons/airplay-button-element'; -export { MediaPlayButtonElement } from './define/buttons/play-button-element'; -export { MediaMuteButtonElement } from './define/buttons/mute-button-element'; export { MediaCaptionButtonElement } from './define/buttons/caption-button-element'; export { MediaFullscreenButtonElement } from './define/buttons/fullscreen-button-element'; +export { MediaGoogleCastButtonElement } from './define/buttons/google-cast-button-element'; +export { MediaLiveButtonElement } from './define/buttons/live-button-element'; +export { MediaMuteButtonElement } from './define/buttons/mute-button-element'; export { MediaPIPButtonElement } from './define/buttons/pip-button-element'; +export { MediaPlayButtonElement } from './define/buttons/play-button-element'; export { MediaSeekButtonElement } from './define/buttons/seek-button-element'; export { MediaToggleButtonElement } from './define/buttons/toggle-button-element'; -export { MediaLiveButtonElement } from './define/buttons/live-button-element'; // Tooltips export { MediaTooltipElement } from './define/tooltips/tooltip-element'; diff --git a/packages/vidstack/src/elements/lit/directives/signal.ts b/packages/vidstack/src/elements/lit/directives/signal.ts index af434452e..f3f7c3efa 100644 --- a/packages/vidstack/src/elements/lit/directives/signal.ts +++ b/packages/vidstack/src/elements/lit/directives/signal.ts @@ -54,7 +54,7 @@ class SignalDirective extends AsyncDirective { ].join('\n'); console.warn( - `[vidstack]: Failed to render most likely due to a hydration issue with your framework.` + + `[vidstack] Failed to render most likely due to a hydration issue with your framework.` + ` Dynamically importing the player should resolve the issue.` + `\n\nSvelte Example:\n\n${svelteDynamicImportExample}`, ); diff --git a/packages/vidstack/src/globals.d.ts b/packages/vidstack/src/globals.d.ts index 033435821..313cdf013 100644 --- a/packages/vidstack/src/globals.d.ts +++ b/packages/vidstack/src/globals.d.ts @@ -1,4 +1,5 @@ /// +/// declare global { const __DEV__: boolean; diff --git a/packages/vidstack/src/index.ts b/packages/vidstack/src/index.ts index 46f46bba7..132d0aed1 100644 --- a/packages/vidstack/src/index.ts +++ b/packages/vidstack/src/index.ts @@ -1,5 +1,5 @@ if (__DEV__) { - console.warn('[vidstack]: dev mode!'); + console.warn('[vidstack] dev mode!'); } // Foundation diff --git a/packages/vidstack/src/providers/audio/loader.ts b/packages/vidstack/src/providers/audio/loader.ts index a78b4283e..5432c3ff2 100644 --- a/packages/vidstack/src/providers/audio/loader.ts +++ b/packages/vidstack/src/providers/audio/loader.ts @@ -1,19 +1,15 @@ -import { isString } from 'maverick.js/std'; - import type { MediaSrc, MediaType } from '../../core'; -import { AUDIO_EXTENSIONS, AUDIO_TYPES } from '../../utils/mime'; +import { isAudioSrc } from '../../utils/mime'; import type { MediaProviderLoader } from '../types'; import type { AudioProvider } from './provider'; export class AudioProviderLoader implements MediaProviderLoader { + readonly name = 'audio'; + target!: HTMLAudioElement; - canPlay({ src, type }: MediaSrc) { - return isString(src) - ? AUDIO_EXTENSIONS.test(src) || - AUDIO_TYPES.has(type) || - (src.startsWith('blob:') && type === 'audio/object') - : type === 'audio/object'; + canPlay(src: MediaSrc) { + return isAudioSrc(src); } mediaType(): MediaType { diff --git a/packages/vidstack/src/providers/audio/provider.ts b/packages/vidstack/src/providers/audio/provider.ts index 8d746d1b2..eef68a400 100644 --- a/packages/vidstack/src/providers/audio/provider.ts +++ b/packages/vidstack/src/providers/audio/provider.ts @@ -1,5 +1,5 @@ import { HTMLMediaProvider } from '../html/provider'; -import type { MediaProviderAdapter, MediaSetupContext } from '../types'; +import type { MediaProviderAdapter } from '../types'; /** * The audio provider adapts the `