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 (
+
+
+
+ );
+}
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 (
-
+