feat: allow scenes to be paused (#58150)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2025-02-27 18:32:32 +01:00
committed by GitHub
parent f9d4f45179
commit 458d0ffde9
9 changed files with 207 additions and 146 deletions
@@ -105,7 +105,8 @@
"update-card": "Update your card",
"donate-now": "Donate Now",
"confirm-amount": "Confirm amount",
"play-scene": "Press Play",
"play": "Play Video",
"pause": "Pause Video",
"closed-caption": "Closed caption",
"share-on-x": "Share on X",
"share-on-bluesky": "Share on BlueSky",
@@ -1171,7 +1172,7 @@
"focus-instructions-panel": "Focus Instructions Panel",
"navigate-previous": "Navigate To Previous Exercise",
"navigate-next": "Navigate To Next Exercise",
"play-scene": "Play Scene"
"play-video": "Play Video"
},
"signout": {
"heading": "Sign out of your account",
+2 -3
View File
@@ -6,11 +6,10 @@ function ClosedCaptionsIcon(
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 256 256'
viewBox='0 0 576 512'
fill={props.fill || 'var(--gray-00)'}
>
<rect width='256' height='256' fill='none' />
<path d='M216,40H40A16.01833,16.01833,0,0,0,24,56V200a16.01833,16.01833,0,0,0,16,16H216a16.01833,16.01833,0,0,0,16-16V56A16.01833,16.01833,0,0,0,216,40ZM96,148a19.85259,19.85259,0,0,0,14.28613-6.00293,7.99956,7.99956,0,0,1,11.42774,11.19727,36,36,0,1,1,0-50.38868,7.99956,7.99956,0,0,1-11.42774,11.19727A20.00012,20.00012,0,1,0,96,148Zm72,0a19.85259,19.85259,0,0,0,14.28613-6.00293,7.99956,7.99956,0,0,1,11.42774,11.19727,36,36,0,1,1,0-50.38868,7.99956,7.99956,0,0,1-11.42774,11.19727A20.00012,20.00012,0,1,0,168,148Z' />
<path d='M0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z' />
</svg>
);
}
@@ -1,6 +1,7 @@
:root {
--theme-color: #0a0a23;
--yellow-gold: #ffbf00;
--gray-00-translucent: rgba(255, 255, 255, 0.85);
--gray-00: #ffffff;
--gray-05: #f5f6f7;
--gray-10: #dfdfe2;
@@ -10,6 +11,7 @@
--gray-80: #2a2a40;
--gray-85: #1b1b32;
--gray-90: #0a0a23;
--gray-90-translucent: rgba(10, 10, 35, 0.85);
--purple-light: #dbb8ff;
--purple-dark: #5a01a7;
--yellow-light: #ffc300;
@@ -43,6 +45,7 @@
}
.dark-palette {
--primary-color-translucent: var(--gray-00-translucent);
--primary-color: var(--gray-00);
--secondary-color: var(--gray-05);
--tertiary-color: var(--gray-10);
@@ -51,6 +54,7 @@
--tertiary-background: var(--gray-80);
--secondary-background: var(--gray-85);
--primary-background: var(--gray-90);
--primary-background-translucent: var(--gray-90-translucent);
--highlight-color: var(--blue-light);
--highlight-background: var(--blue-dark);
--selection-color: var(--blue-light-translucent);
@@ -67,6 +71,7 @@
}
.light-palette {
--primary-color-translucent: var(--gray-90-translucent);
--primary-color: var(--gray-90);
--secondary-color: var(--gray-85);
--tertiary-color: var(--gray-80);
@@ -75,6 +80,7 @@
--tertiary-background: var(--gray-10);
--secondary-background: var(--gray-05);
--primary-background: var(--gray-00);
--primary-background-translucent: var(--gray-00-translucent);
--highlight-color: var(--blue-dark);
--highlight-background: var(--blue-light);
--selection-color: var(--blue-dark-translucent);
@@ -35,7 +35,7 @@ export function Character({
const [mouthIsOpen, setMouthIsOpen] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const onNotify = (eventType: 'play' | 'stop') => {
const onNotify = (eventType: 'play' | 'pause' | 'stop') => {
if (eventType === 'play') {
setIsPlaying(true);
} else {
@@ -3,7 +3,7 @@ const domain =
'https://cdn.freecodecamp.org/curriculum/english/animation-assets';
export const sounds = `${domain}/sounds`;
export const images = `${domain}/images`;
const images = `${domain}/images`;
export const backgrounds = `${images}/backgrounds`;
export const characters = `${images}/characters`;
@@ -1,4 +1,4 @@
type Observer = (eventType: 'play' | 'stop') => void;
type Observer = (eventType: 'play' | 'pause' | 'stop') => void;
export class SceneSubject {
#observers: Observer[];
@@ -16,7 +16,7 @@ export class SceneSubject {
// For now, we don't need to pass any data to the observers, so notify()
// doesn't take any arguments.
notify(eventType: 'play' | 'stop') {
notify(eventType: 'play' | 'pause' | 'stop') {
this.#observers.forEach(observer => observer(eventType));
}
}
@@ -4,60 +4,24 @@
color: var(--gray-00);
}
.scene-start-screen {
position: absolute;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
background-color: rgba(10, 10, 35, 0.5);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: var(--gray-00);
}
.scene-start-btn,
.scene-start-btn:hover,
.scene-start-btn:focus,
.scene-start-btn:active {
background-color: rgba(0, 0, 0, 0);
border: none;
scale: 0.75;
}
.scene-a11y-btn {
position: absolute;
right: 5px;
bottom: 5px;
text-align: right;
display: flex;
align-items: center;
justify-content: center;
}
.scene-play-btn img {
transform: translate(5px, 10px);
}
.scene-a11y-btn svg {
width: calc(50px + 3vw);
height: calc(50px + 3vw);
}
.scene-dialogue-wrap {
display: flex;
flex-direction: column;
justify-content: space-evenly;
position: absolute;
bottom: 0;
padding: calc(5px + 0.25vw) calc(5px + 4vw);
background: rgba(10, 10, 35, 0.9);
font-size: calc(0.25vw + 0.75rem);
width: 100%;
padding: 5px calc(5px + 3.5vw);
font-size: calc(0.25vw + 0.75rem);
min-height: calc(35px + 1vw + 2rem);
flex: 0 0 80%;
background: var(--primary-background-translucent);
color: var(--primary-color);
}
.scene-dialogue-label {
color: var(--blue-light);
color: var(--highlight-color);
font-weight: bold;
}
.scene-dialogue-align-left {
@@ -74,5 +38,34 @@
.scene-dialogue-text {
font-size: calc(0.25vw + 1rem);
padding: 5px 10px;
padding: 0 10px;
}
.scene-controls {
display: flex;
justify-content: space-between;
align-self: center;
background: var(--primary-background);
padding: 0 10px;
min-height: 60px;
max-height: 60px;
}
.scene-btn,
.scene-btn:hover,
.scene-btn:focus,
.scene-btn:active {
color: var(--tertiary-color);
background: rgba(0, 0, 0, 0);
border: none;
scale: 0.75;
}
.scene-play-btn {
background: rgba(0, 0, 0, 0);
}
.scene-a11y-btn svg {
width: 60px;
height: 60px;
}
@@ -6,12 +6,14 @@ import React, {
useCallback
} from 'react';
import { Col, Spacer } from '@freecodecamp/ui';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCirclePause, faCirclePlay } from '@fortawesome/free-solid-svg-icons';
import { isEmpty } from 'lodash-es';
import { useTranslation } from 'react-i18next';
import { FullScene } from '../../../../redux/prop-types';
import { Loader } from '../../../../components/helpers';
import ClosedCaptionsIcon from '../../../../assets/icons/closedcaptions';
import { sounds, images, backgrounds, characterAssets } from './scene-assets';
import { sounds, backgrounds, characterAssets } from './scene-assets';
import Character from './character';
import { SceneSubject } from './scene-subject';
@@ -46,6 +48,27 @@ export function Scene({
? sToMs(audio.finishTimestamp - audio.startTimestamp)
: Infinity;
const pauseAudio = () => {
// Until the play() promise resolves, we can't pause the audio
if (canPauseRef.current) audioRef.current.pause();
canPauseRef.current = false;
clearTimeout(startTimerRef.current);
clearTimeout(finishTimerRef.current);
};
const pauseAnimation = () => {
setIsPlaying(false);
// @ts-expect-error cancelAnimationFrame accepts undefined, but TS doesn't
// know that
window.cancelAnimationFrame(animationRef.current);
};
const resetAudio = useCallback(() => {
pauseAudio();
audioRef.current.currentTime = audio.startTimestamp || 0;
pausedAtRef.current = 0;
}, [audio.startTimestamp]);
// on mount
useEffect(() => {
const { current } = audioRef;
@@ -77,17 +100,14 @@ export function Scene({
// on unmount
return () => {
if (current) {
current.pause();
current.currentTime = 0;
current.removeEventListener('canplaythrough', audioLoaded);
}
resetAudio();
current.removeEventListener('canplaythrough', audioLoaded);
};
}, [audioRef, duration, setup, commands]);
}, [duration, setup, commands, resetAudio]);
const initBackground = setup.background;
// The charactesr are memoized to prevent the useEffect from running on every
// The characters are memoized to prevent the useEffect from running on every
// render,
const initCharacters = useMemo(
() =>
@@ -103,12 +123,12 @@ export function Scene({
const [isPlaying, setIsPlaying] = useState(false);
const [sceneIsReady, setSceneIsReady] = useState(false);
const [showDialogue, setShowDialogue] = useState(false);
const [accessibilityOn, setAccessibilityOn] = useState(false);
const [characters, setCharacters] = useState(initCharacters);
const [dialogue, setDialogue] = useState(initDialogue);
const [background, setBackground] = useState(initBackground);
const startRef = useRef<number>(0);
const startClocktimeRef = useRef<number>(0);
const pausedAtRef = useRef<number>(0);
const startTimerRef = useRef<number>();
const finishTimerRef = useRef<number>();
const animationRef = useRef<number>();
@@ -147,15 +167,10 @@ export function Scene({
setSceneIsReady(true);
};
const pause = () => {
// Until the play() promise resolves, we can't pause the audio
if (canPauseRef.current) audioRef.current.pause();
canPauseRef.current = false;
};
const handlePlay = useCallback(() => {
const pausedAt = pausedAtRef.current;
const updateCurrentTime = () => {
const time = Date.now() - startRef.current;
const time = Date.now() - startClocktimeRef.current;
setCurrentTime(time);
if (isPlayingSceneRef.current) {
@@ -168,75 +183,112 @@ export function Scene({
if (isPlaying || !sceneIsReady) return;
setIsPlaying(true);
isPlayingSceneRef.current = true;
startRef.current = Date.now();
setShowDialogue(true);
// when we paused, the startRef was the clock time when we started and
// pausedAt was the currentTime (i.e. how long we've been playing). That
// means to resume we need to set the startRef to the current time minus
// the time we've already played.
startClocktimeRef.current = Date.now() - pausedAt;
updateCurrentTime();
const audioStartDelay = sToMs(audio.startTime) - pausedAt;
// @ts-expect-error it's not a node timer
startTimerRef.current = setTimeout(() => {
if (audioRef.current.paused) {
void audioRef.current.play().then(() => {
canPauseRef.current = true;
// If the duration is Infinity, that means the duration is simply the
// length of the file. However we need to actively stop the audio to
// ensure that cleanup (i.e. resetAudio is called) )
const effectiveDuration =
duration === Infinity ? sToMs(audioRef.current.duration) : duration;
// If the delay is positive, the setTimeout will have already waited
// that amount of time. However, if it's negative, then the setTimeout
// has no delay and we need to account for that when calculating how
// much audio is left to play.
const effectiveStartDelay = Math.min(0, audioStartDelay);
const audioEndDelay = effectiveDuration + effectiveStartDelay;
if (audioEndDelay < 0) {
resetAudio();
return;
}
// @ts-expect-error it's not a node timer
finishTimerRef.current = setTimeout(() => {
const endTimeStamp = sToMs(audio.finishTimestamp!); // it exists because duration is not Infinity
const audioCurrentTime = sToMs(audioRef.current.currentTime);
const remainingTime = endTimeStamp - audioCurrentTime;
// For some reason, despite the setTimeout resolving at the right
// time, the currentTime can be smaller than expected. That means
// that if we pause now it will cut off the last part.
if (remainingTime < 100) {
// 100ms is arbitrary and may need to be adjusted if people still
// notice the cut off
resetAudio();
} else {
// @ts-expect-error it's not a node timer
finishTimerRef.current = setTimeout(() => {
resetAudio();
}, remainingTime);
}
}, audioEndDelay);
});
}
}, sToMs(audio.startTime));
}, audioStartDelay);
}, [audio, duration, isPlaying, resetAudio, sceneIsReady]);
// @ts-expect-error it's not a node timer
finishTimerRef.current = setTimeout(
() => {
if (duration !== Infinity) {
const endTimeStamp = sToMs(audio.finishTimestamp!); // it exists because duration is not Infinity
const audioCurrentTime = sToMs(audioRef.current.currentTime);
const remainingTime = endTimeStamp - audioCurrentTime;
// For some reason, despite the setTimeout resolving at the right
// time, the currentTime can be smaller than expected. That means
// that if we pause now it will cut off the last part.
if (remainingTime < 100) {
// 100ms is arbitrary and may need to be adjusted if people still
// notice the cut off
pause();
} else {
// @ts-expect-error it's not a node timer
finishTimerRef.current = setTimeout(() => {
pause();
}, remainingTime);
}
}
},
duration + sToMs(audio.startTime)
);
}, [audio, duration, isPlaying, sceneIsReady]);
const handlePause = useCallback(() => {
isPlayingSceneRef.current = false;
pausedAtRef.current = currentTime;
pauseAudio();
pauseAnimation();
}, [currentTime]);
const handleStop = useCallback(() => {
usedCommandsRef.current.clear();
pause();
pauseAudio();
pauseAnimation();
audioRef.current.currentTime = audio.startTimestamp || 0;
setCurrentTime(0);
setIsPlaying(false);
isPlayingSceneRef.current = false;
setShowDialogue(false);
setDialogue(initDialogue);
setCharacters(initCharacters);
setBackground(initBackground);
}, [audio, initCharacters, initBackground]);
const resetAnimation = useCallback(() => {
usedCommandsRef.current.clear();
startClocktimeRef.current = 0;
setCurrentTime(0);
setDialogue(initDialogue);
setCharacters(initCharacters);
setBackground(initBackground);
}, [initCharacters, initBackground]);
const resetScene = () => {
setIsPlaying(false);
isPlayingSceneRef.current = false;
pausedAtRef.current = 0;
};
const onNotify = useCallback(
(eventType: 'play' | 'stop') => {
(eventType: 'play' | 'pause' | 'stop') => {
if (eventType === 'play') {
handlePlay();
} else if (eventType === 'pause') {
handlePause();
} else {
handleStop();
}
},
[handlePlay, handleStop]
[handlePlay, handlePause, handleStop]
);
const resetScene = useCallback(() => {
sceneSubject.notify('stop');
}, [sceneSubject]);
useEffect(() => {
sceneSubject.attach(onNotify);
return () => {
@@ -279,10 +331,13 @@ export function Scene({
}
});
// resetScene only works if called AFTER the commands, otherwise the
// commands will undo the reset.
if (currentTime >= resetTime) resetScene();
}, [currentTime, resetTime, sortedCommands, resetScene]);
if (currentTime >= resetTime) {
// resetAnimation only works if called AFTER the commands, otherwise the
// commands will undo the reset.
resetAnimation();
resetScene();
}
}, [currentTime, resetTime, sortedCommands, resetAnimation]);
useEffect(() => {
return () => {
@@ -321,14 +376,14 @@ export function Scene({
name={character}
position={position}
opacity={opacity}
isTalking={isPlaying && isTalking}
sceneSubject={sceneSubject}
isTalking={isTalking}
/>
);
}
)}
{showDialogue && (alwaysShowDialogue || accessibilityOn) && (
{(alwaysShowDialogue || accessibilityOn) && (
<div
className={`scene-dialogue-wrap ${
dialogue.align ? `scene-dialogue-align-${dialogue.align}` : ''
@@ -338,38 +393,45 @@ export function Scene({
<div className='scene-dialogue-text'>{dialogue.text}</div>
</div>
)}
{!isPlaying && (
<div className='scene-start-screen'>
<button
className='scene-start-btn scene-play-btn'
onClick={() => sceneSubject.notify('play')}
>
<img
src={`${images}/play-button.png`}
alt={t('buttons.play-scene')}
/>
</button>
{!alwaysShowDialogue && (
<button
className='scene-start-btn scene-a11y-btn'
aria-label={t('buttons.closed-caption')}
aria-pressed={accessibilityOn}
onClick={() => setAccessibilityOn(!accessibilityOn)}
>
<ClosedCaptionsIcon
fill={
accessibilityOn ? 'var(--gray-00)' : 'var(--gray-15)'
}
/>
</button>
)}
</div>
)}
</>
)}
</div>
<div className='scene-controls'>
<button
className='scene-btn scene-play-btn'
onClick={() => sceneSubject.notify(isPlaying ? 'pause' : 'play')}
>
{isPlaying ? (
<>
<span className='sr-only'>{t('buttons.pause')}</span>
<FontAwesomeIcon icon={faCirclePause} size='3x' />
</>
) : (
<>
<span className='sr-only'>{t('buttons.play')}</span>
<FontAwesomeIcon icon={faCirclePlay} size='3x' />
</>
)}
</button>
{alwaysShowDialogue ? (
<div className='scene-a11y-btn'></div>
) : (
<button
className='scene-btn scene-a11y-btn'
aria-label={t('buttons.closed-caption')}
aria-pressed={accessibilityOn}
onClick={() => setAccessibilityOn(!accessibilityOn)}
>
<ClosedCaptionsIcon
fill={
accessibilityOn ? 'var(--tertiary-color)' : 'var(--gray-45)'
}
/>
</button>
)}
</div>
<Spacer size='m' />
</Col>
);
@@ -67,7 +67,7 @@ function ShortcutsModal({
<td>CTRL/Command + Enter</td>
</tr>
<tr>
<td>{t('shortcuts.play-scene')}</td>
<td>{t('shortcuts.play-video')}</td>
<td>CTRL + Space</td>
</tr>
<tr>