import { ActiveOperation, ActiveOperationKind, DerivedState, Game, GameFilter, GameSort, GamesListPayload, InstallStatus, } from './types'; const IN_PROGRESS_INSTALL_STATUSES = new Set([ 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 => 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 = { 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); } };