diff --git a/README.md b/README.md
index eb01bb8..53a815d 100644
--- a/README.md
+++ b/README.md
@@ -28,13 +28,14 @@ To Be Played is split into three repositories:
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming " badge in the library, on
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
`pileGames`, so coming games are not on it; Core never picks them.
-- **What to play next** (`/play-next`): four backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
- (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
- played a lot, waiting longest, random, or next in your own backlog order. Reroll without repeats, and "Start playing" in one click.
+- **What to play next** (`/play-next`): four rows of up to 3 backlog games each: up next (your own backlog order),
+ because you played (a similar game for each of your 3 most played), longest on your list, and mood picks. A time
+ budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
+ from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
+ On a phone each row scrolls sideways.
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
- move saves the whole list. The "Up next" dashboard tile shows the first games; the picker has a "next in your
- backlog" reason.
+ move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games.
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 587e2a4..fd64de4 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -431,12 +431,17 @@
},
"PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?",
- "SUBTITLE": "Vier Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende, eins zufällig und eins von ganz oben in deiner Reihenfolge. Wähle eine Länge und eine Stimmung.",
+ "SUBTITLE": "Spiele aus deinem Backlog in vier Reihen: als Nächstes in deiner Reihenfolge, wie Spiele, die du viel gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
"REASON": {
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
- "LONGEST": "Auf deiner Liste seit {{date}}",
+ "LONGEST": "Auf deiner Liste seit {{date}}"
+ },
+ "CATEGORY": {
+ "NEXT_UP": "Als Nächstes",
+ "SIMILAR": "Weil du gespielt hast",
+ "LONGEST": "Am längsten auf deiner Liste",
"RANDOM": "Zufallstreffer",
- "NEXT_UP": "Als Nächstes in deinem Backlog"
+ "MOOD": "Stimmung: {{mood}}"
},
"BUDGET_LABEL": "Spieldauer",
"BUDGET": {
@@ -447,10 +452,9 @@
},
"BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).",
"REROLL": "Andere Vorschläge",
+ "REROLL_ROW": "Andere Vorschläge für {{category}}",
"START_PLAYING": "Jetzt spielen",
"STARTED": "Wird gespielt",
- "ALL_SHOWN": "Du hast alle passenden Spiele gesehen.",
- "START_OVER": "Von vorn",
"EMPTY": "Dein Backlog ist leer. Stark!",
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 62e19bf..c8b85e0 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -431,12 +431,17 @@
},
"PLAY_NEXT": {
"TITLE": "What to play next",
- "SUBTITLE": "Four games from your backlog: one like a game you played a lot, the one waiting longest, one at random, and one from the top of your own order. Pick a length and a mood.",
+ "SUBTITLE": "Games from your backlog in four rows: next in your order, like games you played a lot, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
"REASON": {
"SIMILAR": "Because you played {{name}} ({{playtime}})",
- "LONGEST": "On your list since {{date}}",
- "RANDOM": "Random pick",
- "NEXT_UP": "Next in your backlog"
+ "LONGEST": "On your list since {{date}}"
+ },
+ "CATEGORY": {
+ "NEXT_UP": "Up next",
+ "SIMILAR": "Because you played",
+ "LONGEST": "Longest on your list",
+ "RANDOM": "Random picks",
+ "MOOD": "Mood: {{mood}}"
},
"BUDGET_LABEL": "Time to beat",
"BUDGET": {
@@ -447,10 +452,9 @@
},
"BUDGET_HINT": "Only games with a known time to beat (from IGDB).",
"REROLL": "Other picks",
+ "REROLL_ROW": "Other picks for {{category}}",
"START_PLAYING": "Start playing",
"STARTED": "Now playing",
- "ALL_SHOWN": "You have seen every game that fits.",
- "START_OVER": "Start over",
"EMPTY": "Your backlog is empty. Nice!",
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
"ERROR_LOAD": "Could not load the picks.",
diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx
index c8afc81..10b5154 100644
--- a/src/pages/PlayNextPage.jsx
+++ b/src/pages/PlayNextPage.jsx
@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -15,7 +15,6 @@ import {saveGameStatus} from '../utils/backlog.js';
import {formatDate} from '../utils/format.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 = [
@@ -27,67 +26,126 @@ const BUDGETS = [
// 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 = 4;
+// 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';
-const pickerQuery = (maxMinutes, mood, exclude) => {
- const params = new URLSearchParams({count: String(PICK_COUNT)});
+// 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') params.set('mood', mood);
+ 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 [games, setGames] = useState(null);
- const [seen, setSeen] = useState([]);
+ const [rows, setRows] = 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();
+ // 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());
- const load = useCallback(async (maxMinutes, moodKey, exclude) => {
- const signal = nextSignal();
- setLoading(true);
+ 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);
- const {ok, status, data} = await request(
- `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
+ setLoading(new Set(['ALL']));
+ const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, [], null, signal);
if (signal.aborted) return;
- setLoading(false);
+ finishLoading('ALL');
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]);
+ 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(() => {
- load(budget.maxMinutes, mood, []);
- }, [budget, mood, load]);
+ loadAll(BUDGETS[3].maxMinutes, 'ANY');
+ }, [loadAll]);
const chooseBudget = (next) => {
if (next.key === budget.key) return;
- setGames(null);
- setSeen([]);
+ setRows(null);
+ setSeen({});
setBudget(next);
+ loadAll(next.maxMinutes, mood);
};
const chooseMood = (next) => {
if (next === mood) return;
- setGames(null);
- setSeen([]);
setMood(next);
+ if (rows === null) loadAll(budget.maxMinutes, next);
+ else loadRow('RANDOM', [], budget.maxMinutes, next);
};
- const allShown = games?.length === 0 && seen.length > 0;
+ 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 (
@@ -134,60 +192,71 @@ export default function PlayNextPage() {
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}