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() {