diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 83fc44bde03..2296100709a 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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", diff --git a/client/src/assets/icons/closedcaptions.tsx b/client/src/assets/icons/closedcaptions.tsx index 791c8e97e42..f93f081e9d3 100644 --- a/client/src/assets/icons/closedcaptions.tsx +++ b/client/src/assets/icons/closedcaptions.tsx @@ -6,11 +6,10 @@ function ClosedCaptionsIcon( return ( - - + ); } diff --git a/client/src/components/layouts/variables.css b/client/src/components/layouts/variables.css index 4e400246807..76b5a316968 100644 --- a/client/src/components/layouts/variables.css +++ b/client/src/components/layouts/variables.css @@ -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); diff --git a/client/src/templates/Challenges/components/scene/character.tsx b/client/src/templates/Challenges/components/scene/character.tsx index 133c2235c13..0c125db768f 100644 --- a/client/src/templates/Challenges/components/scene/character.tsx +++ b/client/src/templates/Challenges/components/scene/character.tsx @@ -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 { diff --git a/client/src/templates/Challenges/components/scene/scene-assets.tsx b/client/src/templates/Challenges/components/scene/scene-assets.tsx index f641b982d34..e5e724d1fe0 100644 --- a/client/src/templates/Challenges/components/scene/scene-assets.tsx +++ b/client/src/templates/Challenges/components/scene/scene-assets.tsx @@ -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`; diff --git a/client/src/templates/Challenges/components/scene/scene-subject.tsx b/client/src/templates/Challenges/components/scene/scene-subject.tsx index 24e04b06994..ebc3deead45 100644 --- a/client/src/templates/Challenges/components/scene/scene-subject.tsx +++ b/client/src/templates/Challenges/components/scene/scene-subject.tsx @@ -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)); } } diff --git a/client/src/templates/Challenges/components/scene/scene.css b/client/src/templates/Challenges/components/scene/scene.css index 0cc0ceb30d8..0d929dff7d7 100644 --- a/client/src/templates/Challenges/components/scene/scene.css +++ b/client/src/templates/Challenges/components/scene/scene.css @@ -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; } diff --git a/client/src/templates/Challenges/components/scene/scene.tsx b/client/src/templates/Challenges/components/scene/scene.tsx index 34bda2298ab..488e1080a29 100644 --- a/client/src/templates/Challenges/components/scene/scene.tsx +++ b/client/src/templates/Challenges/components/scene/scene.tsx @@ -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(0); + const startClocktimeRef = useRef(0); + const pausedAtRef = useRef(0); const startTimerRef = useRef(); const finishTimerRef = useRef(); const animationRef = useRef(); @@ -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) && (
{dialogue.text}
)} - - {!isPlaying && ( -
- - - {!alwaysShowDialogue && ( - - )} -
- )} )} + +
+ + + {alwaysShowDialogue ? ( +
+ ) : ( + + )} +
); diff --git a/client/src/templates/Challenges/components/shortcuts-modal.tsx b/client/src/templates/Challenges/components/shortcuts-modal.tsx index 6899a7caa55..346151b4aa0 100644 --- a/client/src/templates/Challenges/components/shortcuts-modal.tsx +++ b/client/src/templates/Challenges/components/shortcuts-modal.tsx @@ -67,7 +67,7 @@ function ShortcutsModal({ CTRL/Command + Enter - {t('shortcuts.play-scene')} + {t('shortcuts.play-video')} CTRL + Space