diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts index 63f6adebe..e5da6c9f9 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts @@ -6,6 +6,7 @@ import { PlayerControlsComponent, WEB_PLAYER_SHARED_CONTROLS, } from '../player-controls'; +import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers'; import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component'; const artPlayerInstances: MockArtplayer[] = []; @@ -125,6 +126,7 @@ describe('ArtPlayerComponent with shared controls', () => { expect(options).toEqual( expect.objectContaining({ controls: [], + pip: false, autoPlayback: false, autoSize: false, autoMini: false, @@ -241,6 +243,40 @@ describe('ArtPlayerComponent with shared controls', () => { expect(artPlayerInstances[0].destroy).toHaveBeenCalledTimes(1); }); + it('exits owned PiP when a channel change rebuilds ArtPlayer', () => { + const environment = new PictureInPictureTestEnvironment(); + try { + createComponent({ + url: 'https://example.test/movie-one.mp4', + name: 'Movie one', + }); + const firstVideo = artPlayerInstances[0].video; + environment.installVideo(firstVideo); + firstVideo.dispatchEvent(new Event('loadedmetadata')); + environment.setActive(firstVideo); + + fixture.componentRef.setInput('channel', { + url: 'https://example.test/movie-two.mp4', + name: 'Movie two', + }); + fixture.detectChanges(); + + const replacementVideo = artPlayerInstances[1].video; + environment.installVideo(replacementVideo); + replacementVideo.dispatchEvent(new Event('loadedmetadata')); + + expect(environment.exit).toHaveBeenCalledTimes(1); + expect( + component.controlsAdapter.state().pictureInPictureActive + ).toBe(false); + expect( + component.controlsAdapter.capabilities().pictureInPicture + ).toBe(true); + } finally { + environment.restore(); + } + }); + it('exits only the ArtPlayer shell fullscreen when interaction is disabled', () => { const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor( document, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts index 55670f612..54009faea 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts @@ -3,6 +3,7 @@ import { PlayerControlsComponent, WebVideoControlsAdapter, } from '../player-controls'; +import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component'; import { @@ -89,6 +90,27 @@ describe('HtmlVideoPlayerComponent shared controls host', () => { expect(controls?.playerSurface()).toBe(shell); }); + it('attaches PiP support to the actual video after metadata', () => { + const environment = new PictureInPictureTestEnvironment(); + try { + const { fixture, adapter } = renderSharedControls( + HtmlVideoPlayerComponent, + fixtures + ); + const video = fixture.debugElement.query(By.css('video')) + .nativeElement as HTMLVideoElement; + environment.installVideo(video, { readyState: 0 }); + + environment.setReadyState(video, 1); + video.dispatchEvent(new Event('loadedmetadata')); + + expect(adapter.capabilities().pictureInPicture).toBe(true); + expect(adapter.state().canPictureInPicture).toBe(true); + } finally { + environment.restore(); + } + }); + it('gates the shared surface and shortcuts with interaction availability', () => { const { fixture, controls } = renderSharedControls( HtmlVideoPlayerComponent, diff --git a/libs/ui/playback/src/lib/player-controls/picture-in-picture.spec-helpers.ts b/libs/ui/playback/src/lib/player-controls/picture-in-picture.spec-helpers.ts new file mode 100644 index 000000000..84b2d0e21 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/picture-in-picture.spec-helpers.ts @@ -0,0 +1,213 @@ +export interface Deferred { + readonly promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +export interface PictureInPictureVideoOptions { + readonly disablePictureInPicture?: boolean; + readonly readyState?: number; + readonly request?: PictureInPictureRequest | null; +} + +type PictureInPictureRequest = () => Promise; + +interface VideoPropertyDescriptors { + readonly disablePictureInPicture: PropertyDescriptor | undefined; + readonly readyState: PropertyDescriptor | undefined; + readonly requestPictureInPicture: PropertyDescriptor | undefined; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +export class PictureInPictureTestEnvironment { + private activeElement: Element | null = null; + private enabled = true; + private readonly enabledDescriptor: PropertyDescriptor | undefined; + private readonly elementDescriptor: PropertyDescriptor | undefined; + private readonly exitDescriptor: PropertyDescriptor | undefined; + private readonly videoDescriptors = new Map< + HTMLVideoElement, + VideoPropertyDescriptors + >(); + + readonly exit: jest.MockedFunction<() => Promise> = jest.fn( + async () => { + this.setActive(null); + } + ); + + constructor(private readonly ownerDocument: Document = document) { + this.enabledDescriptor = Object.getOwnPropertyDescriptor( + ownerDocument, + 'pictureInPictureEnabled' + ); + this.elementDescriptor = Object.getOwnPropertyDescriptor( + ownerDocument, + 'pictureInPictureElement' + ); + this.exitDescriptor = Object.getOwnPropertyDescriptor( + ownerDocument, + 'exitPictureInPicture' + ); + Object.defineProperty(ownerDocument, 'pictureInPictureEnabled', { + configurable: true, + get: () => this.enabled, + }); + Object.defineProperty(ownerDocument, 'pictureInPictureElement', { + configurable: true, + get: () => this.activeElement, + }); + this.setExitAvailable(true); + } + + installVideo( + video: HTMLVideoElement, + options: PictureInPictureVideoOptions = {} + ): jest.MockedFunction | null { + this.assertOwnerDocument(video); + this.captureVideoDescriptors(video); + this.setReadyState(video, options.readyState ?? 1); + this.setDisablePictureInPicture( + video, + options.disablePictureInPicture ?? false + ); + + if (options.request === null) { + Object.defineProperty(video, 'requestPictureInPicture', { + configurable: true, + value: undefined, + }); + return null; + } + + const request = + options.request ?? + (async () => { + this.setActive(video); + return {} as PictureInPictureWindow; + }); + const requestMock: jest.MockedFunction = + jest.fn(request); + Object.defineProperty(video, 'requestPictureInPicture', { + configurable: true, + value: requestMock, + }); + return requestMock; + } + + setActive(element: Element | null, emit = true): void { + const previous = this.activeElement; + this.activeElement = element; + if (!emit || previous === element) { + return; + } + previous?.dispatchEvent(new Event('leavepictureinpicture')); + element?.dispatchEvent(new Event('enterpictureinpicture')); + } + + setDisablePictureInPicture(video: HTMLVideoElement, value: boolean): void { + this.assertOwnerDocument(video); + this.captureVideoDescriptors(video); + Object.defineProperty(video, 'disablePictureInPicture', { + configurable: true, + writable: true, + value, + }); + } + + setEnabled(value: boolean): void { + this.enabled = value; + } + + setExitAvailable(value: boolean): void { + Object.defineProperty(this.ownerDocument, 'exitPictureInPicture', { + configurable: true, + value: value ? this.exit : undefined, + }); + } + + setReadyState(video: HTMLVideoElement, value: number): void { + this.assertOwnerDocument(video); + this.captureVideoDescriptors(video); + Object.defineProperty(video, 'readyState', { + configurable: true, + value, + }); + } + + restore(): void { + for (const [video, descriptors] of this.videoDescriptors) { + this.restoreProperty( + video, + 'disablePictureInPicture', + descriptors.disablePictureInPicture + ); + this.restoreProperty(video, 'readyState', descriptors.readyState); + this.restoreProperty( + video, + 'requestPictureInPicture', + descriptors.requestPictureInPicture + ); + } + this.videoDescriptors.clear(); + this.restoreProperty( + this.ownerDocument, + 'pictureInPictureEnabled', + this.enabledDescriptor + ); + this.restoreProperty( + this.ownerDocument, + 'pictureInPictureElement', + this.elementDescriptor + ); + this.restoreProperty( + this.ownerDocument, + 'exitPictureInPicture', + this.exitDescriptor + ); + } + + private assertOwnerDocument(video: HTMLVideoElement): void { + if (video.ownerDocument !== this.ownerDocument) { + throw new Error('PiP test video belongs to a different document'); + } + } + + private captureVideoDescriptors(video: HTMLVideoElement): void { + if (this.videoDescriptors.has(video)) { + return; + } + this.videoDescriptors.set(video, { + disablePictureInPicture: Object.getOwnPropertyDescriptor( + video, + 'disablePictureInPicture' + ), + readyState: Object.getOwnPropertyDescriptor(video, 'readyState'), + requestPictureInPicture: Object.getOwnPropertyDescriptor( + video, + 'requestPictureInPicture' + ), + }); + } + + private restoreProperty( + target: object, + property: string, + descriptor: PropertyDescriptor | undefined + ): void { + if (descriptor) { + Object.defineProperty(target, property, descriptor); + return; + } + delete (target as Record)[property]; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts index 17e9bd2f5..28195e49b 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts @@ -154,7 +154,9 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => { currentTime: 20, volume: 0.5, }); - adapter.attach(video); + const getDuration = jest.fn(() => 100); + adapter.attach(video, { getDuration }); + getDuration.mockClear(); adapter.commands.seekTo(NaN); adapter.commands.seekTo(Infinity); @@ -164,6 +166,7 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => { expect(video.currentTime).toBe(20); expect(video.volume).toBe(0.5); + expect(getDuration).not.toHaveBeenCalled(); }); it('swallows native media setter exceptions', () => { @@ -311,6 +314,40 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => { expect(adapter.state().durationSeconds).toBe(42); }); + it('preserves the options receiver when reading corrected duration', () => { + const options = { + correctedDuration: 84, + getDuration() { + return this.correctedDuration; + }, + }; + + adapter.attach( + createVideo({ duration: Infinity, seekableLength: 1 }), + options + ); + + expect(adapter.state().durationSeconds).toBe(84); + expect(adapter.state().isLive).toBe(false); + }); + + it('preserves the options receiver when reading live classification', () => { + const options = { + live: true, + isLive() { + return this.live; + }, + }; + + adapter.attach( + createVideo({ duration: 84, seekableLength: 1 }), + options + ); + + expect(adapter.state().isLive).toBe(true); + expect(adapter.capabilities().seek).toBe(false); + }); + it('normalizes a zero duration to null', () => { adapter.attach(createVideo({ duration: 0 }), { isLive: () => false }); expect(adapter.state().durationSeconds).toBeNull(); diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts new file mode 100644 index 000000000..4d00778bc --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts @@ -0,0 +1,373 @@ +import { + PictureInPictureTestEnvironment, + deferred, +} from './picture-in-picture.spec-helpers'; +import { WebVideoControlsAdapter } from './web-video-controls.adapter'; + +const PICTURE_IN_PICTURE_WINDOW = {} as PictureInPictureWindow; + +describe('WebVideoControlsAdapter Picture-in-Picture', () => { + let adapter: WebVideoControlsAdapter; + let environment: PictureInPictureTestEnvironment; + + beforeEach(() => { + adapter = new WebVideoControlsAdapter(); + environment = new PictureInPictureTestEnvironment(); + }); + + afterEach(() => { + adapter.detach(); + environment.restore(); + }); + + it('is unavailable when the document disables PiP', () => { + environment.setEnabled(false); + const video = installVideo(); + + adapter.attach(video); + + expectPiP(false, false); + }); + + it('is unavailable when the element disables PiP', () => { + const video = installVideo({ disablePictureInPicture: true }); + + adapter.attach(video); + + expectPiP(false, false); + }); + + it('is unavailable without requestPictureInPicture', () => { + const video = installVideo({ request: null }); + + adapter.attach(video); + + expectPiP(false, false); + }); + + it('is unavailable without exitPictureInPicture', () => { + environment.setExitAvailable(false); + const video = installVideo(); + + adapter.attach(video); + + expectPiP(false, false); + }); + + it('advertises support before metadata and enables entry after metadata', () => { + const video = installVideo({ readyState: 0 }); + adapter.attach(video); + + expectPiP(true, false); + + environment.setReadyState(video, 1); + video.dispatchEvent(new Event('loadedmetadata')); + + expectPiP(true, true); + }); + + it('requests entry synchronously and serializes the pending request', async () => { + const pending = deferred(); + const video = installVideo({ request: () => pending.promise }); + const request = video.requestPictureInPicture as jest.MockedFunction< + () => Promise + >; + adapter.attach(video); + + adapter.commands.togglePictureInPicture(); + adapter.commands.togglePictureInPicture(); + + expect(request).toHaveBeenCalledTimes(1); + expect(adapter.state().canPictureInPicture).toBe(false); + + pending.resolve(PICTURE_IN_PICTURE_WINDOW); + await flushPromises(); + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('uses browser events, not command optimism, as active authority', async () => { + const pending = deferred(); + const video = installVideo({ request: () => pending.promise }); + adapter.attach(video); + + adapter.commands.togglePictureInPicture(); + expect(adapter.state().pictureInPictureActive).toBe(false); + + environment.setActive(video); + expect(adapter.state().pictureInPictureActive).toBe(true); + environment.setActive(null); + expect(adapter.state().pictureInPictureActive).toBe(false); + + pending.resolve(PICTURE_IN_PICTURE_WINDOW); + await flushPromises(); + }); + + it('keeps owned exit available after entry support changes', () => { + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + environment.setEnabled(false); + environment.installVideo(video, { + disablePictureInPicture: true, + request: null, + }); + adapter.refresh(); + + expectPiP(true, true); + adapter.commands.togglePictureInPicture(); + + expect(environment.exit).toHaveBeenCalledTimes(1); + }); + + it('serializes a pending exit', async () => { + const pending = deferred(); + environment.exit.mockImplementation(() => pending.promise); + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + + adapter.commands.togglePictureInPicture(); + adapter.commands.togglePictureInPicture(); + + expect(environment.exit).toHaveBeenCalledTimes(1); + expect(adapter.state().canPictureInPicture).toBe(false); + + pending.resolve(undefined); + await flushPromises(); + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('fails closed when a PiP API property throws', () => { + const video = installVideo(); + Object.defineProperty(video, 'disablePictureInPicture', { + configurable: true, + get: () => { + throw new Error('PiP state unavailable'); + }, + }); + + adapter.attach(video); + + expectPiP(false, false); + expect(adapter.state().pictureInPictureActive).toBe(false); + }); + + it('contains a synchronous request throw and recovers the action', () => { + const video = installVideo({ + request: () => { + throw new Error('request failed'); + }, + }); + adapter.attach(video); + + expect(() => adapter.commands.togglePictureInPicture()).not.toThrow(); + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('contains a rejected request and recovers the action', async () => { + const video = installVideo({ + request: () => Promise.reject(new Error('request failed')), + }); + adapter.attach(video); + + adapter.commands.togglePictureInPicture(); + await flushPromises(); + + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('contains a synchronous exit throw and recovers the action', () => { + environment.exit.mockImplementation(() => { + throw new Error('exit failed'); + }); + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + + expect(() => adapter.commands.togglePictureInPicture()).not.toThrow(); + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('contains a rejected exit and recovers the action', async () => { + environment.exit.mockImplementation(() => + Promise.reject(new Error('exit failed')) + ); + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + + adapter.commands.togglePictureInPicture(); + await flushPromises(); + + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it.each(['loadstart', 'emptied', 'loadedmetadata', 'refresh'] as const)( + 'preserves owned PiP across same-target %s invalidation', + (eventName) => { + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + + if (eventName === 'refresh') { + adapter.refresh(); + } else { + video.dispatchEvent(new Event(eventName)); + } + + expect(environment.exit).not.toHaveBeenCalled(); + expect(adapter.state().pictureInPictureActive).toBe(true); + } + ); + + it('exits exactly the owned old target once on replacement', () => { + const owners: Element[] = []; + environment.exit.mockImplementation(async () => { + const owner = document.pictureInPictureElement; + if (owner) { + owners.push(owner); + } + environment.setActive(null); + }); + const oldVideo = installVideo(); + const replacement = installVideo(); + adapter.attach(oldVideo); + environment.setActive(oldVideo); + + adapter.attach(replacement); + + expect(environment.exit).toHaveBeenCalledTimes(1); + expect(owners).toEqual([oldVideo]); + expect(adapter.state().pictureInPictureActive).toBe(false); + }); + + it('never exits an unrelated PiP owner', () => { + const video = installVideo(); + adapter.attach(video); + environment.setActive(document.createElement('video')); + + adapter.detach(); + + expect(environment.exit).not.toHaveBeenCalled(); + }); + + it('makes repeated detach idempotent for owned PiP cleanup', () => { + const video = installVideo(); + adapter.attach(video); + environment.setActive(video); + + adapter.detach(); + adapter.detach(); + adapter.detach(); + + expect(environment.exit).toHaveBeenCalledTimes(1); + }); + + it('ignores stale old-target events after replacement', () => { + const oldVideo = installVideo(); + const replacement = installVideo(); + const refresh = jest.spyOn(adapter, 'refresh'); + adapter.attach(oldVideo); + adapter.attach(replacement); + refresh.mockClear(); + + oldVideo.dispatchEvent(new Event('enterpictureinpicture')); + + expect(refresh).not.toHaveBeenCalled(); + expect(adapter.state().pictureInPictureActive).toBe(false); + }); + + it('cleans up a stale successful enter only when the old target owns PiP', async () => { + const oldPending = deferred(); + const replacementPending = deferred(); + const owners: Element[] = []; + environment.exit.mockImplementation(async () => { + const owner = document.pictureInPictureElement; + if (owner) { + owners.push(owner); + } + environment.setActive(null); + }); + const oldVideo = installVideo({ request: () => oldPending.promise }); + const replacement = installVideo({ + request: () => replacementPending.promise, + }); + adapter.attach(oldVideo); + adapter.commands.togglePictureInPicture(); + adapter.attach(replacement); + adapter.commands.togglePictureInPicture(); + + environment.setActive(oldVideo); + oldPending.resolve(PICTURE_IN_PICTURE_WINDOW); + await flushPromises(); + + expect(environment.exit).toHaveBeenCalledTimes(1); + expect(owners).toEqual([oldVideo]); + expect(adapter.state().canPictureInPicture).toBe(false); + + replacementPending.resolve(PICTURE_IN_PICTURE_WINDOW); + await flushPromises(); + }); + + it('does not let a stale failure clear a newer pending operation', async () => { + const oldPending = deferred(); + const replacementPending = deferred(); + const oldVideo = installVideo({ request: () => oldPending.promise }); + const replacement = installVideo({ + request: () => replacementPending.promise, + }); + adapter.attach(oldVideo); + adapter.commands.togglePictureInPicture(); + adapter.attach(replacement); + adapter.commands.togglePictureInPicture(); + + oldPending.reject(new Error('stale request failed')); + await flushPromises(); + + expect(environment.exit).not.toHaveBeenCalled(); + expect(adapter.state().canPictureInPicture).toBe(false); + + replacementPending.resolve(PICTURE_IN_PICTURE_WINDOW); + await flushPromises(); + expect(adapter.state().canPictureInPicture).toBe(true); + }); + + it('reads PiP APIs from the video ownerDocument', () => { + environment.setEnabled(false); + const foreignDocument = + document.implementation.createHTMLDocument('PiP'); + const foreignEnvironment = new PictureInPictureTestEnvironment( + foreignDocument + ); + const video = foreignDocument.createElement('video'); + foreignEnvironment.installVideo(video); + + try { + adapter.attach(video); + expectPiP(true, true); + } finally { + adapter.detach(); + foreignEnvironment.restore(); + } + }); + + function installVideo( + options: Parameters< + PictureInPictureTestEnvironment['installVideo'] + >[1] = {} + ): HTMLVideoElement { + const video = document.createElement('video'); + environment.installVideo(video, options); + return video; + } + + function expectPiP(supported: boolean, canToggle: boolean): void { + expect(adapter.capabilities().pictureInPicture).toBe(supported); + expect(adapter.state().canPictureInPicture).toBe(canToggle); + } +}); + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts index dc8937592..3e30ffb48 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts @@ -10,73 +10,75 @@ import type { PlayerControlsCapabilities, PlayerControlsCommands, PlayerControlsState, - PlayerStatus, PlayerTrack, } from './player-controls.model'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; +import { + applyTrackSelection, + applyVideoCurrentTime, + applyVideoSpeed, + applyVideoVolume, + hasSeekableRange, + isVideoStalled, + mapVideoStatus, + normalizeVideoDuration, + readVideoDuration, + readVideoIsLive, + readVideoVolume, + toggleVideoPlay, + type WebVideoMetadataOptions, + WEB_VIDEO_EVENTS, +} from './web-video-controls.media-helpers'; /** * Engine-agnostic accessors a web engine injects so the adapter can read/select * audio & subtitle tracks without importing hls.js/videojs/artplayer. */ -export interface WebVideoControlsOptions { +export interface WebVideoControlsOptions extends WebVideoMetadataOptions { getAudioTracks?: () => PlayerTrack[]; setAudioTrack?: (id: number) => void | Promise; getSubtitleTracks?: () => PlayerTrack[]; setSubtitleTrack?: (id: number) => void | Promise; - isLive?: () => boolean; - /** - * Optional corrected duration source. Some engines (e.g. Video.js mpegts - * raw-TS VOD) report the real duration on the player, not on the `