From e8fefaf6a8e288e4cf83cf4cf6d4cb5e0189df5b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 Jul 2026 11:50:01 +0200 Subject: [PATCH] Add fullscreen option to inline video Let editors offer a fullscreen button on inline videos, giving viewers a "cinema mode" for longer clips. Uses real device fullscreen where the browser supports it (desktop, Android) so the custom controls stay usable, and hands off to the native player on iPhone for graceful orientation handling. Opt-in per element and unavailable for backdrop position or loop playback. REDMINE-21328 --- entry_types/scrolled/config/locales/de.yml | 4 + entry_types/scrolled/config/locales/en.yml | 4 + .../scrolled/config/locales/new/public.de.yml | 2 + .../scrolled/config/locales/new/public.en.yml | 2 + .../inlineVideo/InlineVideo-spec.js | 74 ++++++++++ .../inlineVideo/handlers-spec.js | 12 ++ .../inlineVideo/FullscreenVideo.js | 129 ++++++++++++++++++ .../inlineVideo/FullscreenVideo.module.css | 27 ++++ .../inlineVideo/InlineVideo.js | 57 +++++--- .../inlineVideo/MutedIndicator.js | 6 +- .../inlineVideo/MutedIndicator.module.css | 6 + .../src/contentElements/inlineVideo/editor.js | 9 ++ .../contentElements/inlineVideo/handlers.js | 6 +- .../src/frontend/MediaPlayerControls.js | 9 +- .../scrolled/package/src/frontend/index.js | 1 + .../src/frontend/usePlayerControlsInactive.js | 13 ++ 16 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/inlineVideo/InlineVideo-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.js create mode 100644 entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.module.css create mode 100644 entry_types/scrolled/package/src/frontend/usePlayerControlsInactive.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 4dfa8925a7..399f764d0e 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -717,6 +717,10 @@ de: mute: Ausblenden play: Weiterspielen turnDown: Leiser weiterspielen + enableFullscreen: + inline_help: Button anzeigen, um das Video im Vollbildmodus zu öffnen. + inline_help_disabled: Steht nicht zur Verfügung bei Hintergrund-Position oder Endlosschleife. + label: Vollbildmodus erlauben hideControlBar: inline_help: Für kurze Videos, bei denen der Benutzer nicht an bestimmte Stellen springen will. inline_help_disabled: Für Videos mit Wiedergabe-Modus "Loop" oder kreisförmigem Zuschnitt sind die Controls immer ausgeblendet. diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 4948fc41ee..a3b29706e8 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -702,6 +702,10 @@ en: mute: Mute play: Keep playing turnDown: Keep playing at lower volume + enableFullscreen: + inline_help: Display a button to open the video in fullscreen mode. + inline_help_disabled: Not available for backdrop position or loop playback. + label: Enable Fullscreen hideControlBar: inline_help: For short videos where there is no need to seek. inline_help_disabled: Controls are always hidden for videos with playback mode "Loop" or circle crop. diff --git a/entry_types/scrolled/config/locales/new/public.de.yml b/entry_types/scrolled/config/locales/new/public.de.yml index b544924d79..35d38e0748 100644 --- a/entry_types/scrolled/config/locales/new/public.de.yml +++ b/entry_types/scrolled/config/locales/new/public.de.yml @@ -1,4 +1,6 @@ de: pageflow_scrolled: public: + enter_fullscreen: Vollbildmodus öffnen + exit_fullscreen: Vollbildmodus verlassen flip_card: Karte wenden diff --git a/entry_types/scrolled/config/locales/new/public.en.yml b/entry_types/scrolled/config/locales/new/public.en.yml index 787a2edaf9..05e105e3de 100644 --- a/entry_types/scrolled/config/locales/new/public.en.yml +++ b/entry_types/scrolled/config/locales/new/public.en.yml @@ -1,4 +1,6 @@ en: pageflow_scrolled: public: + enter_fullscreen: Enter fullscreen + exit_fullscreen: Exit fullscreen flip_card: Flip card diff --git a/entry_types/scrolled/package/spec/contentElements/inlineVideo/InlineVideo-spec.js b/entry_types/scrolled/package/spec/contentElements/inlineVideo/InlineVideo-spec.js new file mode 100644 index 0000000000..e0ab64d665 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/inlineVideo/InlineVideo-spec.js @@ -0,0 +1,74 @@ +import React from 'react'; +import 'contentElements/inlineVideo/frontend'; +import 'support/mediaElementStub'; +import {renderInContentElement, useContentElementMatchers} from 'pageflow-scrolled/testHelpers'; +import {useFakeTranslations} from 'pageflow/testHelpers'; +import '@testing-library/jest-dom/extend-expect'; + +import {InlineVideo} from 'contentElements/inlineVideo/InlineVideo'; +import {usePortraitOrientation} from 'frontend/usePortraitOrientation'; +jest.mock('frontend/usePortraitOrientation'); + +describe('InlineVideo', () => { + useContentElementMatchers(); + + useFakeTranslations({ + 'pageflow_scrolled.public.enter_fullscreen': 'Enter fullscreen', + 'pageflow_scrolled.public.exit_fullscreen': 'Exit fullscreen' + }); + + beforeEach(() => { + usePortraitOrientation.mockReturnValue(false); + }); + + function renderInlineVideo({configuration = {id: 100}} = {}) { + return renderInContentElement( + , + {seed: { + fileUrlTemplates: { + videoFiles: { + medium: ':id_partition/medium/:basename.mp4', + high: ':id_partition/high/:basename.mp4' + } + }, + videoFiles: [{permaId: 100, isReady: true, variants: ['medium', 'high']}] + }} + ); + } + + describe('fullscreen button', () => { + it('is rendered when enableFullscreen is set', () => { + const {queryByTitle} = renderInlineVideo({ + configuration: {id: 100, enableFullscreen: true} + }); + + expect(queryByTitle('Enter fullscreen')).not.toBeNull(); + }); + + it('is not rendered when enableFullscreen is not set', () => { + const {queryByTitle} = renderInlineVideo({ + configuration: {id: 100} + }); + + expect(queryByTitle('Enter fullscreen')).toBeNull(); + }); + + it('is not rendered for backdrop position', () => { + const {queryByTitle} = renderInlineVideo({ + configuration: {id: 100, enableFullscreen: true, position: 'backdrop'} + }); + + expect(queryByTitle('Enter fullscreen')).toBeNull(); + }); + + it('is not rendered for loop playback', () => { + const {queryByTitle} = renderInlineVideo({ + configuration: {id: 100, enableFullscreen: true, playbackMode: 'loop'} + }); + + expect(queryByTitle('Enter fullscreen')).toBeNull(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/inlineVideo/handlers-spec.js b/entry_types/scrolled/package/spec/contentElements/inlineVideo/handlers-spec.js index 7c2bf33e6e..5344e02d79 100644 --- a/entry_types/scrolled/package/spec/contentElements/inlineVideo/handlers-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/inlineVideo/handlers-spec.js @@ -192,6 +192,18 @@ describe('getLifecycleHandlers', () => { expect(playerActions.calls).toEqual([]); }); + + it('is no-op while fullscreen', () => { + const configuration = {playbackMode: 'autoplay'}; + const playerActions = getPlayerActions(); + + const {onDeactivate} = getLifecycleHandlers({ + configuration, playerActions, mediaMuted: false, isFullscreen: true + }); + onDeactivate(); + + expect(playerActions.calls).toEqual([]); + }); }); describe('onInvisible', () => { diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.js new file mode 100644 index 0000000000..005241c600 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.js @@ -0,0 +1,129 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef +} from 'react'; +import classNames from 'classnames'; +import screenfull from 'screenfull'; + +import { + ToggleFullscreenCornerButton, + usePhonePlatform, + usePlayerControlsInactive +} from 'pageflow-scrolled/frontend'; + +import styles from './FullscreenVideo.module.css'; + +const FullscreenActiveContext = createContext(false); + +// Whether the surrounding video is currently displayed in fullscreen. +// Lets descendants (e.g. the lifecycle handlers) tell an entering +// fullscreen apart from the element scrolling out of the viewport. +export function useFullscreenActive() { + return useContext(FullscreenActiveContext); +} + +// Wraps a video player and adds a corner button that toggles real +// device fullscreen. Uses the Fullscreen API via screenfull where +// available (desktop and Android) so that the custom controls rendered +// inside the container remain visible. On iPhone, where the Fullscreen +// API is not available for arbitrary elements, hands off to the native +// video player overlay via webkitEnterFullscreen. Exiting via the +// platform (Esc, Android back gesture, native player) is picked up +// through the change events. +export function FullscreenVideo({playerState, keepButtonVisible, children}) { + const {mediaElementId} = playerState; + const containerRef = useRef(); + const isPhonePlatform = usePhonePlatform(); + const [isFullscreen, setIsFullscreen] = useState(false); + + const controlsInactive = usePlayerControlsInactive(playerState); + const fadedOut = playerState.shouldPlay && controlsInactive && !keepButtonVisible; + + const getVideoElement = useCallback(() => { + if (mediaElementId) { + const element = document.getElementById(mediaElementId); + + if (element) { + return element; + } + } + + return containerRef.current?.querySelector('video'); + }, [mediaElementId]); + + const enterFullscreen = useCallback(() => { + const video = getVideoElement(); + + if (isPhonePlatform && video?.webkitEnterFullscreen) { + video.webkitEnterFullscreen(); + } + else if (screenfull.isEnabled && containerRef.current) { + screenfull.request(containerRef.current); + } + + setIsFullscreen(true); + }, [getVideoElement, isPhonePlatform]); + + const exitFullscreen = useCallback(() => { + if (screenfull.isEnabled && screenfull.isFullscreen) { + screenfull.exit(); + } + + const video = getVideoElement(); + + if (video?.webkitDisplayingFullscreen) { + video.webkitExitFullscreen(); + } + + setIsFullscreen(false); + }, [getVideoElement]); + + useEffect(() => { + function handleScreenfullChange() { + if (!screenfull.isFullscreen) { + setIsFullscreen(false); + } + } + + function handleNativePlayerExit() { + setIsFullscreen(false); + } + + if (screenfull.isEnabled) { + screenfull.on('change', handleScreenfullChange); + } + + const video = getVideoElement(); + + if (video) { + video.addEventListener('webkitendfullscreen', handleNativePlayerExit); + } + + return () => { + if (screenfull.isEnabled) { + screenfull.off('change', handleScreenfullChange); + } + + if (video) { + video.removeEventListener('webkitendfullscreen', handleNativePlayerExit); + } + }; + }, [getVideoElement]); + + return ( + +
+ {children} +
+ +
+
+
+ ); +} diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.module.css b/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.module.css new file mode 100644 index 0000000000..d0a3f43b37 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/FullscreenVideo.module.css @@ -0,0 +1,27 @@ +.container { + position: relative; + width: 100%; + height: 100%; +} + +.container:fullscreen { + background-color: #000; +} + +/* Show the whole frame in fullscreen, overriding the cover fit used + for aspect-ratio crops and the always-cover poster. object-position + is an inline style for cover crops, hence the !important. */ +.container:fullscreen video, +.container:fullscreen img { + object-fit: contain; + object-position: center !important; +} + +.button { + transition: opacity 0.2s ease; +} + +.button.fadedOut { + opacity: 0; + pointer-events: none; +} diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js index bc5f8b7854..3c07117945 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js @@ -24,6 +24,7 @@ import { } from 'pageflow-scrolled/frontend'; import {MutedIndicator} from './MutedIndicator'; +import {FullscreenVideo, useFullscreenActive} from './FullscreenVideo'; import { getLifecycleHandlers, @@ -131,6 +132,33 @@ function OrientationUnawareInlineVideo({ const {aspectRatio, rounded} = processImageModifiers(imageModifiers); + const supportFullscreen = + configuration.enableFullscreen && + configuration.position !== 'backdrop' && + configuration.playbackMode !== 'loop'; + + const mutedIndicatorVisible = media.muted && + playerState.shouldPlay && + !configuration.keepMuted; + + const player = ( + + ); + return ( - - + + {supportFullscreen ? + + {player} + : + player} )} @@ -211,9 +229,10 @@ function PlayerWithControlBar({ applyContentElementBoxStyles }) { const {isEditable, isSelected} = useContentElementEditorState(); + const isFullscreen = useFullscreenActive(); const {shouldLoad, shouldPrepare} = useContentElementLifecycle( - getLifecycleHandlers({configuration, playerActions, mediaMuted: media.muted}) + getLifecycleHandlers({configuration, playerActions, mediaMuted: media.muted, isFullscreen}) ); useAudioFocus({ diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.js index cb22106214..6c22157507 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.js @@ -3,9 +3,11 @@ import classNames from 'classnames'; import styles from './MutedIndicator.module.css'; -export function MutedIndicator({visible}) { +export function MutedIndicator({visible, besideFullscreenButton}) { return ( -
+
diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.module.css b/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.module.css index f8891e80d1..42e59adb77 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.module.css +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/MutedIndicator.module.css @@ -16,6 +16,12 @@ opacity: 1; } +/* Make room for the fullscreen toggle button (40px button + 2px + margins) so the two do not overlap in the top right corner. */ +.wrapper.besideFullscreenButton { + right: 44px; +} + .eqBar { transform: scale(1, -1) translate(0, -24px); fill: #fff; diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js index 0d65b6940b..22997983e9 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js @@ -45,6 +45,7 @@ editor.contentElementTypes.register('inlineVideo', { this.input('playbackMode', SelectInputView, { values: ['manual', 'autoplay', 'autoplayIfUnmuted', 'loop'] }); + this.input('enableFullscreen', CheckBoxInputView); }, configurationEditor({entry}) { @@ -138,6 +139,14 @@ editor.contentElementTypes.register('inlineVideo', { this.view(SeparatorView); + this.input('enableFullscreen', CheckBoxInputView, { + disabledBinding: ['position', 'playbackMode'], + disabled: ([position, playbackMode]) => + position === 'backdrop' || + playbackMode === 'loop', + displayUncheckedIfDisabled: true + }); + this.group('ContentElementPosition', {entry}); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/handlers.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/handlers.js index 5ef72fd7b8..2f5d642edd 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/handlers.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/handlers.js @@ -1,4 +1,4 @@ -export function getLifecycleHandlers({configuration, playerActions, mediaMuted}) { +export function getLifecycleHandlers({configuration, playerActions, mediaMuted, isFullscreen}) { return { onVisible() { if (configuration.playbackMode === 'loop') { @@ -15,7 +15,9 @@ export function getLifecycleHandlers({configuration, playerActions, mediaMuted}) }, onDeactivate() { - if (configuration.playbackMode !== 'loop') { + // Entering fullscreen can move the element out of the viewport + // center and trigger a spurious deactivation. Keep playing then. + if (configuration.playbackMode !== 'loop' && !isFullscreen) { playerActions.fadeOutAndPause(400); } }, diff --git a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js index 229a31930d..17c25375bd 100644 --- a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js +++ b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js @@ -4,7 +4,7 @@ import {useTextTracks} from './useTextTracks'; import {useI18n} from './i18n'; import {useMediaMuted} from './useMediaMuted'; -import {useFocusOutlineVisible} from './focusOutline'; +import {usePlayerControlsInactive} from './usePlayerControlsInactive'; export function MediaPlayerControls(props) { const playerState = props.playerState; @@ -16,7 +16,7 @@ export function MediaPlayerControls(props) { defaultTextTrackFilePermaId: props.defaultTextTrackFilePermaId, captionsByDefault: useMediaMuted() }); - const focusOutlineVisible = useFocusOutlineVisible(); + const controlsInactive = usePlayerControlsInactive(playerState); return (