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 {saveGameStatus} from '../utils/backlog.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', 'SIMILAR', '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) => ( chooseBudget(b)} > {t(`PLAY_NEXT.BUDGET.${b.key}`)} ))} {budget.maxMinutes && ( {t('PLAY_NEXT.BUDGET_HINT')} )} {MOODS.map((key) => ( chooseMood(key)} > {t(`PLAY_NEXT.MOOD.${key}`)} ))} {mood !== 'ANY' && ( {t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')} )} {moodRowMissing && ( {t(emptyKey(budget, mood))} )} {error && ( {t(error)} loadAll(budget.maxMinutes, mood)}> {t('PLAY_NEXT.REROLL')} )} {rows === null && !error && ROW_ORDER.map((source) => ( {Array.from({length: 3}, (_, i) => )} ))} {rows?.map(({source, games}) => ( {headingFor(source)} loadRow(source, seen[source] ?? [], budget.maxMinutes, mood)} disabled={loading.has(source)} > {loading.has(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 === '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 [busy, setBusy] = useState(false); const [started, setStarted] = useState(false); const reason = reasonText(game.reason, t, i18n.language); const startPlaying = async () => { setBusy(true); try { const response = await saveGameStatus(game.appid, 'PLAYING'); if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; } setStarted(true); toast.success(t('BACKLOG.SAVED')); } catch (err) { console.error('Start playing error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); } finally { setBusy(false); } }; 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')} ) : ( {busy ? : } {t('PLAY_NEXT.START_PLAYING')} )} ); }
{t('PLAY_NEXT.SUBTITLE')}
{t('PLAY_NEXT.BUDGET_HINT')}
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
{t(emptyKey(budget, mood))}
{t(error)}
{t(emptyKey(budget, 'ANY'))}
{reason}
{t('PLAY_NEXT.STARTED')}