diff --git a/src/app/components/Account/AccountSignInModal/index.styles.tsx b/src/app/components/Account/AccountSignInModal/index.styles.tsx index 0706e888829..1ce2000a8d9 100644 --- a/src/app/components/Account/AccountSignInModal/index.styles.tsx +++ b/src/app/components/Account/AccountSignInModal/index.styles.tsx @@ -1,6 +1,7 @@ import { Theme, css } from '@emotion/react'; import pixelsToRem from '#app/utilities/pixelsToRem'; import { getEnvConfig } from '#app/lib/utilities/getEnvConfig'; +import { PLAYER_FULLSCREEN_CLASS } from '#lib/mediaPlayerFullscreen.const'; const { SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN, @@ -25,6 +26,10 @@ export default { justifyContent: 'center', overflowY: 'auto', overflowX: 'hidden', + + [`body.${PLAYER_FULLSCREEN_CLASS} &`]: { + zIndex: -1, + }, }), backdrop: css({ diff --git a/src/app/components/Curation/index.tsx b/src/app/components/Curation/index.tsx index 7f7b9e7cbbc..16839a4cd80 100644 --- a/src/app/components/Curation/index.tsx +++ b/src/app/components/Curation/index.tsx @@ -52,20 +52,6 @@ const getGridComponent = (componentName: string | null) => { } }; -const enterFakeScreenCallback = () => { - const consentBanner = document.getElementById('consent-banner'); - if (consentBanner) { - consentBanner.style.zIndex = '-1'; - } -}; - -const exitFakeScreenCallback = () => { - const consentBanner = document.getElementById('consent-banner'); - if (consentBanner) { - consentBanner.style.zIndex = '2147483647'; - } -}; - interface CurationProps extends Curation { // keep this local so we do not change the shared bff curation data shape experimentProps?: ComponentExperimentProps; @@ -238,13 +224,7 @@ export default ({ aria-labelledby="bbcMediaPlayer0" data-testid={mediaCollectionId} > - + ) : null; } diff --git a/src/app/components/MediaLoader/index.styles.ts b/src/app/components/MediaLoader/index.styles.ts index 0399f4dd11f..19bb7899a41 100644 --- a/src/app/components/MediaLoader/index.styles.ts +++ b/src/app/components/MediaLoader/index.styles.ts @@ -1,6 +1,54 @@ import pixelsToRem from '#app/utilities/pixelsToRem'; +import { PLAYER_FULLSCREEN_CLASS } from '#lib/mediaPlayerFullscreen.const'; import { css, Theme } from '@emotion/react'; +export { PLAYER_FULLSCREEN_CLASS }; +export const FAKE_FULLSCREEN_LAYER_CLASS = 'simorgh-fake-fullscreen-layer'; +export const FAKE_FULLSCREEN_ACTIVE_CLASS = 'simorgh-player-fullscreen-active'; +export const ACTIVE_FULLSCREEN_LOADER_STATE = 'active-fake-fullscreen'; + +export const fakeFullscreenStyles = ` + html.${PLAYER_FULLSCREEN_CLASS} { + overflow: hidden; + } + + body.${PLAYER_FULLSCREEN_CLASS} { + overflow: auto; + } + + .${FAKE_FULLSCREEN_LAYER_CLASS} { + display: none; + background: #000; + position: fixed; + inset: 0; + pointer-events: none; + height: 100lvh; + width: 100vw; + outline: 1000px solid #000; + z-index: 2147483646; + } + + .${FAKE_FULLSCREEN_LAYER_CLASS}.${FAKE_FULLSCREEN_ACTIVE_CLASS} { + display: block; + } + + [data-simorgh-media-loader="${ACTIVE_FULLSCREEN_LOADER_STATE}"] { + position: fixed !important; + inset: 0 !important; + width: 100vw !important; + max-width: none !important; + height: 100lvh !important; + margin: 0 !important; + aspect-ratio: auto !important; + background: #000 !important; + z-index: 2147483647 !important; + } + + [data-simorgh-media-loader="${ACTIVE_FULLSCREEN_LOADER_STATE}"] .media-player { + height: 100% !important; + } +`; + const commonMarginSpacing = ({ mq, spacings }: Theme) => css({ marginInline: 0, @@ -52,6 +100,13 @@ export default { !isEmbedded && commonMarginSpacing, ], + mediaPlayerWrapper: () => + css({ + flex: 1, + minHeight: 0, + height: '100%', + }), + audioMediaContainer: () => css({ height: '165px', diff --git a/src/app/components/MediaLoader/index.test.tsx b/src/app/components/MediaLoader/index.test.tsx index cd4c6add514..63edc071a26 100644 --- a/src/app/components/MediaLoader/index.test.tsx +++ b/src/app/components/MediaLoader/index.test.tsx @@ -5,10 +5,11 @@ import { } from '#app/components/react-testing-library-with-providers'; import { Helmet } from 'react-helmet'; import useLocation from '#app/hooks/useLocation'; -import { TV_PAGE } from '#app/routes/utils/pageTypes'; +import { LIVE_PAGE, TV_PAGE } from '#app/routes/utils/pageTypes'; import MediaPlayer from '.'; import { aresMediaBlocks, + livePageAudioClipMediaBlock, onDemandTvBlocks, onDemandTvBlocksWithOverrides, } from './fixture'; @@ -121,6 +122,7 @@ describe('MediaLoader', () => { const mockRequire = jest.fn(); const mockBump = { player: () => ({ + bind: jest.fn(), load: jest.fn(), }), }; @@ -144,6 +146,182 @@ describe('MediaLoader', () => { expect(window.mediaPlayers.testId).not.toBeNull(); }); + + it('adds and removes fullscreen classes on fake fullscreen enter/exit events', async () => { + const mockRequire = jest.fn(); + const bind = jest.fn(); + const mockBump = { + player: () => ({ + bind, + load: jest.fn(), + }), + }; + + window.requirejs = mockRequire; + + await act(async () => { + render(, { + id: 'testId', + }); + }); + + const callbackFn = mockRequire.mock.calls[0][1]; + callbackFn(mockBump); + + const enterFakeFullscreen = bind.mock.calls.find( + ([event]) => event === 'enterFakeFullscreen', + )?.[1]; + const exitFakeFullscreen = bind.mock.calls.find( + ([event]) => event === 'exitFakeFullscreen', + )?.[1]; + + expect(typeof enterFakeFullscreen).toBe('function'); + expect(typeof exitFakeFullscreen).toBe('function'); + + act(() => { + enterFakeFullscreen({}); + }); + + expect(document.documentElement.classList).toContain( + 'simorgh-player-fullscreen', + ); + expect(document.body.classList).toContain('simorgh-player-fullscreen'); + + act(() => { + exitFakeFullscreen({}); + }); + + expect(document.documentElement.classList).not.toContain( + 'simorgh-player-fullscreen', + ); + expect(document.body.classList).not.toContain( + 'simorgh-player-fullscreen', + ); + }); + + it('composes caller fake fullscreen handlers with internal fullscreen handlers', async () => { + const onEnterFakeFullscreen = jest.fn(); + const mockRequire = jest.fn(); + const bind = jest.fn(); + const mockBump = { + player: () => ({ + bind, + load: jest.fn(), + }), + }; + + window.requirejs = mockRequire; + + await act(async () => { + render( + , + { + id: 'testId', + }, + ); + }); + + const callbackFn = mockRequire.mock.calls[0][1]; + callbackFn(mockBump); + + const enterFakeFullscreenBindings = bind.mock.calls.filter( + ([event]) => event === 'enterFakeFullscreen', + ); + + expect(enterFakeFullscreenBindings).toHaveLength(2); + + act(() => { + enterFakeFullscreenBindings.forEach(([, handler]) => handler({})); + }); + + expect(onEnterFakeFullscreen).toHaveBeenCalled(); + expect(document.documentElement.classList).toContain( + 'simorgh-player-fullscreen', + ); + }); + + it('does not bind fake fullscreen handlers for audio players', async () => { + const mockRequire = jest.fn(); + const bind = jest.fn(); + const mockBump = { + player: () => ({ + bind, + load: jest.fn(), + }), + }; + + window.requirejs = mockRequire; + + await act(async () => { + render( + , + { + id: 'testId', + pageType: LIVE_PAGE, + }, + ); + }); + + const callbackFn = mockRequire.mock.calls[0][1]; + callbackFn(mockBump); + + const fakeFullscreenBindings = bind.mock.calls.filter(([event]) => + ['enterFakeFullscreen', 'exitFakeFullscreen'].includes(event), + ); + + expect(fakeFullscreenBindings).toHaveLength(0); + }); + + it('does not reinitialise the player when fake fullscreen state changes', async () => { + // Use the real useState implementation here so entering fake fullscreen + // actually triggers a MediaLoader re-render, reproducing the scenario + // that previously caused the Bump player to be torn down and + // recreated mid-playback (closing iOS fake fullscreen immediately). + (useState as jest.Mock).mockImplementation( + jest.requireActual('react').useState, + ); + + const mockRequire = jest.fn(); + const bind = jest.fn(); + const load = jest.fn(); + const mockBump = { + player: () => ({ + bind, + load, + }), + }; + + window.requirejs = mockRequire; + + await act(async () => { + render(, { + id: 'testId', + pageType: LIVE_PAGE, + }); + }); + + const callbackFn = mockRequire.mock.calls[0][1]; + callbackFn(mockBump); + + expect(mockRequire).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + + const enterFakeFullscreen = bind.mock.calls.find( + ([event]) => event === 'enterFakeFullscreen', + )?.[1]; + + act(() => { + enterFakeFullscreen({}); + }); + + expect(mockRequire).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + }); }); describe('Placeholder', () => { diff --git a/src/app/components/MediaLoader/index.tsx b/src/app/components/MediaLoader/index.tsx index dd131cd373d..f342166eafb 100644 --- a/src/app/components/MediaLoader/index.tsx +++ b/src/app/components/MediaLoader/index.tsx @@ -1,4 +1,4 @@ -import { use, useEffect, useRef, useState } from 'react'; +import { use, useEffect, useMemo, useRef, useState } from 'react'; import { Helmet } from 'react-helmet'; import { RequestContext } from '#contexts/RequestContext'; import { MEDIA_PLAYER_STATUS } from '#app/lib/logger.const'; @@ -13,6 +13,7 @@ import { import filterForBlockType from '#lib/utilities/blockHandlers'; import { PageTypes } from '#app/models/types/global'; import { EventTrackingContext } from '#app/contexts/EventTrackingContext'; +import onClient from '#app/lib/utilities/onClient'; import { BumpType, EventMapping, @@ -26,7 +27,13 @@ import buildConfig from './utils/buildSettings'; import Placeholder from './Placeholder'; import getProducerFromServiceName from './utils/getProducerFromServiceName'; import getCaptionBlock from './utils/getCaptionBlock'; -import styles from './index.styles'; +import styles, { + PLAYER_FULLSCREEN_CLASS, + FAKE_FULLSCREEN_LAYER_CLASS, + FAKE_FULLSCREEN_ACTIVE_CLASS, + ACTIVE_FULLSCREEN_LOADER_STATE, + fakeFullscreenStyles, +} from './index.styles'; import { getBootstrapSrc } from '../Ad/Canonical'; import Metadata from './Metadata'; import AmpMediaLoader from './Amp'; @@ -109,6 +116,8 @@ type MediaContainerProps = { uniqueId?: string; noJsMessage?: string; eventMapping?: EventMapping; + shouldHandleFakeFullscreen?: boolean; + onFakeFullscreenChange?: (isActive: boolean) => void; }; const isAudioPlayer = (playerConfig: PlayerConfig) => @@ -120,19 +129,28 @@ const MediaContainer = ({ uniqueId, noJsMessage, eventMapping, + shouldHandleFakeFullscreen = false, + onFakeFullscreenChange, }: MediaContainerProps) => { const playerElementRef = useRef(null); + const onFakeFullscreenChangeRef = useRef(onFakeFullscreenChange); const isAudio = isAudioPlayer(playerConfig); + onFakeFullscreenChangeRef.current = onFakeFullscreenChange; + useEffect(() => { try { window.requirejs(['bump-4'], (Bump: BumpType) => { if (playerElementRef?.current && playerConfig) { // The requirejs callback cannot be async, so we wrap async logic in an inner function and invoke it immediately. const initPlayer = async () => { + const effectiveConfig = shouldHandleFakeFullscreen + ? { ...playerConfig, supportFakeFullscreen: true } + : playerConfig; + const mediaPlayer = Bump.player( playerElementRef.current, - playerConfig, + effectiveConfig, ); if (uniqueId != null) { @@ -154,6 +172,15 @@ const MediaContainer = ({ }); } + if (shouldHandleFakeFullscreen) { + mediaPlayer.bind('enterFakeFullscreen', () => { + onFakeFullscreenChangeRef.current?.(true); + }); + mediaPlayer.bind('exitFakeFullscreen', () => { + onFakeFullscreenChangeRef.current?.(false); + }); + } + if (showAds) { const adTag = await window.dotcom.ads.getAdTag(); @@ -195,7 +222,13 @@ const MediaContainer = ({ } catch (error) { logger.error(MEDIA_PLAYER_STATUS, error); } - }, [playerConfig, showAds, uniqueId, eventMapping]); + }, [ + playerConfig, + showAds, + uniqueId, + eventMapping, + shouldHandleFakeFullscreen, + ]); return (
{ const { lang, service, translations, defaultImage } = use(ServiceContext); const { pageIdentifier } = use(EventTrackingContext); @@ -243,29 +283,68 @@ const MediaLoader = ({ const [showPlaceholder, setShowPlaceholder] = useState( !PAGETYPES_IGNORE_PLACEHOLDER.includes(pageType), ); + const [isFakeFullscreenActive, setIsFakeFullscreenActive] = useState(false); + // Tracks whether *this* instance is the one that set the global fullscreen + // classes, so its cleanup does not clobber another player's active fullscreen state. + const hasActivatedFakeFullscreenRef = useRef(false); - if (isLite) return null; + useEffect(() => { + return () => { + if (!onClient()) return; + if (!hasActivatedFakeFullscreenRef.current) return; + + document.documentElement.classList.remove(PLAYER_FULLSCREEN_CLASS); + document.body.classList.remove(PLAYER_FULLSCREEN_CLASS); + }; + }, []); const { model: mediaOverrides } = filterForBlockType(blocks, 'mediaOverrides') || {}; const producer = getProducerFromServiceName(service); - const config = buildConfig({ - id: id || '', - blocks, - counterName: mediaOverrides?.pageIdentifierOverride || pageIdentifier, - statsDestination, - producer, - isAmp, - lang, - pageType, - service, - translations, - adsEnabled, - showAdsBasedOnLocation, - embedded, - defaultImage, - }); + const counterName = mediaOverrides?.pageIdentifierOverride || pageIdentifier; + + // Memoised so playerConfig keeps a stable identity across re-renders that + // aren't caused by a real input change (e.g. the fake fullscreen state + // toggling), otherwise MediaContainer treats it as a new config and + // tears down/recreates the Bump player mid-playback. + const config = useMemo( + () => + buildConfig({ + id: id || '', + blocks, + counterName, + statsDestination, + producer, + isAmp, + lang, + pageType, + service, + translations, + adsEnabled, + showAdsBasedOnLocation, + embedded, + defaultImage, + }), + [ + id, + blocks, + counterName, + statsDestination, + producer, + isAmp, + lang, + pageType, + service, + translations, + adsEnabled, + showAdsBasedOnLocation, + embedded, + defaultImage, + ], + ); + + if (isLite) return null; if (!config) return null; @@ -293,6 +372,20 @@ const MediaLoader = ({ const noJsMessage = translatedNoJSMessage || translations?.media?.noJs; const hasPlaceholder = Boolean(showPlaceholder && placeholderSrc); + const shouldHandleFakeFullscreen = + !isAmp && !embedded && !isAudio && !withinFullscreenContainer; + + const setFakeFullscreenPageState = (isActive: boolean) => { + if (!onClient()) return; + + document.documentElement.classList.toggle( + PLAYER_FULLSCREEN_CLASS, + isActive, + ); + document.body.classList.toggle(PLAYER_FULLSCREEN_CLASS, isActive); + hasActivatedFakeFullscreenRef.current = isActive; + setIsFakeFullscreenActive(isActive); + }; return ( <> @@ -322,27 +415,63 @@ const MediaLoader = ({ noJsMessage={noJsMessage} /> ) : ( + // This wrapper - rather than the figure - is what gets forced above + // page content during fake fullscreen, so the Caption below (page + // furniture) is never pulled into the fullscreen layer with it. + // The fake fullscreen layer is a sibling (not a child) of this + // wrapper so that both sit in the same page-level stacking context. + // Inside the wrapper they would compete: the layer at z-index + // 2147483646 would sit above the SMP player at z-index 999 within + // the wrapper's stacking context, hiding the video on iOS Safari + // where GPU compositing does not bypass CSS stacking as it does on + // desktop browsers. <> - {showAds && } - - {hasPlaceholder ? ( - setShowPlaceholder(false)} - isPortraitOrientation={!!isPortrait} - /> - ) : ( -