Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions entry_types/scrolled/config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions entry_types/scrolled/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions entry_types/scrolled/config/locales/new/public.de.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
de:
pageflow_scrolled:
public:
enter_fullscreen: Vollbildmodus öffnen
exit_fullscreen: Vollbildmodus verlassen
flip_card: Karte wenden
2 changes: 2 additions & 0 deletions entry_types/scrolled/config/locales/new/public.en.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
en:
pageflow_scrolled:
public:
enter_fullscreen: Enter fullscreen
exit_fullscreen: Exit fullscreen
flip_card: Flip card
Original file line number Diff line number Diff line change
@@ -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(
<InlineVideo contentElementId={42}
sectionProps={{isIntersecting: false}}
configuration={configuration} />,
{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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<FullscreenActiveContext.Provider value={isFullscreen}>
<div ref={containerRef} className={styles.container}>
{children}
<div className={classNames(styles.button, {[styles.fadedOut]: fadedOut})}>
<ToggleFullscreenCornerButton isFullscreen={isFullscreen}
onEnter={enterFullscreen}
onExit={exitFullscreen} />
</div>
</div>
</FullscreenActiveContext.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from 'pageflow-scrolled/frontend';

import {MutedIndicator} from './MutedIndicator';
import {FullscreenVideo, useFullscreenActive} from './FullscreenVideo';

import {
getLifecycleHandlers,
Expand Down Expand Up @@ -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 = (
<Player key={configuration.playbackMode === 'loop'}
sectionProps={sectionProps}
videoFile={videoFile}
motifArea={motifArea}
posterImageFile={posterImageFile}
inlineFileRightsItems={inlineFileRightsItems}
playerState={playerState}
playerActions={playerActions}
contentElementId={contentElementId}
configuration={configuration}
fit={(aspectRatio || configuration.position === 'backdrop') ? 'cover' : 'contain'}
hideControlBar={rounded === 'circle' ||
configuration.hideControlBar ||
configuration.playbackMode === 'loop'}
applyContentElementBoxStyles={rounded === 'circle'} />
);

return (
<MediaInteractionTracking playerState={playerState} playerActions={playerActions}>
<FitViewport file={videoFile}
Expand All @@ -141,24 +169,14 @@ function OrientationUnawareInlineVideo({
<ContentElementFigure configuration={configuration}>
<FitViewport.Content>
<FilePlaceholder file={videoFile} />
<MutedIndicator visible={media.muted &&
playerState.shouldPlay &&
!configuration.keepMuted} />
<Player key={configuration.playbackMode === 'loop'}
sectionProps={sectionProps}
videoFile={videoFile}
motifArea={motifArea}
posterImageFile={posterImageFile}
inlineFileRightsItems={inlineFileRightsItems}
playerState={playerState}
playerActions={playerActions}
contentElementId={contentElementId}
configuration={configuration}
fit={(aspectRatio || configuration.position === 'backdrop') ? 'cover' : 'contain'}
hideControlBar={rounded === 'circle' ||
configuration.hideControlBar ||
configuration.playbackMode === 'loop'}
applyContentElementBoxStyles={rounded === 'circle'} />
<MutedIndicator visible={mutedIndicatorVisible}
besideFullscreenButton={supportFullscreen} />
{supportFullscreen ?
<FullscreenVideo playerState={playerState}
keepButtonVisible={mutedIndicatorVisible}>
{player}
</FullscreenVideo> :
player}
</FitViewport.Content>
</ContentElementFigure>
)}
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import classNames from 'classnames';

import styles from './MutedIndicator.module.css';

export function MutedIndicator({visible}) {
export function MutedIndicator({visible, besideFullscreenButton}) {
return (
<div className={classNames(styles.wrapper, {[styles.visible]: visible})}>
<div className={classNames(styles.wrapper,
{[styles.visible]: visible,
[styles.besideFullscreenButton]: besideFullscreenButton})}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect className={styles.eqBar1} x="4" y="4" width="3.7" height="8"/>
<rect className={styles.eqBar2} x="10.2" y="4" width="3.7" height="16"/>
Expand Down
Loading
Loading