f62515451b
NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be clear to users. Keep streamed installs in the installed visual state so sorting, filters, and the primary Play action stay unchanged, but make the sharing limitation visible in the UI. Cards now label that state as `Not shareable`, while the detail modal status says `Installed, not shareable`. Downloaded-and-installed games keep the normal `Installed` wording. Test Plan: - just frontend-test - just build - git diff --check - git diff --cached --check Refs: NEXT_STEPS.md item 7
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import {
|
|
ActiveOperation,
|
|
ActiveOperationKind,
|
|
DerivedState,
|
|
Game,
|
|
GameFilter,
|
|
GameSort,
|
|
GamesListPayload,
|
|
InstallStatus,
|
|
} from './types';
|
|
|
|
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
|
InstallStatus.CheckingPeers,
|
|
InstallStatus.Downloading,
|
|
InstallStatus.Installing,
|
|
InstallStatus.Uninstalling,
|
|
InstallStatus.Removing,
|
|
]);
|
|
|
|
export const isInProgress = (status: InstallStatus): boolean =>
|
|
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
|
|
|
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
|
switch (op) {
|
|
case ActiveOperationKind.Downloading:
|
|
return InstallStatus.Downloading;
|
|
case ActiveOperationKind.Installing:
|
|
case ActiveOperationKind.Updating:
|
|
return InstallStatus.Installing;
|
|
case ActiveOperationKind.Uninstalling:
|
|
return InstallStatus.Uninstalling;
|
|
case ActiveOperationKind.RemovingDownload:
|
|
return InstallStatus.Removing;
|
|
}
|
|
};
|
|
|
|
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
|
|
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
|
|
|
|
export const normalizeGamesListPayload = (
|
|
payload: GamesListPayload | Game[],
|
|
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
|
|
|
/**
|
|
* Reconcile a freshly received backend snapshot. Core operation status is
|
|
* derived only from the backend active-operation snapshot plus installed state.
|
|
*/
|
|
export const mergeGameUpdate = (
|
|
incoming: Game,
|
|
previous?: Game,
|
|
activeStatus?: InstallStatus,
|
|
): Game => {
|
|
const installStatus = activeStatus
|
|
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
|
|
|
const localStateChanged = previous !== undefined
|
|
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
|
const statusChanged = previous !== undefined
|
|
&& previous.install_status !== installStatus;
|
|
const clearStatus = localStateChanged
|
|
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
|
|
|
|
return {
|
|
...incoming,
|
|
availability: incoming.availability,
|
|
install_status: installStatus,
|
|
status_message: clearStatus ? undefined : previous?.status_message,
|
|
status_level: clearStatus ? undefined : previous?.status_level,
|
|
download_progress: installStatus === InstallStatus.Downloading
|
|
? previous?.download_progress
|
|
: undefined,
|
|
peer_count: incoming.peer_count ?? 0,
|
|
};
|
|
};
|
|
|
|
/** Visual card state — used for state chip color and action button styling. */
|
|
export const deriveState = (game: Game): DerivedState => {
|
|
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
|
if (isInProgress(game.install_status)) return 'busy';
|
|
if (game.installed) return 'installed';
|
|
if (game.downloaded) return 'local';
|
|
return 'none';
|
|
};
|
|
|
|
export const isInstalledNotShareable = (game: Game): boolean =>
|
|
game.installed && !game.downloaded;
|
|
|
|
export const stateChipLabel = (game: Game): string => {
|
|
const state = deriveState(game);
|
|
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
|
|
switch (state) {
|
|
case 'installed': return 'Installed';
|
|
case 'local': return 'Local';
|
|
case 'downloading': return 'Downloading';
|
|
case 'busy': return 'Working';
|
|
case 'none': return '';
|
|
}
|
|
};
|
|
|
|
export const gameStatusLabel = (game: Game): string => {
|
|
const state = deriveState(game);
|
|
if (state === 'installed' && isInstalledNotShareable(game)) {
|
|
return 'Installed, not shareable';
|
|
}
|
|
switch (state) {
|
|
case 'installed': return 'Installed';
|
|
case 'local': return 'Downloaded';
|
|
case 'downloading': return 'Downloading';
|
|
case 'busy': return 'Working…';
|
|
case 'none': return 'Not downloaded';
|
|
}
|
|
};
|
|
|
|
export const isUnavailable = (game: Game): boolean =>
|
|
!game.installed
|
|
&& !game.downloaded
|
|
&& game.peer_count === 0
|
|
&& game.install_status === InstallStatus.NotInstalled;
|
|
|
|
const parseVersionStamp = (version: string | undefined): number | null => {
|
|
if (!version || !/^\d{8}$/.test(version)) return null;
|
|
const parsed = parseInt(version, 10);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
};
|
|
|
|
export const compareVersionStamps = (
|
|
left: string | undefined,
|
|
right: string | undefined,
|
|
): number | null => {
|
|
const parsedLeft = parseVersionStamp(left);
|
|
const parsedRight = parseVersionStamp(right);
|
|
if (parsedLeft === null || parsedRight === null) return null;
|
|
return parsedLeft - parsedRight;
|
|
};
|
|
|
|
export const hasNewerLocalVersion = (game: Game): boolean =>
|
|
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
|
|
|
export const needsUpdate = (game: Game): boolean => {
|
|
if (!game.installed) return false;
|
|
if (game.peer_count <= 0) return false;
|
|
if (!game.local_version && game.eti_game_version) return true;
|
|
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
|
};
|
|
|
|
export const canStreamInstall = (game: Game): boolean =>
|
|
!game.downloaded
|
|
&& !game.installed
|
|
&& game.peer_count > 0
|
|
&& !isInProgress(game.install_status);
|
|
|
|
/** What pressing the card's main action button should do, given the state. */
|
|
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
|
|
|
export const primaryActionFor = (game: Game): PrimaryAction => {
|
|
if (isInProgress(game.install_status)) return 'busy';
|
|
if (isUnavailable(game)) return 'disabled';
|
|
if (!game.installed) return game.downloaded ? 'install' : 'download';
|
|
if (needsUpdate(game)) return 'update';
|
|
return 'play';
|
|
};
|
|
|
|
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
|
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
|
let value = Math.max(0, bytesPerSecond);
|
|
let unitIndex = 0;
|
|
|
|
while (value >= 1000 && unitIndex < units.length - 1) {
|
|
value /= 1000;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
|
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
|
};
|
|
|
|
const MB = 1024 * 1024;
|
|
const GB = 1024 * 1024 * 1024;
|
|
const DECIMAL_MB = 1_000_000;
|
|
|
|
const stripTrailingDecimalZeros = (value: string): string =>
|
|
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
|
|
|
export const downloadProgressPercent = (game: Game): number => {
|
|
const progress = game.download_progress;
|
|
if (!progress || progress.total_bytes <= 0) return 0;
|
|
|
|
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
|
};
|
|
|
|
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
|
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
|
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
|
};
|
|
|
|
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
|
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
|
return `${Math.round(mb)} MB/s`;
|
|
};
|
|
|
|
export const formatDownloadBytes = (bytes: number): string => {
|
|
const safeBytes = Math.max(0, bytes);
|
|
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
|
|
|
const gb = safeBytes / GB;
|
|
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
|
};
|
|
|
|
export const formatDownloadEta = (seconds: number): string => {
|
|
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
|
if (seconds < 60) return `${Math.round(seconds)} s`;
|
|
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes} min`;
|
|
|
|
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
|
};
|
|
|
|
export const inProgressLabel = (game: Game): string | undefined => {
|
|
switch (game.install_status) {
|
|
case InstallStatus.CheckingPeers:
|
|
return 'Checking peers…';
|
|
case InstallStatus.Downloading:
|
|
return game.download_progress
|
|
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
|
|
: 'Downloading…';
|
|
case InstallStatus.Installing:
|
|
return 'Installing…';
|
|
case InstallStatus.Uninstalling:
|
|
return 'Uninstalling…';
|
|
case InstallStatus.Removing:
|
|
return 'Removing…';
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export const actionLabel = (game: Game): string => {
|
|
const busy = inProgressLabel(game);
|
|
if (busy) return busy;
|
|
if (isUnavailable(game)) return 'Unavailable';
|
|
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
|
|
if (needsUpdate(game)) return 'Update';
|
|
return 'Play';
|
|
};
|
|
|
|
/** Counts shown on filter pills. */
|
|
export interface FilterCounts {
|
|
all: number;
|
|
local: number;
|
|
installed: number;
|
|
}
|
|
|
|
const isDownloading = (game: Game): boolean =>
|
|
game.install_status === InstallStatus.Downloading;
|
|
|
|
const isNetworkGame = (game: Game): boolean =>
|
|
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
|
|
|
export const countByFilter = (games: Game[]): FilterCounts => ({
|
|
all: games.filter(isNetworkGame).length,
|
|
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
|
installed: games.filter(g => g.installed).length,
|
|
});
|
|
|
|
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
|
switch (filter) {
|
|
case 'local':
|
|
return game.installed || game.downloaded || isDownloading(game);
|
|
case 'installed':
|
|
return game.installed;
|
|
case 'all':
|
|
return isNetworkGame(game);
|
|
}
|
|
};
|
|
|
|
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
|
installed: 0,
|
|
local: 1,
|
|
downloading: 2,
|
|
busy: 3,
|
|
none: 4,
|
|
};
|
|
|
|
const compareByState = (a: Game, b: Game): number => {
|
|
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
|
|
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
|
};
|
|
|
|
export const applyFilterAndSort = (
|
|
games: Game[],
|
|
filter: GameFilter,
|
|
sort: GameSort,
|
|
query: string,
|
|
): Game[] => {
|
|
let list = games.filter(g => matchesFilter(g, filter));
|
|
const q = query.trim().toLowerCase();
|
|
if (q) {
|
|
list = list.filter(g =>
|
|
g.name.toLowerCase().includes(q)
|
|
|| (g.genre?.toLowerCase().includes(q) ?? false)
|
|
|| (g.publisher?.toLowerCase().includes(q) ?? false),
|
|
);
|
|
}
|
|
switch (sort) {
|
|
case 'az':
|
|
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
|
case 'sizeDesc':
|
|
return [...list].sort((a, b) => b.size - a.size);
|
|
case 'sizeAsc':
|
|
return [...list].sort((a, b) => a.size - b.size);
|
|
case 'status':
|
|
return [...list].sort(compareByState);
|
|
}
|
|
};
|