diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0..d2665928 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -94,6 +94,12 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear history", + "remove_from_history": "Remove from history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4a..7846571e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -30,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = true; + private static readonly ADD_LOG_INTERCEPTOR = false; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index d3164ced..5f2c1d1d 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -3,12 +3,18 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; -import { useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { + useAppDispatch, + useAppSelector, + useSearchHistory, + useSearchSuggestions, +} from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; +import { SearchDropdown } from "@renderer/components"; const pathTitle: Record = { "/": "home", @@ -20,6 +26,7 @@ const pathTitle: Record = { export function Header() { const inputRef = useRef(null); + const searchContainerRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -37,6 +44,7 @@ export function Header() { ); const isOnLibraryPage = location.pathname.startsWith("/library"); + const isOnCataloguePage = location.pathname.startsWith("/catalogue"); const searchValue = isOnLibraryPage ? librarySearchValue @@ -45,9 +53,29 @@ export function Header() { const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [dropdownPosition, setDropdownPosition] = useState({ + x: 0, + y: 0, + }); const { t } = useTranslation("header"); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); + + const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( + searchValue, + isOnLibraryPage, + isDropdownVisible && isFocused && !isOnCataloguePage + ); + + const historyItems = getRecentHistory( + isOnLibraryPage ? "library" : "catalogue", + 3 + ); + const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle; @@ -59,13 +87,43 @@ export function Header() { return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); + const totalItems = historyItems.length + suggestions.length; + + const updateDropdownPosition = () => { + if (searchContainerRef.current) { + const rect = searchContainerRef.current.getBoundingClientRect(); + setDropdownPosition({ + x: rect.left, + y: rect.bottom, + }); + } + }; + const focusInput = () => { setIsFocused(true); inputRef.current?.focus(); }; + const handleFocus = () => { + if (isFocused && isDropdownVisible) { + updateDropdownPosition(); + return; + } + + setIsFocused(true); + setActiveIndex(-1); + setTimeout(() => { + updateDropdownPosition(); + setIsDropdownVisible(true); + }, 220); + }; + const handleBlur = () => { - setIsFocused(false); + setTimeout(() => { + setIsFocused(false); + setIsDropdownVisible(false); + setActiveIndex(-1); + }, 200); }; const handleBackButtonClick = () => { @@ -77,10 +135,37 @@ export function Header() { dispatch(setLibrarySearchQuery(value.slice(0, 255))); } else { dispatch(setFilters({ title: value.slice(0, 255) })); - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); - } } + setActiveIndex(-1); + }; + + const executeSearch = (query: string) => { + const context = isOnLibraryPage ? "library" : "catalogue"; + if (query.trim()) { + addToHistory(query, context); + } + handleSearch(query); + + if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + + setIsDropdownVisible(false); + inputRef.current?.blur(); + }; + + const handleSelectHistory = (query: string) => { + executeSearch(query); + }; + + const handleSelectSuggestion = (suggestion: { + title: string; + objectId: string; + shop: string; + }) => { + setIsDropdownVisible(false); + inputRef.current?.blur(); + navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); }; const handleClearSearch = () => { @@ -89,14 +174,79 @@ export function Header() { } else { dispatch(setFilters({ title: "" })); } + setActiveIndex(-1); + }; + + const handleRemoveHistoryItem = (query: string) => { + removeFromHistory(query); + }; + + const handleClearHistory = () => { + clearHistory(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (activeIndex >= 0 && activeIndex < totalItems) { + if (activeIndex < historyItems.length) { + handleSelectHistory(historyItems[activeIndex].query); + } else { + const suggestionIndex = activeIndex - historyItems.length; + handleSelectSuggestion(suggestions[suggestionIndex]); + } + } else if (searchValue.trim()) { + executeSearch(searchValue); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + if (!isDropdownVisible) { + setIsDropdownVisible(true); + updateDropdownPosition(); + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1)); + } else if (event.key === "Escape") { + event.preventDefault(); + setIsDropdownVisible(false); + setActiveIndex(-1); + inputRef.current?.blur(); + } + }; + + const handleCloseDropdown = () => { + setIsDropdownVisible(false); + setActiveIndex(-1); }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { + const prevPath = sessionStorage.getItem("prevPath"); + const currentPath = location.pathname; + + if ( + prevPath?.startsWith("/catalogue") && + !currentPath.startsWith("/catalogue") && + catalogueSearchValue + ) { dispatch(setFilters({ title: "" })); } + + sessionStorage.setItem("prevPath", currentPath); }, [location.pathname, catalogueSearchValue, dispatch]); + useEffect(() => { + if (!isDropdownVisible) return; + + const handleResize = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isDropdownVisible]); + return ( <>
handleSearch(event.target.value)} - onFocus={() => setIsFocused(true)} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {searchValue && ( @@ -165,6 +317,27 @@ export function Header() {
+ + 0 || + suggestions.length > 0 || + isLoadingSuggestions) + } + position={dropdownPosition} + historyItems={historyItems} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + onSelectHistory={handleSelectHistory} + onSelectSuggestion={handleSelectSuggestion} + onRemoveHistoryItem={handleRemoveHistoryItem} + onClearHistory={handleClearHistory} + onClose={handleCloseDropdown} + activeIndex={activeIndex} + currentQuery={searchValue} + searchContainerRef={searchContainerRef} + /> ); } diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 89dccdbc..e8876fcb 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -19,3 +19,4 @@ export * from "./context-menu/context-menu"; export * from "./game-context-menu/game-context-menu"; export * from "./game-context-menu/use-game-actions"; export * from "./star-rating/star-rating"; +export * from "./search-dropdown/search-dropdown"; diff --git a/src/renderer/src/components/search-dropdown/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx new file mode 100644 index 00000000..9950c8a1 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -0,0 +1,105 @@ +import React from "react"; + +interface HighlightTextProps { + text: string; + query: string; +} + +export function HighlightText({ text, query }: HighlightTextProps) { + if (!query.trim()) { + return <>{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const textWords = text.split(/\b/); + const matches: Array<{ start: number; end: number; text: string }> = []; + + let currentIndex = 0; + textWords.forEach((word) => { + const wordLower = word.toLowerCase(); + + queryWords.forEach((queryWord) => { + if (wordLower === queryWord) { + matches.push({ + start: currentIndex, + end: currentIndex + word.length, + text: word, + }); + } + }); + + currentIndex += word.length; + }); + + if (matches.length === 0) { + return <>{text}; + } + + matches.sort((a, b) => a.start - b.start); + + const mergedMatches: Array<{ start: number; end: number }> = []; + + if (matches.length === 0) { + return <>{text}; + } + + let current = matches[0]; + + for (let i = 1; i < matches.length; i++) { + if (matches[i].start <= current.end) { + current.end = Math.max(current.end, matches[i].end); + } else { + mergedMatches.push(current); + current = matches[i]; + } + } + mergedMatches.push(current); + + const parts: Array<{ text: string; highlight: boolean }> = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + }); + } + + return ( + <> + {parts.map((part, index) => + part.highlight ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ); +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss new file mode 100644 index 00000000..78a6fac1 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -0,0 +1,153 @@ +@use "../../scss/globals.scss"; + +.search-dropdown { + position: fixed; + background-color: globals.$dark-background-color; + border: 1px solid globals.$border-color; + border-radius: 8px; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + margin-top: 4px; + width: 250px; + + &__section { + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid globals.$border-color; + } + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 4px; + } + + &__section-title { + color: globals.$muted-color; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__clear-button { + color: globals.$muted-color; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all ease 0.2s; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: #dadbe1; + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + } + + &__item-container { + position: relative; + display: flex; + align-items: center; + + &:hover .search-dropdown__item-remove { + opacity: 1; + } + } + + &__item-remove { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + color: globals.$muted-color; + padding: 4px; + border-radius: 4px; + opacity: 0; + transition: all ease 0.15s; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + + &:hover { + color: #ff5555; + background-color: rgba(255, 85, 85, 0.1); + } + } + + &__item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + color: #dadbe1; + text-align: left; + border: none; + background: transparent; + + &:hover, + &--active { + background-color: globals.$background-color; + } + + &:focus { + outline: none; + } + } + + &__item-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: globals.$muted-color; + + &--image { + border-radius: 2px; + object-fit: cover; + } + } + + &__item-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } + + &__loading, + &__empty { + padding: 16px 12px; + text-align: center; + color: globals.$muted-color; + font-size: 14px; + } + + &__empty { + font-style: italic; + } + + &__highlight { + background-color: rgba(255, 193, 7, 0.3); + color: #ffc107; + font-weight: 600; + padding: 0 2px; + border-radius: 2px; + } +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx new file mode 100644 index 00000000..d90c3bf5 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ClockIcon, + SearchIcon, + TrashIcon, + XIcon, +} from "@primer/octicons-react"; +import cn from "classnames"; +import { useTranslation } from "react-i18next"; +import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; +import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions"; +import { HighlightText } from "./highlight-text"; +import "./search-dropdown.scss"; + +export interface SearchDropdownProps { + visible: boolean; + position: { x: number; y: number }; + historyItems: SearchHistoryEntry[]; + suggestions: SearchSuggestion[]; + isLoadingSuggestions: boolean; + onSelectHistory: (query: string) => void; + onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onRemoveHistoryItem: (query: string) => void; + onClearHistory: () => void; + onClose: () => void; + activeIndex: number; + currentQuery: string; + searchContainerRef?: React.RefObject; +} + +export function SearchDropdown({ + visible, + position, + historyItems, + suggestions, + isLoadingSuggestions, + onSelectHistory, + onSelectSuggestion, + onRemoveHistoryItem, + onClearHistory, + onClose, + activeIndex, + currentQuery, + searchContainerRef, +}: SearchDropdownProps) { + const dropdownRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const { t } = useTranslation("header"); + + useEffect(() => { + if (!visible) { + setAdjustedPosition(position); + return; + } + + const checkPosition = () => { + if (!dropdownRef.current) return; + + const rect = dropdownRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = position.x; + let adjustedY = position.y; + + if (adjustedX + 250 > viewportWidth - 10) { + adjustedX = Math.max(10, viewportWidth - 250 - 10); + } + + if (adjustedY + rect.height > viewportHeight - 10) { + adjustedY = Math.max(10, viewportHeight - rect.height - 10); + } + + setAdjustedPosition({ x: adjustedX, y: adjustedY }); + }; + + requestAnimationFrame(checkPosition); + }, [visible, position]); + + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if ( + dropdownRef.current && + !dropdownRef.current.contains(target) && + !searchContainerRef?.current?.contains(target) + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [visible, onClose, searchContainerRef]); + + const handleItemClick = useCallback( + ( + type: "history" | "suggestion", + item: SearchHistoryEntry | SearchSuggestion + ) => { + if (type === "history") { + onSelectHistory((item as SearchHistoryEntry).query); + } else { + onSelectSuggestion(item as SearchSuggestion); + } + }, + [onSelectHistory, onSelectSuggestion] + ); + + if (!visible) return null; + + const totalItems = historyItems.length + suggestions.length; + const hasHistory = historyItems.length > 0; + const hasSuggestions = suggestions.length > 0; + + const getItemIndex = ( + section: "history" | "suggestion", + indexInSection: number + ) => { + if (section === "history") { + return indexInSection; + } + return historyItems.length + indexInSection; + }; + + const dropdownContent = ( +
+ {hasHistory && ( +
+
+ + {t("recent_searches")} + + +
+
    + {historyItems.map((item, index) => ( +
  • + + +
  • + ))} +
+
+ )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} +
+ ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..4c3c1bd2 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,3 +8,5 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..dac6d391 --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,78 @@ +import { useState, useCallback, useEffect } from "react"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const STORAGE_KEY = "search-history"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as SearchHistoryEntry[]; + setHistory(parsed); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const addToHistory = useCallback( + (query: string, context: "library" | "catalogue") => { + if (!query.trim()) return; + + const newEntry: SearchHistoryEntry = { + query: query.trim(), + timestamp: Date.now(), + context, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() + ); + const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const getRecentHistory = useCallback( + (context: "library" | "catalogue", limit: number = 3) => { + return history + .filter((entry) => entry.context === context) + .slice(0, limit); + }, + [history] + ); + + return { + history, + addToHistory, + removeFromHistory, + clearHistory, + getRecentHistory, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..f2baa8db --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + source: "library" | "catalogue"; +} + +export function useSearchSuggestions( + query: string, + isOnLibraryPage: boolean, + enabled: boolean = true +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + + const getLibrarySuggestions = useCallback( + (searchQuery: string, limit: number = 3): SearchSuggestion[] => { + if (!searchQuery.trim()) return []; + + const queryLower = searchQuery.toLowerCase(); + const matches: SearchSuggestion[] = []; + + for (const game of library) { + if (matches.length >= limit) break; + + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + if (queryIndex === queryLower.length) { + matches.push({ + title: game.title, + objectId: game.objectId, + shop: game.shop, + iconUrl: game.iconUrl, + source: "library", + }); + } + } + + return matches; + }, + [library] + ); + + const fetchCatalogueSuggestions = useCallback( + async (searchQuery: string, limit: number = 3) => { + if (!searchQuery.trim() || searchQuery.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + Array<{ + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + }> + >("/catalogue/search/suggestions", { + params: { + query: searchQuery, + limit, + }, + needsAuth: false, + }); + + if (abortController.signal.aborted) return; + + const catalogueSuggestions: SearchSuggestion[] = response.map( + (item) => ({ + ...item, + source: "catalogue" as const, + }) + ); + + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, + [] + ); + + const debouncedFetchCatalogue = useRef( + debounce(fetchCatalogueSuggestions, 300) + ).current; + + useEffect(() => { + if (!enabled || !query || query.length < 2) { + setSuggestions([]); + setIsLoading(false); + abortControllerRef.current?.abort(); + debouncedFetchCatalogue.cancel(); + return; + } + + if (isOnLibraryPage) { + const librarySuggestions = getLibrarySuggestions(query, 3); + setSuggestions(librarySuggestions); + setIsLoading(false); + } else { + debouncedFetchCatalogue(query, 3); + } + + return () => { + debouncedFetchCatalogue.cancel(); + abortControllerRef.current?.abort(); + }; + }, [ + query, + isOnLibraryPage, + enabled, + getLibrarySuggestions, + debouncedFetchCatalogue, + ]); + + return { suggestions, isLoading }; +} 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 c9658636..56912fcc 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 @@ -100,20 +100,48 @@ export function GallerySlider() { src?: string; poster?: string; videoSrc?: string; + videoType?: string; alt: string; }> = []; if (shopDetails?.movies) { shopDetails.movies.forEach((video, index) => { - items.push({ - id: String(video.id), - type: "video", - poster: video.thumbnail, - videoSrc: video.mp4.max.startsWith("http://") - ? video.mp4.max.replace("http://", "https://") - : video.mp4.max, - alt: t("video", { number: String(index + 1) }), - }); + // 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; + + if (video.hls_h264) { + videoSrc = video.hls_h264; + videoType = "application/x-mpegURL"; + } else if (video.dash_h264) { + videoSrc = video.dash_h264; + videoType = "application/dash+xml"; + } else if (video.dash_av1) { + 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"; + } + + if (videoSrc) { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: videoSrc.startsWith("http://") + ? videoSrc.replace("http://", "https://") + : videoSrc, + videoType, + alt: video.name || t("video", { number: String(index + 1) }), + }); + } }); } @@ -172,7 +200,9 @@ export function GallerySlider() { autoPlay={autoplayEnabled} tabIndex={-1} > - + {item.videoSrc && ( + + )} ) : (