Public Access
Four rows: up next, because you played, longest on list, mood. Each row rerolls alone and tracks its own loading state. Mood change reloads only the mood row. Phones scroll each row sideways; wider screens show 3 columns.
332 lines
14 KiB
React
332 lines
14 KiB
React
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 (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<div className="space-y-2">
|
|
<h1 className="text-3xl font-bold tracking-tight">{t('PLAY_NEXT.TITLE')}</h1>
|
|
<p className="text-muted-foreground">{t('PLAY_NEXT.SUBTITLE')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.BUDGET_LABEL')}>
|
|
{BUDGETS.map((b) => (
|
|
<Button
|
|
key={b.key}
|
|
size="sm"
|
|
variant={budget.key === b.key ? 'default' : 'outline'}
|
|
aria-pressed={budget.key === b.key}
|
|
onClick={() => chooseBudget(b)}
|
|
>
|
|
{t(`PLAY_NEXT.BUDGET.${b.key}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
{budget.maxMinutes && (
|
|
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.BUDGET_HINT')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.MOOD_LABEL')}>
|
|
{MOODS.map((key) => (
|
|
<Button
|
|
key={key}
|
|
size="sm"
|
|
variant={mood === key ? 'default' : 'outline'}
|
|
aria-pressed={mood === key}
|
|
onClick={() => chooseMood(key)}
|
|
>
|
|
{t(`PLAY_NEXT.MOOD.${key}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
{mood !== 'ANY' && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
|
|
</p>
|
|
)}
|
|
{moodRowMissing && (
|
|
<p className="text-muted-foreground">
|
|
{t(emptyKey(budget, mood))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-destructive">{t(error)}</p>
|
|
<Button variant="outline" onClick={() => loadAll(budget.maxMinutes, mood)}>
|
|
<Shuffle/>{t('PLAY_NEXT.REROLL')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{rows === null && !error && ROW_ORDER.map((source) => (
|
|
<div key={source} className={ROW_CLASS}>
|
|
{Array.from({length: 3}, (_, i) => <Skeleton key={i} className={`h-56 rounded-xl ${CARD_CLASS}`}/>)}
|
|
</div>
|
|
))}
|
|
|
|
{rows?.map(({source, games}) => (
|
|
<section key={source} aria-labelledby={`row-${source}`} className="min-w-0 space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 id={`row-${source}`} className="text-xl font-semibold">{headingFor(source)}</h2>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
aria-label={t('PLAY_NEXT.REROLL_ROW', {category: headingFor(source)})}
|
|
onClick={() => loadRow(source, seen[source] ?? [], budget.maxMinutes, mood)}
|
|
disabled={loading.has(source)}
|
|
>
|
|
{loading.has(source) ? <Loader2 className="animate-spin"/> : <Shuffle/>}
|
|
</Button>
|
|
</div>
|
|
<div className={ROW_CLASS}>
|
|
{games.map((game) => (
|
|
<div key={game.appid} className={CARD_CLASS}><PickCard game={game}/></div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
|
|
{rows?.length === 0 && !moodRowMissing && (
|
|
<p className="text-muted-foreground">
|
|
{t(emptyKey(budget, 'ANY'))}
|
|
</p>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Card className="gap-3 overflow-hidden p-0">
|
|
<Link to={`/library/${game.appid}`} className="block">
|
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full"/>
|
|
</Link>
|
|
<div className="flex flex-1 flex-col gap-3 px-4 pb-4">
|
|
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
|
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
|
</Link>
|
|
{reason && <p className="text-sm text-muted-foreground">{reason}</p>}
|
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
{game.timeToBeatMinutes > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Timer className="size-4"/>{formatPlaytime(game.timeToBeatMinutes)}
|
|
</span>
|
|
)}
|
|
{game.priority && (
|
|
<Badge variant="secondary">
|
|
{t('BACKLOG.PRIORITY_LABEL')}: {t(`BACKLOG.PRIORITY.${game.priority}`)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-auto">
|
|
{started ? (
|
|
<p className="flex items-center gap-1.5 text-sm font-medium text-primary">
|
|
<Check className="size-4"/>{t('PLAY_NEXT.STARTED')}
|
|
</p>
|
|
) : (
|
|
<Button size="sm" className="w-full" onClick={startPlaying} disabled={busy}>
|
|
{busy ? <Loader2 className="animate-spin"/> : <Play/>}
|
|
{t('PLAY_NEXT.START_PLAYING')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|