import {useCallback, useEffect, 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 {formatPlaytime} from '../utils/games.js'; import {request} from '../utils/api.js'; import useAbortPrevious from '../hooks/useAbortPrevious.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']; const PICK_COUNT = 3; // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. const MAX_EXCLUDES = 100; const pickerQuery = (maxMinutes, mood, exclude) => { const params = new URLSearchParams({count: String(PICK_COUNT)}); if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (mood !== 'ANY') params.set('mood', mood); exclude.forEach((appid) => params.append('exclude', String(appid))); return params.toString(); }; export default function PlayNextPage() { const {t} = useTranslation(); const [budget, setBudget] = useState(BUDGETS[3]); const [mood, setMood] = useState('ANY'); const [games, setGames] = useState(null); const [seen, setSeen] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // A slow answer for an older budget or mood must not replace the newer one. const nextSignal = useAbortPrevious(); const load = useCallback(async (maxMinutes, moodKey, exclude) => { const signal = nextSignal(); setLoading(true); setError(null); const {ok, status, data} = await request( `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal}); if (signal.aborted) return; setLoading(false); if (status === 401) return; if (!ok || !data) { setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD'); return; } const picked = data.games ?? []; setGames(picked); setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES)); // No `t` here: a language change gives a new `t`, and a reload would replace the random picks. }, [nextSignal]); useEffect(() => { load(budget.maxMinutes, mood, []); }, [budget, mood, load]); const chooseBudget = (next) => { if (next.key === budget.key) return; setGames(null); setSeen([]); setBudget(next); }; const chooseMood = (next) => { if (next === mood) return; setGames(null); setSeen([]); setMood(next); }; const allShown = games?.length === 0 && seen.length > 0; 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')} )} {error && {t(error)}} {games === null && !error && ( {Array.from({length: PICK_COUNT}, (_, i) => )} )} {games?.length > 0 && ( {games.map((game) => )} )} {(games?.length > 0 || error) && ( load(budget.maxMinutes, mood, seen)} disabled={loading}> {loading ? : } {t('PLAY_NEXT.REROLL')} )} {allShown && ( {t('PLAY_NEXT.ALL_SHOWN')} load(budget.maxMinutes, mood, [])} disabled={loading}> {t('PLAY_NEXT.START_OVER')} )} {games?.length === 0 && seen.length === 0 && ( {t(emptyKey(budget, mood))} )} ); } 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} = useTranslation(); const [busy, setBusy] = useState(false); const [started, setStarted] = useState(false); 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 ( {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(error)}
{t('PLAY_NEXT.ALL_SHOWN')}
{t(emptyKey(budget, mood))}
{t('PLAY_NEXT.STARTED')}