diff --git a/packages/ui-react/src/components/Card/Card.module.scss b/packages/ui-react/src/components/Card/Card.module.scss index c0e62bdcf..21bc00799 100644 --- a/packages/ui-react/src/components/Card/Card.module.scss +++ b/packages/ui-react/src/components/Card/Card.module.scss @@ -18,12 +18,7 @@ position: relative; z-index: 1; outline: none !important; - box-shadow: none !important; transform: scale(1.05); - - & .poster { - box-shadow: 0 0 0 3px var(--highlight-color, variables.$white), 0 8px 10px rgb(0 0 0 / 14%), 0 3px 14px rgb(0 0 0 / 12%), 0 4px 5px rgb(0 0 0 / 20%); - } } } @@ -67,10 +62,6 @@ &:hover { transform: scale(1); cursor: default; - - & .poster { - box-shadow: none; - } } } } @@ -84,8 +75,6 @@ background-position: center; background-size: cover; border-radius: 4px; - box-shadow: 0 8px 10px rgb(0 0 0 / 14%), 0 3px 14px rgb(0 0 0 / 12%), 0 4px 5px rgb(0 0 0 / 20%); - transition: box-shadow 0.1s ease; &.current::after { position: absolute; diff --git a/packages/ui-react/src/components/Hero/Hero.module.scss b/packages/ui-react/src/components/Hero/Hero.module.scss index 192923975..a67c7a8d3 100644 --- a/packages/ui-react/src/components/Hero/Hero.module.scss +++ b/packages/ui-react/src/components/Hero/Hero.module.scss @@ -32,7 +32,7 @@ } .poster { - position: absolute; + position: fixed; top: 0; right: 0; z-index: -1; @@ -41,13 +41,14 @@ max-height: 700px; object-fit: cover; object-position: center 30%; + transition: opacity ease-out 0.3s; -webkit-mask-image: radial-gradient(farthest-corner at 80% 30%, rgba(0, 0, 0, 0.8) 20%, rgb(0 0 0 / 21%) 45%, rgb(0 0 0 / 0%) 73%); mask-image: radial-gradient(farthest-corner at 80% 30%, rgba(0, 0, 0, 0.8) 20%, rgb(0 0 0 / 21%) 45%, rgb(0 0 0 / 0%) 73%); } .posterFade { - position: absolute; + position: fixed; top: 0; right: 0; left: 0; diff --git a/packages/ui-react/src/components/Hero/Hero.tsx b/packages/ui-react/src/components/Hero/Hero.tsx index 626b6d5f2..64f0f6a9c 100644 --- a/packages/ui-react/src/components/Hero/Hero.tsx +++ b/packages/ui-react/src/components/Hero/Hero.tsx @@ -1,6 +1,8 @@ -import React, { type PropsWithChildren } from 'react'; +import React, { useRef, type PropsWithChildren } from 'react'; import Image from '../Image/Image'; +import { useScrolledDown } from '../../hooks/useScrolledDown'; +import useBreakpoint, { Breakpoint } from '../../hooks/useBreakpoint'; import styles from './Hero.module.scss'; @@ -10,10 +12,17 @@ type Props = PropsWithChildren<{ const Hero = ({ image, children }: Props) => { const alt = ''; // intentionally empty for a11y, because adjacent text alternative + const posterRef = useRef(null); + const breakpoint = useBreakpoint(); + const isMobile = breakpoint <= Breakpoint.sm; + + useScrolledDown(50, isMobile ? 150 : 500, (progress: number) => { + if (posterRef.current) posterRef.current.style.opacity = `${Math.max(1 - progress, 0.1)}`; + }); return (
- {alt} + {alt}
{children}
diff --git a/packages/ui-react/src/components/HeroShelf/HeroShelf.module.scss b/packages/ui-react/src/components/HeroShelf/HeroShelf.module.scss index d6e40e3d0..be328ccd5 100644 --- a/packages/ui-react/src/components/HeroShelf/HeroShelf.module.scss +++ b/packages/ui-react/src/components/HeroShelf/HeroShelf.module.scss @@ -8,7 +8,7 @@ $desktop-max-height: 700px; $desktop-min-height: 275px; $tablet-height: 70vw; -$tablet-min-height: 375px; +$tablet-min-height: 550px; $mobile-height: 70vh; $mobile-min-height: 450px; @@ -47,12 +47,13 @@ $mobile-landscape-height: 100vh; } @include responsive.mobile-only() { - height: calc($mobile-height - variables.$header-height - var(--safe-area-top, 0)); + height: calc($mobile-height - variables.$header-height - var(--safe-area-top, 0px)); min-height: calc($mobile-min-height - variables.$header-height); } @include responsive.mobile-only-landscape() { height: calc($mobile-landscape-height - variables.$header-height); + min-height: initial; padding: 0; } } @@ -106,22 +107,15 @@ $mobile-landscape-height: 100vh; @include responsive.mobile-only() { height: $mobile-height; min-height: $mobile-min-height; + margin-top: var(--safe-area-top, 0); } @include responsive.mobile-only-landscape() { height: $mobile-landscape-height; + min-height: initial; } } -.undimmed { - opacity: 1; - transition: opacity ease-out 0.3s; -} - -.dimmed { - opacity: 0.01; -} - .background { position: absolute; top: 0; @@ -145,13 +139,13 @@ $mobile-landscape-height: 100vh; } @include responsive.tablet-small-only() { - -webkit-mask-image: linear-gradient(190deg, black, rgba(0, 0, 0, 0.8) 40%, transparent 90%); - mask-image: linear-gradient(190deg, black, rgba(0, 0, 0, 0.8) 40%, transparent 90%); + -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 1) 30%, transparent 90%); + mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 1) 30%, transparent 90%); } @include responsive.mobile-only() { - -webkit-mask-image: linear-gradient(190deg, black, rgba(0, 0, 0, 0.8) 40%, transparent 85%); - mask-image: linear-gradient(190deg, black, rgba(0, 0, 0, 0.8) 40%, transparent 85%); + -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 1) 30%, transparent 75%); + mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 1) 30%, transparent 75%); } } @@ -184,7 +178,14 @@ $mobile-landscape-height: 100vh; } } -.metadataMobile { +.swipeSlider { + display: flex; + align-items: flex-end; + width: 100%; + height: 100%; +} + +.swipeSliderMobile { width: 100%; height: 100%; } @@ -194,7 +195,8 @@ $mobile-landscape-height: 100vh; display: flex; flex-direction: column; gap: 24px; - max-width: 46%; + min-width: 50%; + max-width: 50%; padding-left: calc(variables.$base-spacing * 4); > h2 { @@ -230,12 +232,23 @@ $mobile-landscape-height: 100vh; } @include responsive.mobile-only-landscape() { - max-width: 70%; + min-height: initial; padding: 0 calc(variables.$base-spacing * 3) calc(variables.$base-spacing * 3) calc(variables.$base-spacing * 3); + + > h2 { + font-size: 24px; + } + > div { + font-size: 14px; + } } @include responsive.tablet-small-only() { padding: 0 calc(variables.$base-spacing * 3) calc(variables.$base-spacing * 3) calc(variables.$base-spacing * 3); + + > div > button { + width: auto !important; // Override StartWatchingButton fullWidth prop + } } @include responsive.mobile-only() { diff --git a/packages/ui-react/src/components/HeroShelf/HeroShelf.tsx b/packages/ui-react/src/components/HeroShelf/HeroShelf.tsx index e6369283e..c580f8a6f 100644 --- a/packages/ui-react/src/components/HeroShelf/HeroShelf.tsx +++ b/packages/ui-react/src/components/HeroShelf/HeroShelf.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState, type CSSProperties, type TransitionEventHandler } from 'react'; +import React, { useCallback, useEffect, useRef, useState, type CSSProperties, type TransitionEventHandler } from 'react'; import type { Playlist } from '@jwp/ott-common/types/playlist'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,7 @@ import styles from './HeroShelf.module.scss'; import HeroShelfMetadata from './HeroShelfMetadata'; import HeroShelfBackground from './HeroShelfBackground'; import HeroShelfPagination from './HeroShelfPagination'; -import HeroShelfMetadataMobile from './HeroShelfMetadataMobile'; +import HeroSwipeSlider from './HeroSwipeSlider'; type Props = { playlist: Playlist; @@ -27,20 +27,28 @@ const HeroShelf = ({ playlist, loading = false, error = null }: Props) => { const { t } = useTranslation('common'); const breakpoint = useBreakpoint(); const isMobile = breakpoint <= Breakpoint.sm; - const scrolledDown = useScrolledDown(500, 200); + const posterRef = useRef(null); const [direction, setDirection] = useState<'left' | 'right' | null>(null); const [animationPhase, setAnimationPhase] = useState<'init' | 'start' | 'end' | null>(null); + const [isSwipeAnimation, setIsSwipeAnimation] = useState(false); - const slideTo = (toIndex: number) => { + useScrolledDown(50, isMobile ? 200 : 600, (progress: number) => { + if (posterRef.current) posterRef.current.style.opacity = `${Math.max(1 - progress, isMobile ? 0 : 0.1)}`; + }); + + const slideTo = (toIndex: number, isSwiping = false) => { if (animationPhase) return; setNextIndex(toIndex); setDirection(toIndex > index ? 'right' : 'left'); setAnimationPhase('init'); + setIsSwipeAnimation(isSwiping); }; - const slideLeft = () => slideTo(index - 1); - const slideRight = () => slideTo(index + 1); + const handleSlideLeft = () => slideTo(index - 1); + const handleSlideRight = () => slideTo(index + 1); + const handleSwipeLeft = () => slideTo(index - 1, true); + const handleSwipeRight = () => slideTo(index + 1, true); const handleBackgroundAnimationEnd: TransitionEventHandler = useCallback( (event) => { @@ -62,39 +70,60 @@ const HeroShelf = ({ playlist, loading = false, error = null }: Props) => { setIndex(nextIndex); setDirection(null); setAnimationPhase(null); + setIsSwipeAnimation(false); } }, [animationPhase, direction, nextIndex]); const isAnimating = animationPhase === 'start' || animationPhase === 'end'; const directionFactor = direction === 'left' ? 1 : direction === 'right' ? -1 : 0; - // Background animation - const backgroundX = isMobile ? 10 : 40; - const backgroundCurrentStyle: CSSProperties = { - transform: `scale(1.2) translateX(${isAnimating ? backgroundX * directionFactor : 0}px)`, - opacity: isAnimating ? 0 : 1, - transition: isAnimating ? `opacity ${isMobile ? 0.3 : 0.1}s ease-out, transform 0.3s ease-in` : 'none', - }; - const backgroundAltStyle: CSSProperties = { - transform: `scale(1.2) translateX(${animationPhase === 'init' ? backgroundX * directionFactor * -1 : 0}px)`, - opacity: isAnimating ? 1 : 0, - transition: isAnimating ? 'opacity 0.3s ease-out, transform 0.3s ease-out' : 'none', - }; + const getBackgroundStyle = (side?: 'left' | 'right') => { + const backgroundX = isMobile ? 10 : 40; - // Metadata animation - const left = 60; - const metadataCurrentStyle: CSSProperties = { - left: isAnimating && direction ? left * directionFactor : 0, - opacity: isAnimating ? 0 : 1, - transition: isAnimating ? 'opacity 0.15s ease-out, left 0.15s ease-out' : 'none', - pointerEvents: isAnimating ? 'none' : 'initial', + if (side == 'left') { + return { + transform: `scale(1.2) translateX(${animationPhase === 'init' ? backgroundX * -1 : 0}px)`, + opacity: isAnimating ? 1 : 0, + transition: isAnimating ? 'opacity 0.3s ease-out, transform 0.3s ease-out' : 'none', + }; + } + if (side == 'right') { + return { + transform: `scale(1.2) translateX(${animationPhase === 'init' ? backgroundX : 0}px)`, + opacity: isAnimating ? 1 : 0, + transition: isAnimating ? 'opacity 0.3s ease-out, transform 0.3s ease-out' : 'none', + }; + } + return { + transform: `scale(1.2) translateX(${isAnimating ? backgroundX * directionFactor : 0}px)`, + opacity: isAnimating ? 0 : 1, + transition: isAnimating ? `opacity ${isMobile ? 0.3 : 0.1}s ease-out, transform 0.3s ease-in` : 'none', + }; }; - const metadataAltStyle: CSSProperties = { - left: animationPhase === 'init' ? left * directionFactor * -1 : 0, - opacity: isAnimating ? 1 : 0, - transition: isAnimating ? 'opacity 0.2s ease-out, left 0.2s ease-out' : 'none', - pointerEvents: 'none', + const getMetadataStyle = (side?: 'left' | 'right', isSwiping = false): CSSProperties => { + if (side === 'left') { + return { + left: isSwiping || isSwipeAnimation ? '-100%' : animationPhase === 'init' ? -60 : 0, + opacity: isSwiping || isSwipeAnimation || isAnimating ? 1 : 0, + transition: isAnimating ? 'opacity 0.2s ease-out, left 0.2s ease-out' : 'none', + pointerEvents: 'none', + }; + } + if (side === 'right') { + return { + left: isSwiping || isSwipeAnimation ? '100%' : animationPhase === 'init' ? 60 : 0, + opacity: isSwiping || isSwipeAnimation || isAnimating ? 1 : 0, + transition: isAnimating ? 'opacity 0.2s ease-out, left 0.2s ease-out' : 'none', + pointerEvents: 'none', + }; + } + return { + left: isAnimating && direction ? 60 * directionFactor : 0, + opacity: isAnimating ? 0 : 1, + transition: isAnimating ? 'opacity 0.15s ease-out, left 0.15s ease-out' : 'none', + pointerEvents: isAnimating ? 'none' : 'initial', + }; }; const item = playlist.playlist[index]; @@ -102,24 +131,23 @@ const HeroShelf = ({ playlist, loading = false, error = null }: Props) => { const rightItem = playlist.playlist[nextIndex > index ? nextIndex : index + 1] || null; const renderedItem = animationPhase !== 'end' ? item : direction === 'right' ? leftItem : rightItem; - const altItem = direction === 'right' ? rightItem : leftItem; if (error || !playlist?.playlist) return

Could not load items

; return (
-
+
); }; diff --git a/packages/ui-react/src/components/HeroShelf/HeroShelfMetadata.tsx b/packages/ui-react/src/components/HeroShelf/HeroShelfMetadata.tsx index 2fb04c94b..7e40db160 100644 --- a/packages/ui-react/src/components/HeroShelf/HeroShelfMetadata.tsx +++ b/packages/ui-react/src/components/HeroShelf/HeroShelfMetadata.tsx @@ -12,6 +12,7 @@ import TruncatedText from '../TruncatedText/TruncatedText'; import StartWatchingButton from '../../containers/StartWatchingButton/StartWatchingButton'; import Button from '../Button/Button'; import Icon from '../Icon/Icon'; +import useBreakpoint, { Breakpoint } from '../../hooks/useBreakpoint'; import styles from './HeroShelf.module.scss'; @@ -21,17 +22,17 @@ const HeroShelfMetadata = ({ playlistId, style, hidden, - isMobile, }: { item: PlaylistItem | null; loading: boolean; playlistId: string | undefined; - style?: CSSProperties; + style: CSSProperties; hidden?: boolean; - isMobile?: boolean; }) => { const navigate = useNavigate(); const { t } = useTranslation('common'); + const breakpoint = useBreakpoint(); + const isMobile = breakpoint < Breakpoint.sm; if (!item) return null; diff --git a/packages/ui-react/src/components/HeroShelf/HeroShelfPagination.tsx b/packages/ui-react/src/components/HeroShelf/HeroShelfPagination.tsx index b5832a0da..cd2e04575 100644 --- a/packages/ui-react/src/components/HeroShelf/HeroShelfPagination.tsx +++ b/packages/ui-react/src/components/HeroShelf/HeroShelfPagination.tsx @@ -35,19 +35,9 @@ type Props = { setIndex: (index: number) => void; range?: number; animationDuration?: number; - className?: string; }; -const HeroShelfPagination = ({ - playlist, - index: indexIn, - direction, - nextIndex: nextIndexIn, - setIndex, - range = 3, - animationDuration = 200, - className, -}: Props) => { +const HeroShelfPagination = ({ playlist, index: indexIn, direction, nextIndex: nextIndexIn, setIndex, range = 3, animationDuration = 200 }: Props) => { const { t } = useTranslation('common'); const placeholderCount = range + 1; // Placeholders are used to keep a stable amount of DOM elements const index = indexIn + placeholderCount; @@ -60,7 +50,7 @@ const HeroShelfPagination = ({ }, [playlist.playlist, placeholderCount]); return ( -