From 62d0524b83bcf37157ef806dd141723ece2f5168 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Wed, 10 Jun 2026 10:32:43 +1000 Subject: [PATCH] feat(core): lock fullscreen orientation (#1656) --- apps/sandbox/app/shared/react/providers.ts | 4 +- packages/core/src/dom/feature.ts | 50 ++++- .../core/src/dom/presentation/orientation.ts | 76 +++++++ .../presentation/tests/orientation.test.ts | 150 ++++++++++++++ .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + .../dom/store/features/orientation-lock.ts | 52 +++++ .../store/features/tests/fullscreen.test.ts | 54 +++++ .../features/tests/orientation-lock.test.ts | 187 ++++++++++++++++++ packages/core/src/dom/tests/feature.test.ts | 41 ++++ .../src/player/tests/create-player.test-d.ts | 12 +- .../src/player/tests/create-player.test-d.tsx | 12 +- .../api-docs-builder/src/feature-handler.ts | 38 +++- .../api-docs-builder/src/tests/e2e.test.ts | 12 +- .../src/dom/store/features/feature.parts.ts | 2 + .../core/src/dom/store/features/index.ts | 1 + .../dom/store/features/orientation-lock.ts | 6 + .../docs/reference/feature-fullscreen.mdx | 21 +- .../reference/feature-orientation-lock.mdx | 61 ++++++ site/src/docs.config.ts | 1 + 20 files changed, 770 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/dom/presentation/orientation.ts create mode 100644 packages/core/src/dom/presentation/tests/orientation.test.ts create mode 100644 packages/core/src/dom/store/features/orientation-lock.ts create mode 100644 packages/core/src/dom/store/features/tests/orientation-lock.test.ts create mode 100644 packages/core/src/dom/tests/feature.test.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/orientation-lock.ts create mode 100644 site/src/content/docs/reference/feature-orientation-lock.mdx diff --git a/apps/sandbox/app/shared/react/providers.ts b/apps/sandbox/app/shared/react/providers.ts index b3cad8b8..0afc4a5f 100644 --- a/apps/sandbox/app/shared/react/providers.ts +++ b/apps/sandbox/app/shared/react/providers.ts @@ -1,4 +1,4 @@ -import { createPlayer } from '@videojs/react'; +import { createPlayer, features } from '@videojs/react'; import { audioFeatures } from '@videojs/react/audio'; import { backgroundFeatures } from '@videojs/react/background'; import { liveAudioFeatures } from '@videojs/react/live-audio'; @@ -6,7 +6,7 @@ import { liveVideoFeatures } from '@videojs/react/live-video'; import { videoFeatures } from '@videojs/react/video'; export const { Provider: VideoProvider } = createPlayer({ - features: videoFeatures, + features: [...videoFeatures, features.orientationLock], }); export const { Provider: AudioProvider } = createPlayer({ diff --git a/packages/core/src/dom/feature.ts b/packages/core/src/dom/feature.ts index 07a1a460..eb4c61ce 100644 --- a/packages/core/src/dom/feature.ts +++ b/packages/core/src/dom/feature.ts @@ -1,5 +1,49 @@ -import { defineSlice } from '@videojs/store'; +import { type AttachContext, defineSlice, type SliceConfig, type StateContext } from '@videojs/store'; +import { isUndefined } from '@videojs/utils/predicate'; -import type { PlayerTarget } from './media/types'; +import type { PlayerFeature, PlayerTarget } from './media/types'; -export const definePlayerFeature = defineSlice(); +export interface ConfigurablePlayerFeature extends PlayerFeature { + (config?: Config): PlayerFeature; +} + +export interface ConfigurablePlayerFeatureConfig + extends Omit, 'attach' | 'state'> { + state: (ctx: StateContext, config: Config) => State; + attach?: (ctx: AttachContext, config: Config) => void; +} + +const definePlayerSlice = defineSlice(); + +export function definePlayerFeature(config: SliceConfig): PlayerFeature; +export function definePlayerFeature( + config: ConfigurablePlayerFeatureConfig, + defaultConfig: Config +): ConfigurablePlayerFeature; +export function definePlayerFeature( + config: SliceConfig | ConfigurablePlayerFeatureConfig, + defaultConfig?: Config +): PlayerFeature | ConfigurablePlayerFeature { + if (arguments.length === 1) { + return definePlayerSlice(config as SliceConfig); + } + + const { name, state, attach } = config as ConfigurablePlayerFeatureConfig; + + const forConfig = (featureConfig: Config): PlayerFeature => + definePlayerSlice({ + ...(isUndefined(name) ? {} : { name }), + state: (ctx) => state(ctx, featureConfig), + ...(attach ? { attach: (ctx) => attach(ctx, featureConfig) } : {}), + }); + + const defaultFeature = forConfig(defaultConfig as Config); + const feature = ((featureConfig?: Config) => + isUndefined(featureConfig) ? defaultFeature : forConfig(featureConfig)) as ConfigurablePlayerFeature; + + feature.state = defaultFeature.state; + if (defaultFeature.attach) feature.attach = defaultFeature.attach; + if (!isUndefined(name)) Object.defineProperty(feature, 'name', { value: name }); + + return feature; +} diff --git a/packages/core/src/dom/presentation/orientation.ts b/packages/core/src/dom/presentation/orientation.ts new file mode 100644 index 00000000..e942db53 --- /dev/null +++ b/packages/core/src/dom/presentation/orientation.ts @@ -0,0 +1,76 @@ +import { isFunction } from '@videojs/utils/predicate'; + +export interface ScreenOrientationLock { + lock(): Promise; + unlock(): void; +} + +export type ScreenOrientationLockType = + | 'any' + | 'landscape' + | 'landscape-primary' + | 'landscape-secondary' + | 'natural' + | 'portrait' + | 'portrait-primary' + | 'portrait-secondary'; + +export interface ScreenOrientationLockConfig { + type?: ScreenOrientationLockType | undefined; +} + +interface ScreenOrientation { + lock?: ((type: ScreenOrientationLockType) => Promise) | undefined; + unlock?: (() => void) | undefined; +} + +export function createScreenOrientationLock({ + type = 'landscape', +}: ScreenOrientationLockConfig = {}): ScreenOrientationLock { + let locked = false; + let desired = false; + + const releaseOrientation = () => { + const orientation = globalThis.screen?.orientation as ScreenOrientation | undefined; + const unlock = orientation?.unlock; + + if (!isFunction(unlock)) return; + + try { + unlock.call(orientation); + } catch {} + }; + + return { + async lock() { + if (locked) return; + desired = true; + + const orientation = globalThis.screen?.orientation as ScreenOrientation | undefined; + const lock = orientation?.lock; + + if (!isFunction(lock)) return; + + try { + await lock.call(orientation, type); + } catch { + return; + } + + if (desired) { + locked = true; + } else { + releaseOrientation(); + } + }, + + unlock() { + desired = false; + + if (!locked) return; + + locked = false; + releaseOrientation(); + }, + }; +} diff --git a/packages/core/src/dom/presentation/tests/orientation.test.ts b/packages/core/src/dom/presentation/tests/orientation.test.ts new file mode 100644 index 00000000..93077004 --- /dev/null +++ b/packages/core/src/dom/presentation/tests/orientation.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createScreenOrientationLock } from '../orientation'; + +function stubOrientation(orientation: Partial) { + vi.stubGlobal('screen', { orientation }); +} + +describe('createScreenOrientationLock', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('locks landscape by default', async () => { + const orientation = { + lock: vi.fn(async () => {}), + unlock: vi.fn(), + }; + stubOrientation(orientation); + + const screenLock = createScreenOrientationLock(); + + await screenLock.lock(); + + expect(orientation.lock).toHaveBeenCalledWith('landscape'); + }); + + it('locks the configured orientation type', async () => { + const orientation = { + lock: vi.fn(async () => {}), + unlock: vi.fn(), + }; + stubOrientation(orientation); + + const screenLock = createScreenOrientationLock({ type: 'portrait' }); + + await screenLock.lock(); + + expect(orientation.lock).toHaveBeenCalledWith('portrait'); + }); + + it('unlocks only after a successful lock', async () => { + const orientation = { + lock: vi.fn(async () => {}), + unlock: vi.fn(), + }; + stubOrientation(orientation); + + const screenLock = createScreenOrientationLock(); + + screenLock.unlock(); + await screenLock.lock(); + await screenLock.lock(); + screenLock.unlock(); + screenLock.unlock(); + + expect(orientation.lock).toHaveBeenCalledTimes(1); + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('ignores missing browser APIs', async () => { + stubOrientation({}); + + const screenLock = createScreenOrientationLock(); + + await expect(screenLock.lock()).resolves.toBeUndefined(); + expect(() => screenLock.unlock()).not.toThrow(); + }); + + it('releases orientation when unlock runs before lock settles', async () => { + let resolveLock!: () => void; + const lockPromise = new Promise((resolve) => { + resolveLock = resolve; + }); + + const orientation = { + lock: vi.fn(() => lockPromise), + unlock: vi.fn(), + }; + stubOrientation(orientation); + + const screenLock = createScreenOrientationLock(); + const lockTask = screenLock.lock(); + + screenLock.unlock(); + resolveLock(); + await lockTask; + + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('does not release a newer active lock when an older lock settles', async () => { + let resolveFirst!: () => void; + let resolveSecond!: () => void; + + const firstLock = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondLock = new Promise((resolve) => { + resolveSecond = resolve; + }); + + const orientation = { + lock: vi.fn().mockReturnValueOnce(firstLock).mockReturnValueOnce(secondLock), + unlock: vi.fn(), + }; + stubOrientation(orientation); + + const screenLock = createScreenOrientationLock(); + const firstTask = screenLock.lock(); + + screenLock.unlock(); + const secondTask = screenLock.lock(); + + resolveSecond(); + await secondTask; + + resolveFirst(); + await firstTask; + + expect(orientation.unlock).not.toHaveBeenCalled(); + + screenLock.unlock(); + + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('ignores rejected locks and thrown unlocks', async () => { + const orientation = { + lock: vi.fn().mockRejectedValue(new Error('NotAllowedError')), + unlock: vi.fn(() => { + throw new Error('InvalidStateError'); + }), + }; + stubOrientation(orientation); + + const rejectedLock = createScreenOrientationLock(); + + await expect(rejectedLock.lock()).resolves.toBeUndefined(); + rejectedLock.unlock(); + + expect(orientation.unlock).not.toHaveBeenCalled(); + + const acceptedLock = createScreenOrientationLock(); + orientation.lock.mockResolvedValue(undefined); + + await acceptedLock.lock(); + + expect(() => acceptedLock.unlock()).not.toThrow(); + }); +}); diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 44c7b15b..222351ad 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -2,6 +2,7 @@ import { bufferFeature } from './buffer'; import { controlsFeature } from './controls'; import { fullscreenFeature } from './fullscreen'; import { liveFeature } from './live'; +import { orientationLockFeature } from './orientation-lock'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; @@ -20,6 +21,7 @@ export { controlsFeature as controls, fullscreenFeature as fullscreen, liveFeature as live, + orientationLockFeature as orientationLock, pipFeature as pip, playbackFeature as playback, playbackRateFeature as playbackRate, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 65768a9e..2025b08b 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -4,6 +4,7 @@ export * from './error'; export * as features from './feature.parts'; export * from './fullscreen'; export * from './live'; +export * from './orientation-lock'; export * from './pip'; export * from './playback'; export * from './playback-rate'; diff --git a/packages/core/src/dom/store/features/orientation-lock.ts b/packages/core/src/dom/store/features/orientation-lock.ts new file mode 100644 index 00000000..a0e0a69b --- /dev/null +++ b/packages/core/src/dom/store/features/orientation-lock.ts @@ -0,0 +1,52 @@ +import { listen } from '@videojs/utils/dom'; + +import { definePlayerFeature } from '../../feature'; +import { isFullscreen } from '../../presentation/fullscreen'; +import { createScreenOrientationLock, type ScreenOrientationLockType } from '../../presentation/orientation'; + +export interface OrientationLockFeatureConfig { + /** Screen orientation type to lock while fullscreen is active. */ + type?: ScreenOrientationLockType | undefined; +} + +interface WebKitPresentationMedia extends HTMLMediaElement { + webkitPresentationMode?: string; +} + +export const orientationLockFeature = definePlayerFeature( + { + name: 'orientationLock', + state: () => ({}), + + attach({ target, signal }, config: OrientationLockFeatureConfig) { + const { media, container } = target; + const orientationLock = createScreenOrientationLock({ type: config.type }); + + let wasFullscreen = false; + const sync = () => { + const fullscreen = isFullscreen(container, media); + + if (!wasFullscreen && fullscreen) { + void orientationLock.lock(); + } else if (wasFullscreen && !fullscreen) { + orientationLock.unlock(); + } + + wasFullscreen = fullscreen; + }; + + sync(); + + listen(document, 'fullscreenchange', sync, { signal }); + listen(document, 'webkitfullscreenchange', sync, { signal }); + + const video = media as WebKitPresentationMedia; + if ('webkitPresentationMode' in video) { + listen(media, 'webkitpresentationmodechanged', sync, { signal }); + } + + signal.addEventListener('abort', () => orientationLock.unlock(), { once: true }); + }, + }, + { type: 'landscape' } satisfies OrientationLockFeatureConfig +); diff --git a/packages/core/src/dom/store/features/tests/fullscreen.test.ts b/packages/core/src/dom/store/features/tests/fullscreen.test.ts index aad4bfee..b7ec48e7 100644 --- a/packages/core/src/dom/store/features/tests/fullscreen.test.ts +++ b/packages/core/src/dom/store/features/tests/fullscreen.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; import { HTMLVideoElementHost } from '../../../media/video-host'; import { createMockVideo } from '../../../tests/test-helpers'; +import { selectFullscreen } from '../../selectors'; import { fullscreenFeature } from '../fullscreen'; describe('fullscreenFeature', () => { @@ -24,9 +25,25 @@ describe('fullscreenFeature', () => { writable: true, configurable: true, }); + vi.unstubAllGlobals(); }); describe('attach', () => { + it('exposes the fullscreen slice name for selectors', () => { + expect(fullscreenFeature.name).toBe('fullscreen'); + expect(selectFullscreen.displayName).toBe('fullscreen'); + }); + + it('selects fullscreen state', () => { + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container }); + + expect(selectFullscreen(store.state)?.fullscreen).toBe(false); + }); + it('syncs initial state on attach', () => { const video = createMockVideo(); const container = document.createElement('div'); @@ -342,6 +359,42 @@ describe('fullscreenFeature', () => { }); describe('transitions', () => { + it('toggleFullscreen() exits PiP first when entering fullscreen', async () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: true, + writable: true, + configurable: true, + }); + + const originalExit = document.exitPictureInPicture; + document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined); + + const video = createMockVideo(); + const container = document.createElement('div'); + container.requestFullscreen = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(document, 'pictureInPictureElement', { + value: video, + writable: true, + configurable: true, + }); + + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container }); + + await store.toggleFullscreen(); + + expect(document.exitPictureInPicture).toHaveBeenCalled(); + expect(container.requestFullscreen).toHaveBeenCalled(); + + document.exitPictureInPicture = originalExit; + Object.defineProperty(document, 'pictureInPictureElement', { + value: null, + writable: true, + configurable: true, + }); + }); + it('requestFullscreen() exits PiP first if active', async () => { Object.defineProperty(document, 'fullscreenEnabled', { value: true, @@ -419,6 +472,7 @@ describe('fullscreenFeature with HTMLVideoElementHost', () => { writable: true, configurable: true, }); + vi.unstubAllGlobals(); }); describe('attach', () => { diff --git a/packages/core/src/dom/store/features/tests/orientation-lock.test.ts b/packages/core/src/dom/store/features/tests/orientation-lock.test.ts new file mode 100644 index 00000000..befe08f3 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/orientation-lock.test.ts @@ -0,0 +1,187 @@ +import { createStore } from '@videojs/store'; +import type { Mock } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PlayerTarget } from '../../../media/types'; +import { createMockVideo } from '../../../tests/test-helpers'; +import { orientationLockFeature } from '../orientation-lock'; + +type OrientationMock = { + lock: Mock; + unlock: Mock; +}; + +interface WebKitPresentationVideo extends HTMLVideoElement { + webkitPresentationMode?: string; +} + +function stubOrientation(): OrientationMock; +function stubOrientation>(orientation: Orientation): Orientation; +function stubOrientation>(orientation?: Orientation) { + const stub = + orientation ?? + ({ + lock: vi.fn(async () => {}), + unlock: vi.fn(), + } satisfies OrientationMock); + + vi.stubGlobal('screen', { orientation: stub }); + return stub; +} + +function setFullscreenElement(value: Element | null) { + Object.defineProperty(document, 'fullscreenElement', { + value, + writable: true, + configurable: true, + }); +} + +describe('orientationLockFeature', () => { + afterEach(() => { + setFullscreenElement(null); + vi.unstubAllGlobals(); + }); + + it('locks landscape by default when fullscreen starts', async () => { + const orientation = stubOrientation(); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalledWith('landscape'); + }); + }); + + it('locks the configured orientation type when fullscreen starts', async () => { + const orientation = stubOrientation(); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature({ type: 'portrait' })); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalledWith('portrait'); + }); + }); + + it('unlocks when fullscreen exits', async () => { + const orientation = stubOrientation(); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalled(); + }); + + await Promise.resolve(); + orientation.unlock.mockClear(); + + setFullscreenElement(null); + document.dispatchEvent(new Event('fullscreenchange')); + + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('unlocks on destroy while fullscreen is active', async () => { + const orientation = stubOrientation(); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalled(); + }); + + await Promise.resolve(); + orientation.unlock.mockClear(); + store.destroy(); + + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('handles webkit presentation mode changes', async () => { + const orientation = stubOrientation(); + const video = createMockVideo() as WebKitPresentationVideo; + video.webkitPresentationMode = 'inline'; + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + video.webkitPresentationMode = 'fullscreen'; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalledWith('landscape'); + }); + + await Promise.resolve(); + orientation.unlock.mockClear(); + + video.webkitPresentationMode = 'inline'; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + expect(orientation.unlock).toHaveBeenCalledTimes(1); + }); + + it('does nothing when screen orientation APIs are unsupported', () => { + const orientation = stubOrientation({}); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + setFullscreenElement(null); + document.dispatchEvent(new Event('fullscreenchange')); + + expect(orientation).toEqual({}); + }); + + it('does not unlock when the lock request rejects', async () => { + const orientation = stubOrientation({ + lock: vi.fn().mockRejectedValue(new Error('NotAllowedError')), + unlock: vi.fn(), + }); + const video = createMockVideo(); + const container = document.createElement('div'); + + const store = createStore()(orientationLockFeature); + store.attach({ media: video, container }); + + setFullscreenElement(container); + document.dispatchEvent(new Event('fullscreenchange')); + + await vi.waitFor(() => { + expect(orientation.lock).toHaveBeenCalled(); + }); + + setFullscreenElement(null); + document.dispatchEvent(new Event('fullscreenchange')); + + expect(orientation.unlock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/dom/tests/feature.test.ts b/packages/core/src/dom/tests/feature.test.ts new file mode 100644 index 00000000..00388e14 --- /dev/null +++ b/packages/core/src/dom/tests/feature.test.ts @@ -0,0 +1,41 @@ +import { createSelector, type StateContext } from '@videojs/store'; +import { describe, expect, it } from 'vitest'; +import { definePlayerFeature } from '../feature'; +import type { PlayerTarget } from '../media/types'; + +const stateContext = { + target: () => { + throw new Error('Target is not available in this test.'); + }, + signals: undefined as unknown as StateContext['signals'], + get: () => ({}), + set: () => {}, +} satisfies StateContext; + +describe('definePlayerFeature', () => { + it('defines a plain player feature', () => { + const feature = definePlayerFeature({ + name: 'plain', + state: () => ({ enabled: true }), + }); + + expect(feature.name).toBe('plain'); + expect(feature.state(stateContext).enabled).toBe(true); + }); + + it('defines a configurable player feature', () => { + const feature = definePlayerFeature( + { + name: 'configurable', + state: (_ctx, config: { enabled: boolean }) => ({ enabled: config.enabled }), + }, + { enabled: true } + ); + + expect(feature.name).toBe('configurable'); + expect(feature.state(stateContext).enabled).toBe(true); + expect(feature().state(stateContext).enabled).toBe(true); + expect(feature({ enabled: false }).state(stateContext).enabled).toBe(false); + expect(createSelector(feature).displayName).toBe('configurable'); + }); +}); diff --git a/packages/html/src/player/tests/create-player.test-d.ts b/packages/html/src/player/tests/create-player.test-d.ts index 27818b5a..1d162936 100644 --- a/packages/html/src/player/tests/create-player.test-d.ts +++ b/packages/html/src/player/tests/create-player.test-d.ts @@ -1,5 +1,5 @@ import type { AudioPlayerStore, PlayerStore, PlayerTarget, VideoPlayerStore } from '@videojs/core/dom'; -import { audioFeatures, backgroundFeatures, definePlayerFeature, videoFeatures } from '@videojs/core/dom'; +import { audioFeatures, backgroundFeatures, definePlayerFeature, features, videoFeatures } from '@videojs/core/dom'; import type { Slice } from '@videojs/store'; import { assertType, describe, it } from 'vitest'; @@ -44,6 +44,16 @@ describe('createPlayer', () => { assertType]>>>(result); }); + it('accepts the orientation lock feature alias with and without config', () => { + const configuredOrientationLock = features.orientationLock({ type: 'portrait' }); + + const defaultResult = createPlayer({ features: [features.orientationLock] }); + const configuredResult = createPlayer({ features: [configuredOrientationLock] }); + + assertType>>(defaultResult); + assertType>>(configuredResult); + }); + it('resolves extended video features to generic PlayerStore', () => { interface AnalyticsState { events: string[]; diff --git a/packages/react/src/player/tests/create-player.test-d.tsx b/packages/react/src/player/tests/create-player.test-d.tsx index 8da86650..1c907f1f 100644 --- a/packages/react/src/player/tests/create-player.test-d.tsx +++ b/packages/react/src/player/tests/create-player.test-d.tsx @@ -1,5 +1,5 @@ import type { AudioPlayerStore, PlayerStore, PlayerTarget, VideoPlayerStore } from '@videojs/core/dom'; -import { audioFeatures, definePlayerFeature, videoFeatures } from '@videojs/core/dom'; +import { audioFeatures, definePlayerFeature, features, videoFeatures } from '@videojs/core/dom'; import type { Slice } from '@videojs/store'; import { assertType, describe, it } from 'vitest'; @@ -38,6 +38,16 @@ describe('createPlayer', () => { assertType]>>>(result); }); + it('accepts the orientation lock feature alias with and without config', () => { + const configuredOrientationLock = features.orientationLock({ type: 'portrait' }); + + const defaultResult = createPlayer({ features: [features.orientationLock] }); + const configuredResult = createPlayer({ features: [configuredOrientationLock] }); + + assertType>>(defaultResult); + assertType>>(configuredResult); + }); + it('resolves extended video features to generic PlayerStore', () => { interface AnalyticsState { events: string[]; diff --git a/site/scripts/api-docs-builder/src/feature-handler.ts b/site/scripts/api-docs-builder/src/feature-handler.ts index 31142ef2..01a44942 100644 --- a/site/scripts/api-docs-builder/src/feature-handler.ts +++ b/site/scripts/api-docs-builder/src/feature-handler.ts @@ -12,6 +12,7 @@ * - Feature files: *.ts in the features directory (excluding index, presets, feature.parts) * - Feature exports: const matching *Feature (singular, not *Features) * - State type: explicit return type annotation on the state() arrow function + * - Silent features: state() returns an empty object * - State interfaces: exported from packages/core/src/core/media/state.ts */ import * as fs from 'node:fs'; @@ -25,7 +26,7 @@ const SKIP_FILES = new Set(['index.ts', 'presets.ts', 'feature.parts.ts']); interface FeatureSource { filePath: string; name: string; - stateTypeName: string; + stateTypeName?: string; } // ─── Discovery ──────────────────────────────────────────────────── @@ -54,6 +55,7 @@ function discoverFeatureSources(featuresDir: string): FeatureSource[] { let name: string | undefined; let stateTypeName: string | undefined; + let silent = false; for (const prop of arg.properties) { if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue; @@ -66,11 +68,13 @@ function discoverFeatureSources(featuresDir: string): FeatureSource[] { const fn = prop.initializer; if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.type && ts.isTypeReferenceNode(fn.type)) { stateTypeName = fn.type.typeName.getText(sourceFile); + } else if (isEmptyState(fn)) { + silent = true; } } } - if (name && stateTypeName) { + if (name && (stateTypeName || silent)) { sources.push({ filePath, name, stateTypeName }); } } @@ -80,6 +84,24 @@ function discoverFeatureSources(featuresDir: string): FeatureSource[] { return sources; } +function isEmptyState(node: ts.Expression): boolean { + if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return false; + if (ts.isBlock(node.body)) return false; + + const body = unwrapParentheses(node.body); + return ts.isObjectLiteralExpression(body) && body.properties.length === 0; +} + +function unwrapParentheses(node: ts.Expression): ts.Expression { + let expression = node; + + while (ts.isParenthesizedExpression(expression)) { + expression = expression.expression; + } + + return expression; +} + // ─── Type Formatting ────────────────────────────────────────────── function formatCheckerType(type: ts.Type, checker: ts.TypeChecker): string { @@ -194,6 +216,18 @@ export function generateFeatureReferences(monorepoRoot: string): FeatureResult[] const results: FeatureResult[] = []; for (const source of sources) { + if (!source.stateTypeName) { + const ref: FeatureReference = { + name: source.name, + slug: source.name, + state: {}, + actions: {}, + }; + + results.push({ name: source.name, slug: source.name, reference: ref }); + continue; + } + const interfaceDecl = interfaces.get(source.stateTypeName); if (!interfaceDecl) continue; diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index b8d6b03e..9f82b2fb 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -713,6 +713,7 @@ describe('Feature pipeline (end-to-end)', () => { describe('Discovery', () => { it('discovers features from the features index', () => { const names = results.map((r) => r.name); + expect(names).toContain('orientationLock'); expect(names).toContain('playback'); expect(names).toContain('volume'); }); @@ -729,7 +730,16 @@ describe('Feature pipeline (end-to-end)', () => { }); it('produces one result per feature', () => { - expect(results.length).toBe(2); + expect(results.length).toBe(3); + }); + }); + + describe('orientationLock (silent feature)', () => { + it('generates an empty reference for empty state', () => { + const ref = findFeature('orientationLock')!.reference; + + expect(ref.state).toEqual({}); + expect(ref.actions).toEqual({}); }); }); diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/feature.parts.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/feature.parts.ts index d7bd1695..aec56106 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/feature.parts.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/feature.parts.ts @@ -4,5 +4,7 @@ * Exercises: namespace re-export filtering — `export * as features from './feature.parts'` * in the index should NOT produce a feature entry named "features". */ + +export { orientationLockFeature as orientationLock } from './orientation-lock'; export { playbackFeature as playback } from './playback'; export { volumeFeature as volume } from './volume'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/index.ts index ad30d545..62608252 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/index.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/index.ts @@ -7,6 +7,7 @@ */ export * as features from './feature.parts'; +export * from './orientation-lock'; export * from './playback'; export * from './presets'; export * from './volume'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/orientation-lock.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/orientation-lock.ts new file mode 100644 index 00000000..0ea2e8cd --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/orientation-lock.ts @@ -0,0 +1,6 @@ +import { definePlayerFeature } from '../../feature'; + +export const orientationLockFeature = definePlayerFeature({ + name: 'orientationLock', + state: () => ({}), +}); diff --git a/site/src/content/docs/reference/feature-fullscreen.mdx b/site/src/content/docs/reference/feature-fullscreen.mdx index 568b11a2..386b5304 100644 --- a/site/src/content/docs/reference/feature-fullscreen.mdx +++ b/site/src/content/docs/reference/feature-fullscreen.mdx @@ -27,11 +27,11 @@ import { selectFullscreen, usePlayer } from '@videojs/react'; function FullscreenButton() { const fs = usePlayer(selectFullscreen); - if (!fs || fs.availability !== 'available') return null; + if (!fs || fs.fullscreenAvailability !== 'available') return null; return ( - ); } @@ -50,3 +50,18 @@ class FullscreenButton extends MediaElement { } ``` + +### Screen orientation + +Add `features.orientationLock` to lock screen orientation while fullscreen is active. Unsupported browsers and rejected lock requests are ignored, so iOS Safari continues to use its normal fullscreen behavior. + +By default, the feature locks to `landscape`. Pass a Screen Orientation API type to customize it: + +```ts +import { createPlayer, features } from '@videojs/react'; +import { videoFeatures } from '@videojs/react/video'; + +const Player = createPlayer({ + features: [...videoFeatures, features.orientationLock({ type: 'portrait' })], +}); +``` diff --git a/site/src/content/docs/reference/feature-orientation-lock.mdx b/site/src/content/docs/reference/feature-orientation-lock.mdx new file mode 100644 index 00000000..ce9b7099 --- /dev/null +++ b/site/src/content/docs/reference/feature-orientation-lock.mdx @@ -0,0 +1,61 @@ +--- +title: Orientation lock +description: Screen orientation locking while fullscreen is active +--- + +import FeatureReference from "@/components/docs/api-reference/FeatureReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; + +Locks screen orientation while fullscreen is active. Add it explicitly to a feature list; it is not included in the default video presets. + + + +### Usage + +By default, the feature locks to `landscape`. Pass a Screen Orientation API type to customize it. + + +```tsx title="player.tsx" +import { createPlayer, features } from '@videojs/react'; +import { videoFeatures } from '@videojs/react/video'; + +export const Player = createPlayer({ + features: [...videoFeatures, features.orientationLock], +}); +``` + + + +```ts title="player.ts" +import { createPlayer, features } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const player = createPlayer({ + features: [...videoFeatures, features.orientationLock], +}); +``` + + + +```tsx title="portrait-player.tsx" +import { createPlayer, features } from '@videojs/react'; +import { videoFeatures } from '@videojs/react/video'; + +export const Player = createPlayer({ + features: [...videoFeatures, features.orientationLock({ type: 'portrait' })], +}); +``` + + + +```ts title="portrait-player.ts" +import { createPlayer, features } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const player = createPlayer({ + features: [...videoFeatures, features.orientationLock({ type: 'portrait' })], +}); +``` + + +Unsupported browsers and rejected lock requests are ignored. diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 2881afd8..d0d9bce9 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -130,6 +130,7 @@ export const sidebar: Sidebar = [ { slug: 'reference/feature-error' }, { slug: 'reference/feature-fullscreen' }, { slug: 'reference/feature-live' }, + { slug: 'reference/feature-orientation-lock' }, { slug: 'reference/feature-pip', sidebarLabel: 'Picture-in-picture' }, { slug: 'reference/feature-playback' }, { slug: 'reference/feature-playback-rate' },