diff --git a/package.json b/package.json index 35d14e37..2c47616f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", + "hls.js": "^1.5.12", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", diff --git a/src/renderer/index.html b/src/renderer/index.html index 42166e56..6284effc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra Launcher diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4c3c1bd2..a1666ede 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -10,3 +10,4 @@ export * from "./use-download-options-listener"; export * from "./use-game-card"; export * from "./use-search-history"; export * from "./use-search-suggestions"; +export * from "./use-hls-video"; diff --git a/src/renderer/src/hooks/use-hls-video.ts b/src/renderer/src/hooks/use-hls-video.ts new file mode 100644 index 00000000..c861d0cf --- /dev/null +++ b/src/renderer/src/hooks/use-hls-video.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import Hls from "hls.js"; +import { logger } from "@renderer/logger"; + +interface UseHlsVideoOptions { + videoSrc: string | undefined; + videoType: string | undefined; + autoplay?: boolean; + muted?: boolean; + loop?: boolean; +} + +export function useHlsVideo( + videoRef: React.RefObject, + { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions +) { + const hlsRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !videoSrc) return; + + const isHls = videoType === "application/x-mpegURL"; + + if (!isHls) { + return; + } + + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + }); + + hlsRef.current = hls; + + hls.loadSource(videoSrc); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + }); + + hls.on(Hls.Events.ERROR, (event, data) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + logger.error("HLS network error, trying to recover"); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + logger.error("HLS media error, trying to recover"); + hls.recoverMediaError(); + break; + default: + logger.error("HLS fatal error, destroying instance"); + hls.destroy(); + break; + } + } + }); + + return () => { + hls.destroy(); + hlsRef.current = null; + }; + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = videoSrc; + video.load(); + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + + return () => { + video.src = ""; + }; + } else { + logger.warn("HLS playback is not supported in this browser"); + } + }, [videoRef, videoSrc, videoType, autoplay, muted, loop]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (muted !== undefined) { + video.muted = muted; + } + if (loop !== undefined) { + video.loop = loop; + } + }, [videoRef, muted, loop]); + + return hlsRef.current; +} + diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index 56912fcc..e19cbf26 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -8,6 +8,7 @@ import { import useEmblaCarousel from "embla-carousel-react"; import { gameDetailsContext } from "@renderer/context"; import { useAppSelector } from "@renderer/hooks"; +import { VideoPlayer } from "./video-player"; import "./gallery-slider.scss"; export function GallerySlider() { @@ -106,8 +107,6 @@ export function GallerySlider() { if (shopDetails?.movies) { shopDetails.movies.forEach((video, index) => { - // Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1 - // Fallback to old format: mp4/webm if new formats are not available let videoSrc: string | undefined; let videoType: string | undefined; @@ -121,11 +120,9 @@ export function GallerySlider() { videoSrc = video.dash_av1; videoType = "application/dash+xml"; } else if (video.mp4?.max) { - // Fallback to old format videoSrc = video.mp4.max; videoType = "video/mp4"; } else if (video.webm?.max) { - // Fallback to webm if mp4 is not available videoSrc = video.webm.max; videoType = "video/webm"; } @@ -191,19 +188,17 @@ export function GallerySlider() { {mediaItems.map((item) => (
{item.type === "video" ? ( - + /> ) : ( (null); + const isHls = videoType === "application/x-mpegURL"; + + useHlsVideo(videoRef, { + videoSrc, + videoType, + autoplay, + muted, + loop, + }); + + if (isHls) { + return ( +