import {useCallback, useEffect, useRef, useState} from 'react'; import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import useSetStatus from '../hooks/useSetStatus.js'; import {formatDate} from '../utils/format.js'; import {formatPlaytime} from '../utils/games.js'; import {request} from '../utils/api.js'; // maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data. const BUDGETS = [ {key: 'SHORT', maxMinutes: 300}, {key: 'MEDIUM', maxMinutes: 900}, {key: 'LONG', maxMinutes: 2400}, {key: 'ANY', maxMinutes: null}, ]; // Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out // games without that data. const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; // The rows in the order Core sends them; RANDOM is the mood row. const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'FRIENDS', 'LONGEST', 'RANDOM']; // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. const MAX_EXCLUDES = 100; const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible'; const CARD_CLASS = 'w-64 shrink-0 snap-start sm:w-auto'; // The mood only filters the RANDOM row. const pickerQuery = (maxMinutes, mood, exclude, category) => { const params = new URLSearchParams(); if (category) params.set('category', category); if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (mood !== 'ANY' && (!category || category === 'RANDOM')) params.set('mood', mood); exclude.forEach((appid) => params.append('exclude', String(appid))); return params.toString(); }; const fetchPicker = (maxMinutes, mood, exclude, category, signal) => request( `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, mood, exclude, category)}`, {signal}); export default function PlayNextPage() { const {t} = useTranslation(); const [budget, setBudget] = useState(BUDGETS[3]); const [mood, setMood] = useState('ANY'); const [rows, setRows] = useState(null); const [seen, setSeen] = useState({}); const [error, setError] = useState(null); // Keys ('ALL' or a row) with a request in flight. const [loading, setLoading] = useState(() => new Set()); // One controller per request key ('ALL' or a row). A slow answer for an older budget, mood or reroll must not // replace the newer one. const controllers = useRef(new Map()); useEffect(() => { const current = controllers.current; return () => current.forEach((controller) => controller.abort()); }, []); const startLoading = useCallback((key) => setLoading((old) => new Set(old).add(key)), []); const finishLoading = useCallback((key) => setLoading((old) => { const next = new Set(old); next.delete(key); return next; }), []); const nextSignal = useCallback((key) => { controllers.current.get(key)?.abort(); const controller = new AbortController(); controllers.current.set(key, controller); return controller.signal; }, []); const loadAll = useCallback(async (maxMinutes, moodKey) => { controllers.current.forEach((controller) => controller.abort()); const signal = nextSignal('ALL'); setError(null); setLoading(new Set(['ALL'])); const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, [], null, signal); if (signal.aborted) return; finishLoading('ALL'); if (status === 401) return; if (!ok || !data) { setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD'); return; } const categories = data.categories ?? []; setRows(categories); setSeen(Object.fromEntries(categories.map((c) => [c.source, c.games.map((g) => g.appid)]))); }, [nextSignal, finishLoading]); const loadRow = useCallback(async (source, exclude, maxMinutes, moodKey) => { const signal = nextSignal(source); startLoading(source); const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, exclude, source, signal); if (signal.aborted) return; finishLoading(source); if (!ok || !data) { if (status !== 401) toast.error(t(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD')); return; } const row = (data.categories ?? []).find((c) => c.source === source); if (!row && exclude.length > 0) { // Every game that fits was shown: start the row over. await loadRow(source, [], maxMinutes, moodKey); return; } setRows((old) => ROW_ORDER .map((key) => (key === source ? row : old.find((r) => r.source === key))) .filter(Boolean)); setSeen((old) => ({ ...old, [source]: [...exclude, ...(row?.games ?? []).map((g) => g.appid)].slice(-MAX_EXCLUDES), })); }, [nextSignal, startLoading, finishLoading, t]); useEffect(() => { loadAll(BUDGETS[3].maxMinutes, 'ANY'); }, [loadAll]); const chooseBudget = (next) => { if (next.key === budget.key) return; setRows(null); setSeen({}); setBudget(next); loadAll(next.maxMinutes, mood); }; const chooseMood = (next) => { if (next === mood) return; setMood(next); if (rows === null) loadAll(budget.maxMinutes, next); else loadRow('RANDOM', [], budget.maxMinutes, next); }; const headingFor = (source) => (source === 'RANDOM' && mood !== 'ANY' ? t('PLAY_NEXT.CATEGORY.MOOD', {mood: t(`PLAY_NEXT.MOOD.${mood}`)}) : t(`PLAY_NEXT.CATEGORY.${source}`)); const moodRowMissing = rows !== null && mood !== 'ANY' && !loading.has('RANDOM') && !rows.some((r) => r.source === 'RANDOM'); return (

{t('PLAY_NEXT.TITLE')}

{t('PLAY_NEXT.SUBTITLE')}

{BUDGETS.map((b) => ( ))}
{budget.maxMinutes && (

{t('PLAY_NEXT.BUDGET_HINT')}

)}
{MOODS.map((key) => ( ))}
{mood !== 'ANY' && (

{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}

)} {moodRowMissing && (

{t(emptyKey(budget, mood))}

)}
{error && (

{t(error)}

)} {rows === null && !error && ROW_ORDER.map((source) => (
{Array.from({length: 3}, (_, i) => )}
))} {rows?.map(({source, games}) => (

{headingFor(source)}

{games.map((game) => (
))}
))} {rows?.length === 0 && !moodRowMissing && (

{t(emptyKey(budget, 'ANY'))}

)}
); } // The row heading says why a NEXT_UP or RANDOM game was picked. const reasonText = (reason, t, language) => { if (reason?.source === 'SIMILAR') { return t('PLAY_NEXT.REASON.SIMILAR', { name: reason.anchorName ?? `App ${reason.anchorAppid}`, playtime: formatPlaytime(reason.anchorPlaytimeMinutes), }); } if (reason?.source === 'SERIES') { return t('PLAY_NEXT.REASON.SERIES', {name: reason.seriesName}); } if (reason?.source === 'STUDIO') { return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName}); } if (reason?.source === 'FRIENDS') { return reason.friendFinished ? t('PLAY_NEXT.REASON.FRIENDS_FINISHED', {name: reason.friendName}) : t('PLAY_NEXT.REASON.FRIENDS_PLAYED', {name: reason.friendName, playtime: formatPlaytime(reason.friendPlaytimeMinutes)}); } if (reason?.source === 'LONGEST') { return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); } return null; }; const emptyKey = (budget, mood) => { // With a length limit, waiting for the background check may never help. if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN'; if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD'; return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY'; }; function PickCard({game}) { const {t, i18n} = useTranslation(); const [started, setStarted] = useState(false); const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: () => setStarted(true)}); const reason = reasonText(game.reason, t, i18n.language); return (
{reason &&

{reason}

}
{game.timeToBeatMinutes > 0 && ( {formatPlaytime(game.timeToBeatMinutes)} )} {game.priority && ( {t('BACKLOG.PRIORITY_LABEL')}: {t(`BACKLOG.PRIORITY.${game.priority}`)} )}
{started ? (

{t('PLAY_NEXT.STARTED')}

) : ( )}
); }