diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecb8bf6..ff1320e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 1.3.0
+
+- Pinned games: □ pins the focused game at its place when you save. Opening other games no longer
+ pushes it out; the rest of the row still follows the system's rule (the last game opened takes the
+ first free place). A pin badge marks the game, a block marks it on the ruler, and the position reads
+ "pinned at 3rd".
+- Options: Pin the whole row, Unpin all, and Remove pinned games from every profile.
+- If the system rebuilds its database and the pins come loose, Lineup offers to pin the games again.
+- The change count includes pins.
+
## 1.2.0
- Profile pictures: in the header, under Options, and next to each name in Switch profile. A profile
diff --git a/README.md b/README.md
index 78d3fec..8b2fd9b 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,10 @@ out on the TV. Its local service then writes the new order where the home screen
Options menu.
- **Home screen edge.** The home screen shows only the first 12 games. A divider in the row and a mark
on the ruler show where that edge falls, and games past it read "not on the home screen".
-- **Sort A to Z**, discard changes, and a count of the games that moved.
+- **Pinned games.** □ pins the focused game where it stands when you save. Opening other games no longer
+ pushes it out of its place; the rest of the row still follows the system's rule. Options can pin the
+ whole row, unpin all, or remove pinned games from every profile. Works from 1.3.0 on.
+- **Sort A to Z**, discard changes, and a count of the games that changed.
- **Applies on exit.** Saving closes the app, and the home screen comes back with the new order. No
restart needed.
- **Updates in the app.** Lineup checks for a new release once a day (Options → Updates turns that off),
@@ -56,12 +59,13 @@ Use one or the other. Both register title ID `PPSA99077`.
| --- | --- | --- |
| ✕ | Lift the focused game | Drop it here |
| D-pad | Move focus | Carry the game |
+| □ | Pin or unpin the focused game | Pin or unpin it |
| △ | Save | |
| Options | Options menu | |
| ◯ | Quit (asks to save unsaved changes) | Put the game back |
-Lineup changes only the order of the home row. Opening a game afterwards still moves it to the front,
-as it always does.
+Opening a game afterwards still moves it to the front, as it always does, unless a pinned game holds
+that place.
## Safety
@@ -77,7 +81,8 @@ as it always does.
The app is written in React and runs natively on the console through
[PS5 React](#ps5-react). A small C service, `lineupd`, ships inside the app folder. The app starts it
through the ELF loader, and it answers on `127.0.0.1:9077`. It reads the per-profile tables in
-`/system_data/priv/mms/app.db` and writes the new order back.
+`/system_data/priv/mms/app.db` and writes the new order back. Pinned games are kept in place by SQLite
+triggers it adds to that database; `POST /pins/off` and `POST /pins/uninstall` take them out again.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the components, the service's HTTP API and what
was learned about how the home screen stores its row.
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 45a441b..46a608e 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -20,7 +20,10 @@ ordem na TV. Depois, o serviço local dele grava a nova ordem onde a tela inicia
- **Limite da tela inicial.** A tela inicial mostra só os 12 primeiros jogos. Um traço na fileira e uma
marca na régua mostram onde fica esse limite, e os jogos depois dele aparecem como "fora da tela
inicial".
-- **Ordenar de A a Z**, descartar mudanças e contagem de jogos que mudaram de lugar.
+- **Jogos fixos.** □ fixa o jogo em foco na posição em que ele estiver ao salvar. Abrir outros jogos não
+ tira ele do lugar; o resto da fileira segue a regra do sistema. Em Opções dá para fixar a fileira
+ toda, desafixar todos ou remover os jogos fixos de todos os perfis. Vale a partir da 1.3.0.
+- **Ordenar de A a Z**, descartar mudanças e contagem de jogos que mudaram.
- **Aplica ao sair.** Salvar fecha o app, e a tela inicial volta com a nova ordem. Não precisa
reiniciar.
- **Atualização no app.** O Lineup procura uma versão nova uma vez por dia (Opções → Atualizações
@@ -57,12 +60,13 @@ Use um ou outro. Os dois registram o title ID `PPSA99077`.
| --- | --- | --- |
| ✕ | Levanta o jogo em foco | Solta aqui |
| Direcional | Move o foco | Leva o jogo |
+| □ | Fixa ou desafixa o jogo em foco | Fixa ou desafixa o jogo |
| △ | Salva | |
| Options | Menu Opções | |
| ◯ | Sai (pergunta se quer salvar as mudanças) | Devolve o jogo |
-O Lineup muda só a ordem da fileira. Abrir um jogo depois continua trazendo ele para a frente, como
-sempre.
+Abrir um jogo depois continua trazendo ele para a frente, como sempre, a não ser que um jogo fixo ocupe
+esse lugar.
## Segurança
@@ -78,7 +82,8 @@ sempre.
O app é escrito em React e roda nativo no console pelo [PS5 React](#ps5-react). Um pequeno serviço em
C, o `lineupd`, vai dentro da pasta do app. O app inicia o serviço pelo ELF loader, e ele responde em
`127.0.0.1:9077`. Ele lê as tabelas de cada perfil em `/system_data/priv/mms/app.db` e grava a nova
-ordem.
+ordem. Os jogos fixos ficam no lugar graças a triggers do SQLite que ele cria nesse banco;
+`POST /pins/off` e `POST /pins/uninstall` tiram tudo de novo.
Veja [docs/pt-BR/ARQUITETURA.md](docs/pt-BR/ARQUITETURA.md) para os componentes, a API HTTP do serviço
e o que se descobriu sobre como a tela inicial guarda a fileira.
diff --git a/app/api.js b/app/api.js
index 5feb4e9..1221f8a 100644
--- a/app/api.js
+++ b/app/api.js
@@ -3,7 +3,7 @@
import {Archives, Downloads, FileSystem, Http, Payloads} from '@ps5-react/core';
import {T} from './i18n.js';
-export const VERSION = '1.2.0';
+export const VERSION = '1.3.0';
const BASE = 'http://127.0.0.1:9077';
/** An error the app shows as is: a short title and what to do about it. */
@@ -97,16 +97,21 @@ async function read(path, what) {
}
export const profiles = async () => (await read('/profiles', T.service.profiles)).profiles ?? [];
-export const row = async user => (await read(`/row?user=${user}`, T.service.row)).games ?? [];
+/** {games, pins}: each game's `pinned` is its slot or null; `pins` is 'on', 'missing' (triggers lost) or 'off'. */
+export async function row(user) {
+ const data = await read(`/row?user=${user}`, T.service.row);
+ return {games: data.games ?? [], pins: data.pins ?? 'off'};
+}
export const iconUrl = game => `${BASE}/icon/${game.titleId}?m=${game.icon}`;
export const avatarUrl = profile => `${BASE}/avatar/${profile.userId}?m=${profile.avatar}`;
/**
- * Saves `order` (titleIds) for `user`, sending each game's index as it was read. Resolves to
- * {ok: true} or {ok: false, stale, message}; `stale` means the row changed since it was read.
+ * Saves `order` (titleIds) for `user`, sending each game's index as it was read and pinning the games in `pins`
+ * where they stand. Resolves to {ok: true} or {ok: false, stale, message}; `stale` means the row changed since it
+ * was read.
*/
-export async function save(user, order, games) {
- const body = `LIN1\n${order.map(id => `${id} ${games[id].index}\n`).join('')}`;
+export async function save(user, order, games, pins) {
+ const body = `LIN1\n${order.map(id => `${id} ${games[id].index}${pins[id] ? ' pin' : ''}\n`).join('')}`;
let reply;
try {
reply = await call(`/row?user=${user}`, {method: 'POST', body});
@@ -119,6 +124,16 @@ export async function save(user, order, games) {
return {ok: false, stale: false, message: T.service.saveStatus(reply.status) + detail};
}
+/** Removes every profile's pinned games and all Lineup added to the home screen database. Resolves to an error message or ''. */
+export async function unpinAll() {
+ try {
+ const reply = await call('/pins/uninstall', {method: 'POST', timeoutMs: 15000});
+ return reply.ok ? '' : T.pins.removeFailed(reply.data.error ?? T.service.status(reply.status));
+ } catch {
+ return T.pins.removeFailed(T.pins.noAnswer);
+ }
+}
+
// ---- Updates ----
const UPDATE_DIR = '/data/lineup/update';
diff --git a/app/app.json b/app/app.json
index a3578f1..b18512e 100644
--- a/app/app.json
+++ b/app/app.json
@@ -3,7 +3,7 @@
"titleId": "PPSA99077",
"contentId": "UP9000-PPSA99077_00-LINEUP0000000000",
"name": "Lineup",
- "version": "01.002.000",
+ "version": "01.003.000",
"render": {
"width": 1920,
"height": 1080
diff --git a/app/i18n.js b/app/i18n.js
index aea169c..1b031fb 100644
--- a/app/i18n.js
+++ b/app/i18n.js
@@ -37,7 +37,22 @@ const en = {
of: n => `of ${n}`,
hints: {
lift: 'Lift', save: 'Save', options: 'Options', quit: 'Quit', move: 'Move', drop: 'Drop',
- cancel: 'Cancel', choose: 'Choose', back: 'Back', close: 'Close',
+ cancel: 'Cancel', choose: 'Choose', back: 'Back', close: 'Close', pin: 'Pin', unpin: 'Unpin',
+ },
+ pins: {
+ pinnedAt: n => `pinned at ${englishOrdinal(n)}`,
+ pinAll: 'Pin the whole row', unpinAll: 'Unpin all', removeAll: 'Remove pinned games from every profile',
+ removeTitle: 'Remove pinned games from every profile?',
+ removeBody: "Every profile goes back to the system's rule: the last game opened comes first. "
+ + 'The current order stays as it is.',
+ removeConfirm: 'Remove', cancel: 'Cancel',
+ removed: 'Pinned games removed from every profile.',
+ removeFailed: detail => `Could not remove the pinned games (${detail}).`,
+ noAnswer: 'the service did not answer',
+ missingTitle: 'The pinned games came loose',
+ missingBody: 'The home screen database was rebuilt or updated, so the games no longer hold their places. '
+ + 'Pin them again?',
+ reapply: 'Pin again', later: 'Not now',
},
saved: ['Order saved', 'Closing the app. The home screen comes back with the new order.'],
closeNow: 'Close now',
@@ -86,7 +101,7 @@ const en = {
profilesSubtitle: 'Each profile has its own row',
exitDialog: {
title: 'Save the new order before leaving?',
- body: n => (n === 1 ? 'One game moved.' : `${n} games moved.`),
+ body: n => (n === 1 ? 'One game changed.' : `${n} games changed.`),
save: 'Save and quit', discard: 'Quit without saving', back: 'Back',
},
switchDialog: {
@@ -124,7 +139,22 @@ const pt = {
of: n => `de ${n}`,
hints: {
lift: 'Levantar', save: 'Salvar', options: 'Opções', quit: 'Sair', move: 'Mover', drop: 'Soltar',
- cancel: 'Cancelar', choose: 'Escolher', back: 'Voltar', close: 'Fechar',
+ cancel: 'Cancelar', choose: 'Escolher', back: 'Voltar', close: 'Fechar', pin: 'Fixar', unpin: 'Desafixar',
+ },
+ pins: {
+ pinnedAt: n => `fixo no ${n}º`,
+ pinAll: 'Fixar toda a fileira', unpinAll: 'Desafixar todos', removeAll: 'Remover jogos fixos de todos os perfis',
+ removeTitle: 'Remover os jogos fixos de todos os perfis?',
+ removeBody: 'Todos os perfis voltam à regra do sistema: o último jogo aberto vai para a frente. '
+ + 'A ordem atual continua como está.',
+ removeConfirm: 'Remover', cancel: 'Cancelar',
+ removed: 'Jogos fixos removidos de todos os perfis.',
+ removeFailed: detail => `Não consegui remover os jogos fixos (${detail}).`,
+ noAnswer: 'o serviço não respondeu',
+ missingTitle: 'Os jogos fixos se soltaram',
+ missingBody: 'O banco de dados da tela inicial foi reconstruído ou atualizado, e os jogos não seguram mais o '
+ + 'lugar. Fixar de novo?',
+ reapply: 'Fixar de novo', later: 'Agora não',
},
saved: ['Ordem salva', 'Fechando o app. A tela inicial já volta com a nova ordem.'],
closeNow: 'Fechar agora',
@@ -173,7 +203,7 @@ const pt = {
profilesSubtitle: 'Cada perfil tem a sua fileira',
exitDialog: {
title: 'Salvar a nova ordem antes de sair?',
- body: n => (n === 1 ? 'Um jogo mudou de lugar.' : `${n} jogos mudaram de lugar.`),
+ body: n => (n === 1 ? 'Um jogo mudou.' : `${n} jogos mudaram.`),
save: 'Salvar e sair', discard: 'Sair sem salvar', back: 'Voltar',
},
switchDialog: {
diff --git a/app/index.jsx b/app/index.jsx
index 3a6e5a0..a6f4833 100644
--- a/app/index.jsx
+++ b/app/index.jsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Lineup reorders the PS5 home-screen game row. The lineupd service (payloads/lineupd.c) reads and
-// saves the row; this app draws it, lifts the focused game with X and carries it with the D-pad.
+// saves the row; this app draws it, lifts the focused game with X and carries it with the D-pad. Square pins
+// the focused game where it stands when the order is saved.
//
// Focus lives on invisible slots, one per position, laid out in a plain row so the focus manager moves
// between them. The games are drawn apart, keyed by title, and only their transforms move: the focused
@@ -13,8 +14,8 @@ import {useInterfaceSounds} from './sounds.js';
import * as api from './api.js';
import {LANGUAGE, T} from './i18n.js';
import {BOTTOM, DIGITS, EM, TOP} from './digits.js';
-import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, Sheet, SheetItem, SheetRule, Toast, Wash,
- px} from './ui.jsx';
+import {Avatar, Badge, Button, C, Dialog, Hints, LiftLight, Mark, Message, PinBadge, Sheet, SheetItem, SheetRule,
+ Toast, Wash, px} from './ui.jsx';
const LEFT = 64; // the focused game's left edge
const BIG = 168; // focused icon
@@ -35,7 +36,21 @@ const SPRING = {type: 'spring', stiffness: 420, damping: 40};
const TILE = {...SPRING, opacity: {type: 'tween', duration: 0.14}};
const sortKey = name => name.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
-const changed = s => s.order.reduce((count, id, i) => count + (s.home[id] !== i), 0);
+// Games moved or pinned/unpinned since the row was read. `pinned` maps the games read pinned to their slots.
+const changed = s => s.order.reduce((count, id, i) => count + (s.home[id] !== i || !s.pins[id] !== !(id in s.pinned)), 0);
+const allPinned = pinned => Object.fromEntries(Object.keys(pinned).map(id => [id, true]));
+
+// The order the pins ask for, as lineupd's triggers work it out: pinned games at their slots (packed at the end of
+// a row that got shorter), the others in their order in the free places.
+function pinnedOrder(order, pinned) {
+ const fixed = order.filter(id => id in pinned).sort((a, b) => pinned[a] - pinned[b]);
+ const at = {};
+ fixed.forEach((id, k) => {
+ at[Math.min(pinned[id], order.length - fixed.length + k)] = id;
+ });
+ const rest = order.filter(id => !(id in pinned));
+ return order.map((_, i) => at[i] ?? rest.shift());
+}
const Slots = memo(function Slots({count, onFocusIndex, onPressIndex}) {
const slots = [];
@@ -53,7 +68,7 @@ const Slots = memo(function Slots({count, onFocusIndex, onPressIndex}) {
// row, and 168 px with its ring for focus. They swap with a short crossfade while scaling into each
// other, so at rest neither is scaled or faded. Tiles sit at fixed places in a strip the row slides
// as a whole, so a move re-renders only the games whose `place` changed.
-const Tile = memo(function Tile({game, index, place, lifted, moved}) {
+const Tile = memo(function Tile({game, index, place, lifted, moved, pinned}) {
const focused = place === 'focus';
const up = focused && lifted;
const x = LEFT + index * STRIDE + (place === 'after' ? BIG - SMALL : 0);
@@ -72,6 +87,7 @@ const Tile = memo(function Tile({game, index, place, lifted, moved}) {
+ {pinned ? : null}
{game.name}
+ {pinned ? : null}
{focused && !lifted ? : null}
{up ? (
@@ -109,7 +126,7 @@ function Row({s}) {
const place = index < s.focus ? 'before' : index > s.focus ? 'after' : 'focus';
return (
+ moved={s.home[id] !== index} pinned={!!s.pins[id]} />
);
})}
{s.order.length > HOME_LIMIT ? (
@@ -125,12 +142,13 @@ function Row({s}) {
const RAIL = 1152;
// The whole row at a glance: a hairline ruler with a tick per game (when they fit), the moved games'
-// ticks in white and a marker on the focused one.
-const Ticks = memo(function Ticks({order, home}) {
+// ticks in white, the pinned ones as small white blocks, and a marker on the focused one.
+const Ticks = memo(function Ticks({order, home, pins}) {
const at = i => Math.round(i * RAIL / Math.max(order.length - 1, 1));
if (order.length > 96) return null;
- const ticks = order.map((id, i) => (
- (pins[id]
+ ?
+ :
));
// Where the home screen stops: a tall mark halfway between the last game it shows and the next.
@@ -146,7 +164,7 @@ function Rail({s}) {
return (
-
+
@@ -178,6 +196,7 @@ function Position({s}) {
const game = s.games[id];
const was = s.home[id];
const notes = [];
+ if (s.pins[id]) notes.push(T.pins.pinnedAt(s.focus + 1));
if (was !== s.focus) notes.push(T.before(was + 1));
if (s.focus >= HOME_LIMIT) notes.push(T.offHome);
const note = notes.join(', ');
@@ -217,7 +236,7 @@ const LIFT_HINTS = [['dpad', H.move], ['cross', H.drop], ['circle', H.cancel]];
const PICK_HINTS = [['cross', H.choose], ['circle', H.back]];
const START = {phase: 'loading', step: T.service.looking, error: null, profiles: [], user: null,
- games: {}, home: {}, base: [], order: [], focus: 0, dir: 1, lifted: false, liftFrom: 0, layer: null,
+ games: {}, home: {}, base: [], order: [], pins: {}, pinned: {}, focus: 0, dir: 1, lifted: false, liftFrom: 0, layer: null,
target: null, toast: null, pending: null, progress: null, settings: null, update: null, install: null};
const DAY = 24 * 60 * 60 * 1000;
@@ -265,18 +284,21 @@ function App() {
};
const loadRow = async (user, profiles) => {
- const list = await api.row(user);
+ const {games: list, pins} = await api.row(user);
if (!list.length) throw new api.Problem(...T.emptyRow);
const games = {};
const home = {};
+ const pinned = {};
list.forEach((game, i) => {
games[game.titleId] = game;
home[game.titleId] = i;
+ if (game.pinned !== null && game.pinned !== undefined) pinned[game.titleId] = game.pinned;
});
const base = list.map(game => game.titleId);
+ const missing = pins === 'missing';
light(false);
- patch({phase: 'row', profiles, user, games, home, base, order: base, focus: 0, dir: 1, lifted: false,
- layer: null, target: null, pending: {index: 0}});
+ patch({phase: 'row', profiles, user, games, home, base, order: base, pins: allPinned(pinned), pinned, focus: 0,
+ dir: 1, lifted: false, layer: missing ? 'pinsMissing' : null, target: null, pending: missing ? null : {index: 0}});
};
const boot = async () => {
@@ -374,17 +396,17 @@ function App() {
}
};
- // Saves, then shows the saved screen, or `next()` instead when switching profiles.
- const save = async next => {
- const now = ref.current;
- if (!changed(now)) {
+ // Saves, then shows the saved screen, or `next()` instead when switching profiles. `force` saves an unchanged
+ // row too, to put the pins' triggers back.
+ const save = async (next, now = ref.current, force = false) => {
+ if (!changed(now) && !force) {
close();
toast(T.alreadySaved);
return;
}
light(false);
patch({lifted: false, layer: 'busy'});
- const result = await api.save(now.user, now.order, now.games);
+ const result = await api.save(now.user, now.order, now.games, now.pins);
if (result.ok) {
if (next) {
toast(T.savedToast);
@@ -443,13 +465,39 @@ function App() {
save();
return true;
}
+ if (action === 'square') {
+ patch(p => {
+ const id = p.order[p.focus];
+ const pins = {...p.pins};
+ if (pins[id]) delete pins[id];
+ else pins[id] = true;
+ return {pins};
+ });
+ return true;
+ }
return false;
};
const discard = () => {
- patch(p => ({order: p.base, layer: null, pending: {index: 0}}));
+ patch(p => ({order: p.base, pins: allPinned(p.pinned), layer: null, pending: {index: 0}}));
toast(T.discarded);
};
+ const pinAll = on => patch(p => ({pins: on ? Object.fromEntries(p.order.map(id => [id, true])) : {}, layer: null,
+ pending: {index: p.focus}}));
+ // Puts the games back at their slots and saves, which creates the triggers again.
+ const reapply = () => {
+ const now = ref.current;
+ const order = pinnedOrder(now.order, now.pinned);
+ patch({order});
+ save(null, {...now, order}, true);
+ };
+ const unpinEverywhere = async () => {
+ patch({layer: 'busy'});
+ const error = await api.unpinAll();
+ if (!error) patch({pins: {}, pinned: {}});
+ close();
+ toast(error || T.pins.removed);
+ };
const sortAZ = () => patch(p => {
const id = p.order[p.focus];
const order = p.order.slice().sort((a, b) => {
@@ -509,7 +557,8 @@ function App() {
}
const count = changed(s);
- const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : ROW_HINTS;
+ const pinHint = ['square', s.pins[s.order[s.focus]] ? H.unpin : H.pin];
+ const hints = s.layer ? PICK_HINTS : s.lifted ? LIFT_HINTS : [ROW_HINTS[0], pinHint, ...ROW_HINTS.slice(1)];
const settings = s.settings ?? {checkOnOpen: true, lastCheck: 0};
const file = s.update?.[s.install?.kind === 'image' ? 'exfat' : 'zip'];
const notes = [s.update?.notes?.[LANGUAGE] ?? s.update?.notes?.en, file?.size ? T.update.size(file.size) : '']
@@ -544,6 +593,8 @@ function App() {
detail={T.menu.changes(count)} />,
,
,
+ pinAll(true)} />,
+ pinAll(false)} />,
,
patch({layer: 'profiles'})}
detail={s.profiles.length > 1 ? T.menu.profileCount(s.profiles.length) : ''} />,
@@ -551,6 +602,7 @@ function App() {
patch({layer: 'update'})} />
) : null,
patch({layer: 'updates'})} />,
+ patch({layer: 'removePins'})} />,
] : s.profiles.map(item => (
}
@@ -586,6 +638,18 @@ function App() {