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 (