Skip to content
Open
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
126 changes: 102 additions & 24 deletions src/big-picture/src/components/pages/game/screenshot-carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
import { CaretLeftIcon, CaretRightIcon, PlayIcon } from "@phosphor-icons/react";
import type { SteamMovie, SteamScreenshot } from "@types";
import useEmblaCarousel from "embla-carousel-react";
import type { FocusOverrideTarget } from "../../../../services";
Expand All @@ -11,6 +11,7 @@ import {
useState,
} from "react";
import { getItemFocusTarget } from "../../../../helpers";
import { useUserPreferences } from "../../../../hooks";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import { FocusItem, HorizontalFocusGroup } from "../../../common";
import { useNavigationIsFocused, useNavigationStore } from "../../../../stores";
Expand All @@ -22,6 +23,8 @@ import {
} from "../navigation";
import { VideoPlayer } from "./video-player";

const PLAY_ICON_SIZE = 28;

interface ScreenshotCarouselProps {
screenshots: SteamScreenshot[];
videos: SteamMovie[];
Expand All @@ -44,9 +47,15 @@ interface ScreenshotCarouselSlideProps {
item: MediaItem;
index: number;
isSelected: boolean;
autoplayEnabled: boolean;
preferencesLoaded: boolean;
started: boolean;
isPlaying: boolean;
onFocused: (index: number) => void;
onSelectItem: (index: number) => void;
onSelectItem: (index: number, target: EventTarget | null) => void;
setVideoRef: (index: number, element: HTMLVideoElement | null) => void;
onVideoPlay: (index: number) => void;
onVideoPause: (index: number) => void;
leftNavigationTarget?: FocusOverrideTarget;
downNavigationTarget?: FocusOverrideTarget;
rightNavigationTarget?: FocusOverrideTarget;
Expand All @@ -56,9 +65,15 @@ function ScreenshotCarouselSlide({
item,
index,
isSelected,
autoplayEnabled,
preferencesLoaded,
started,
isPlaying,
onFocused,
onSelectItem,
setVideoRef,
onVideoPlay,
onVideoPause,
leftNavigationTarget,
downNavigationTarget,
rightNavigationTarget,
Expand Down Expand Up @@ -89,28 +104,41 @@ function ScreenshotCarouselSlide({
<button
type="button"
className="game-page__media-carousel-surface"
onClick={() => onSelectItem(index)}
onClick={(event) => onSelectItem(index, event.target)}
aria-label={`Media item ${index + 1}`}
>
{item.type === "video" ? (
<VideoPlayer
videoSrc={item.videoSrc}
videoType={item.videoType}
poster={item.poster}
autoplay={isSelected}
muted
loop
controls
style={{
width: "100%",
borderRadius: 8,
objectFit: "cover",
aspectRatio: "16 / 9",
}}
videoRef={(element) => {
setVideoRef(index, element);
}}
/>
<>
<VideoPlayer
videoSrc={item.videoSrc}
videoType={item.videoType}
poster={item.poster}
autoplay={autoplayEnabled ? isSelected : started}
load={autoplayEnabled || started}
muted
loop
controls={autoplayEnabled || started}
style={{
width: "100%",
borderRadius: 8,
objectFit: "cover",
aspectRatio: "16 / 9",
}}
videoRef={(element) => {
setVideoRef(index, element);
}}
onPlay={() => onVideoPlay(index)}
onPause={() => onVideoPause(index)}
/>

{preferencesLoaded && !autoplayEnabled && !isPlaying && (
<div className="game-page__media-carousel-play-overlay">
<div className="game-page__media-carousel-play-icon">
<PlayIcon size={PLAY_ICON_SIZE} weight="fill" />
</div>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</div>
)}
</>
) : (
<img
src={item.src}
Expand All @@ -134,11 +162,18 @@ export function ScreenshotCarousel({
}: Readonly<ScreenshotCarouselProps>) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
const [selectedIndex, setSelectedIndex] = useState(0);
const [startedIndices, setStartedIndices] = useState<Set<number>>(new Set());
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const carouselContainerRef = useRef<HTMLDivElement | null>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
const isFocusDrivenScrollRef = useRef(false);
const navigation = NavigationService.getInstance();
const currentFocusId = useNavigationStore((state) => state.currentFocusId);
const userPreferences = useUserPreferences();
const preferencesLoaded = userPreferences != null;
const autoplayEnabled = preferencesLoaded
? userPreferences.autoplayGameTrailers !== false
: false;

const mediaItems: MediaItem[] = useMemo(() => {
const items: MediaItem[] = [];
Expand Down Expand Up @@ -245,7 +280,7 @@ export function ScreenshotCarousel({
videoRefs.current.forEach((video, videoIndex) => {
if (!video) return;

if (videoIndex === index) {
if (videoIndex === index && autoplayEnabled) {
video.play().catch(() => {});
} else {
video.pause();
Expand All @@ -263,6 +298,7 @@ export function ScreenshotCarousel({

isFocusDrivenScrollRef.current = false;
}, [
autoplayEnabled,
currentFocusId,
emblaApi,
isFocusInsideCarousel,
Expand All @@ -286,6 +322,11 @@ export function ScreenshotCarousel({
setSelectedIndex(0);
}, [mediaItems.length, selectedIndex]);

useEffect(() => {
setStartedIndices(new Set());
setPlayingIndex(null);
}, [mediaItems]);

useEffect(() => {
if (!emblaApi || !currentFocusId) return;

Expand Down Expand Up @@ -318,12 +359,43 @@ export function ScreenshotCarousel({
);

const handleSelectItem = useCallback(
(index: number) => {
(index: number, target: EventTarget | null) => {
emblaApi?.scrollTo(index);

if (autoplayEnabled || index !== selectedIndex) return;

const video = videoRefs.current[index];

if (!startedIndices.has(index)) {
setStartedIndices((prev) => {
const next = new Set(prev);
next.add(index);
return next;
});
video?.play().catch(() => {});
return;
}

if (target instanceof HTMLVideoElement) return;

if (video && !video.paused) {
video.pause();
return;
}

video?.play().catch(() => {});
},
[emblaApi]
[autoplayEnabled, emblaApi, selectedIndex, startedIndices]
);

const handleVideoPlay = useCallback((index: number) => {
setPlayingIndex(index);
}, []);

const handleVideoPause = useCallback((index: number) => {
setPlayingIndex((current) => (current === index ? null : current));
}, []);

const setVideoRef = useCallback(
(index: number, element: HTMLVideoElement | null) => {
videoRefs.current[index] = element;
Expand Down Expand Up @@ -353,9 +425,15 @@ export function ScreenshotCarousel({
item={item}
index={index}
isSelected={index === selectedIndex}
autoplayEnabled={autoplayEnabled}
preferencesLoaded={preferencesLoaded}
started={startedIndices.has(index)}
isPlaying={playingIndex === index}
onFocused={handleSlideFocused}
onSelectItem={handleSelectItem}
setVideoRef={setVideoRef}
onVideoPlay={handleVideoPlay}
onVideoPause={handleVideoPause}
leftNavigationTarget={
index === 0
? getItemFocusTarget(BIG_PICTURE_SIDEBAR_ITEM_IDS.home)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,37 @@ interface VideoPlayerProps {
videoType?: string;
poster?: string;
autoplay?: boolean;
load?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
style?: React.CSSProperties;
videoRef?: (el: HTMLVideoElement | null) => void;
onPlay?: () => void;
onPause?: () => void;
}

export function VideoPlayer({
videoSrc,
videoType,
poster,
autoplay = false,
load = true,
muted = true,
loop = false,
controls = true,
style,
videoRef,
onPlay,
onPause,
}: Readonly<VideoPlayerProps>) {
const internalRef = useRef<HTMLVideoElement>(null);
const isHls = videoType === "application/x-mpegURL";

useHlsVideo(internalRef, {
videoSrc,
videoType,
load,
autoplay,
muted,
loop,
Expand All @@ -50,8 +57,11 @@ export function VideoPlayer({
loop={loop}
muted={muted}
autoPlay={autoplay}
preload={load ? "auto" : "none"}
playsInline
style={style}
onPlay={onPlay}
onPause={onPause}
>
<track kind="captions" />
</video>
Expand All @@ -66,8 +76,11 @@ export function VideoPlayer({
loop={loop}
muted={muted}
autoPlay={autoplay}
preload={load ? "auto" : "none"}
playsInline
style={style}
onPlay={onPlay}
onPause={onPause}
>
{videoSrc && <source src={videoSrc} type={videoType} />}
<track kind="captions" />
Expand Down
21 changes: 21 additions & 0 deletions src/big-picture/src/pages/game/game.scss
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@
}

&__media-carousel-surface {
position: relative;
width: 100%;
max-width: 100%;
box-sizing: border-box;
Expand All @@ -483,6 +484,26 @@
}
}

&__media-carousel-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}

&__media-carousel-play-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #fff;
background-color: rgba(0, 0, 0, 0.6);
}

&__media-carousel-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,50 @@
object-fit: cover;
}

&__video-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}

&__video-play-button {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;

&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
}

&__video-play-icon {
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: transform 0.2s ease;

.gallery-slider__video-play-button:hover & {
transform: scale(1.1);
}
}

&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export function GallerySlider() {
videoType={item.videoType}
poster={item.poster}
autoplay={autoplayEnabled && index === firstVideoIndex}
loadOnDemand={!autoplayEnabled}
loop
muted
controls
Expand Down
Loading
Loading