diff --git a/src/main/features/game/ipc.ts b/src/main/features/game/ipc.ts index cba97404..1900a5e9 100644 --- a/src/main/features/game/ipc.ts +++ b/src/main/features/game/ipc.ts @@ -8,7 +8,9 @@ import { cancelBatchStorageSizeCalculation, deleteGameMemory, deleteGameSave, + hideGameFromRecentGames, isBatchStorageSizeCalculationRunning, + recalculateLastRunDate, restoreGameSave, searchGameSavePaths, updateGameMemoryCover @@ -121,4 +123,12 @@ export function setupGameIPC(): void { ipcManager.handle('game:is-batch-storage-size-calculation-running', async () => { return isBatchStorageSizeCalculationRunning() }) + + ipcManager.handle('game:recalculate-last-run-date', async (_, gameId: string) => { + return await recalculateLastRunDate(gameId) + }) + + ipcManager.handle('game:hide-from-recent-games', async (_, gameId: string) => { + await hideGameFromRecentGames(gameId) + }) } diff --git a/src/main/features/game/services/index.ts b/src/main/features/game/services/index.ts index 50f1da31..0afca442 100644 --- a/src/main/features/game/services/index.ts +++ b/src/main/features/game/services/index.ts @@ -3,3 +3,4 @@ export * from './utils' export * from './save' export * from './active' export * from './size' +export * from './record' diff --git a/src/main/features/game/services/record.ts b/src/main/features/game/services/record.ts new file mode 100644 index 00000000..2d20d516 --- /dev/null +++ b/src/main/features/game/services/record.ts @@ -0,0 +1,19 @@ +import { GameDBManager } from '~/core/database' +import { updateRecentGamesInTray } from '~/features/system' +import { calculateLastRunDateFromTimers } from '@appUtils' + +export async function recalculateLastRunDate(gameId: string): Promise { + const timers = await GameDBManager.getGameValue(gameId, 'record.timers') + const lastRunDate = calculateLastRunDateFromTimers(timers) + + await GameDBManager.setGameValue(gameId, 'record.lastRunDate', lastRunDate) + await GameDBManager.setGameValue(gameId, 'record.hideFromRecentGames', false) + await updateRecentGamesInTray() + + return lastRunDate +} + +export async function hideGameFromRecentGames(gameId: string): Promise { + await GameDBManager.setGameValue(gameId, 'record.hideFromRecentGames', true) + await updateRecentGamesInTray() +} diff --git a/src/main/features/importer/services/versionConverter/common.ts b/src/main/features/importer/services/versionConverter/common.ts index 381c2168..c00d4cd8 100644 --- a/src/main/features/importer/services/versionConverter/common.ts +++ b/src/main/features/importer/services/versionConverter/common.ts @@ -282,6 +282,7 @@ async function convertGame(gameId: string, gamePath: string): Promise { score: record.score || -1, playTime: record.playingTime || 0, playStatus: record.playStatus || 'unplayed', + hideFromRecentGames: false, timers: record.timer || [], dailyPlayTimes: [], storageSize: STORAGE_SIZE_NOT_CALCULATED diff --git a/src/main/features/monitor/services/monitor.ts b/src/main/features/monitor/services/monitor.ts index 82724a25..07bddc06 100644 --- a/src/main/features/monitor/services/monitor.ts +++ b/src/main/features/monitor/services/monitor.ts @@ -640,6 +640,7 @@ export class GameMonitor { if (await GameDBManager.getGame(this.options.gameId)) { await GameDBManager.setGameValue(this.options.gameId, 'record.timers', dbTimers) await GameDBManager.setGameValue(this.options.gameId, 'record.lastRunDate', this.endTime) + await GameDBManager.setGameValue(this.options.gameId, 'record.hideFromRecentGames', false) await GameDBManager.setGameValue(this.options.gameId, 'record.playTime', playTime) } diff --git a/src/main/features/system/services/tray.ts b/src/main/features/system/services/tray.ts index f0c080aa..93921625 100644 --- a/src/main/features/system/services/tray.ts +++ b/src/main/features/system/services/tray.ts @@ -168,7 +168,12 @@ export class TrayManager { const gameDocs = await GameDBManager.getAllGames() const recentGameIds = ( await GameDBManager.sortGames({ by: 'record.lastRunDate', order: 'desc' }) - ).slice(0, 5) + ) + .filter((gameId) => { + const game = gameDocs[gameId] + return game?.record?.lastRunDate && game.record.hideFromRecentGames !== true + }) + .slice(0, 5) const recentGames = await Promise.all( recentGameIds.map(async (gameId) => { diff --git a/src/renderer/locales/en/game.json b/src/renderer/locales/en/game.json index be26ab6b..07f09c59 100644 --- a/src/renderer/locales/en/game.json +++ b/src/renderer/locales/en/game.json @@ -24,7 +24,13 @@ "recent": { "title": "Recent Games", "hide": "Hide", - "empty": "No recent games" + "remove": "Remove from Recent Games", + "empty": "No recent games", + "notifications": { + "removing": "Removing from recent games…", + "removed": "Removed from recent games", + "removeError": "Failed to remove from recent games" + } }, "filter": { "results": "Filter Results", @@ -410,6 +416,7 @@ "rename": "Edit Basic Information", "editLogo": "Edit Logo", "editPlayTime": "Edit Play Time", + "recalculateLastRunDate": "Recalculate Last Played Date", "markNSFW": "Mark as NSFW", "unmarkNSFW": "Unmark NSFW", "downloadMetadata": "Update Metadata", @@ -425,7 +432,12 @@ "storageSizeCalculated": "Game size calculated: {{size}}", "storageSizeError": "Failed to calculate game size", "storageSizeConfirm": "Calculate game size? This may take a moment.", - "storageSizeRecalculateConfirm": "Current size: {{currentSize}}. Recalculate game size?" + "storageSizeRecalculateConfirm": "Current size: {{currentSize}}. Recalculate game size?", + "recalculatingLastRunDate": "Recalculating last played date…", + "lastRunDateRecalculated": "Last played date updated to {{date, niceDate}}", + "lastRunDateCleared": "No valid timers found. Last played date cleared", + "lastRunDateRecalculateError": "Failed to recalculate last played date", + "lastRunDateRecalculateConfirm": "The last played date will be recalculated from the current timer list. If no valid timers exist, the date will be cleared." } }, "timersEditor": { diff --git a/src/renderer/locales/ja/game.json b/src/renderer/locales/ja/game.json index 0f35098d..be836e51 100644 --- a/src/renderer/locales/ja/game.json +++ b/src/renderer/locales/ja/game.json @@ -24,7 +24,13 @@ "recent": { "title": "最近のゲーム", "hide": "非表示", - "empty": "最近のゲームはありません" + "remove": "最近のゲームから削除", + "empty": "最近のゲームはありません", + "notifications": { + "removing": "最近のゲームから削除中…", + "removed": "最近のゲームから削除しました", + "removeError": "最近のゲームからの削除に失敗しました" + } }, "filter": { "results": "フィルター結果", @@ -410,6 +416,7 @@ "rename": "基本情報を編集", "editLogo": "ロゴを編集", "editPlayTime": "プレイ時間を編集", + "recalculateLastRunDate": "最終プレイ日を再計算", "markNSFW": "NSFW としてマークする", "unmarkNSFW": "NSFW のマークを解除する", "downloadMetadata": "メタデータを更新", @@ -425,7 +432,12 @@ "storageSizeCalculated": "ゲームサイズを計算しました:{{size}}", "storageSizeError": "ゲームサイズの計算に失敗しました", "storageSizeConfirm": "ゲームサイズを計算しますか?少し時間がかかる場合があります。", - "storageSizeRecalculateConfirm": "現在のサイズ:{{currentSize}}。ゲームサイズを再計算しますか?" + "storageSizeRecalculateConfirm": "現在のサイズ:{{currentSize}}。ゲームサイズを再計算しますか?", + "recalculatingLastRunDate": "最終プレイ日を再計算中…", + "lastRunDateRecalculated": "最終プレイ日を {{date, niceDate}} に更新しました", + "lastRunDateCleared": "有効なタイマーが見つかりません。最終プレイ日をクリアしました", + "lastRunDateRecalculateError": "最終プレイ日の再計算に失敗しました", + "lastRunDateRecalculateConfirm": "現在のタイマーリストから最終プレイ日を再計算します。有効なタイマーがない場合、日付はクリアされます。" } }, "timersEditor": { diff --git a/src/renderer/locales/zh-CN/game.json b/src/renderer/locales/zh-CN/game.json index c876ef07..bc4898b6 100644 --- a/src/renderer/locales/zh-CN/game.json +++ b/src/renderer/locales/zh-CN/game.json @@ -24,7 +24,13 @@ "recent": { "title": "最近游戏", "hide": "隐藏", - "empty": "暂无最近游戏" + "remove": "从最近游戏中移除", + "empty": "暂无最近游戏", + "notifications": { + "removing": "正在移除最近游戏…", + "removed": "已从最近游戏中移除", + "removeError": "移除最近游戏失败" + } }, "filter": { "results": "筛选结果", @@ -410,6 +416,7 @@ "rename": "修改基本信息", "editLogo": "编辑徽标", "editPlayTime": "修改游玩时间", + "recalculateLastRunDate": "重新计算最后运行日期", "markNSFW": "标记为NSFW", "unmarkNSFW": "取消标记为 NSFW", "downloadMetadata": "更新资料数据", @@ -425,7 +432,12 @@ "storageSizeCalculated": "游戏大小已计算:{{size}}", "storageSizeError": "计算游戏大小失败", "storageSizeConfirm": "计算游戏大小?这可能需要一些时间。", - "storageSizeRecalculateConfirm": "当前大小:{{currentSize}}。重新计算游戏大小?" + "storageSizeRecalculateConfirm": "当前大小:{{currentSize}}。重新计算游戏大小?", + "recalculatingLastRunDate": "正在重新计算最后运行日期…", + "lastRunDateRecalculated": "最后运行日期已更新为 {{date, niceDate}}", + "lastRunDateCleared": "没有有效计时器,最后运行日期已清空", + "lastRunDateRecalculateError": "重新计算最后运行日期失败", + "lastRunDateRecalculateConfirm": "将根据当前计时器列表重新设置最后运行日期。没有有效计时器时会清空该日期。" } }, "timersEditor": { diff --git a/src/renderer/locales/zh-TW/game.json b/src/renderer/locales/zh-TW/game.json index 87dcf564..04724f51 100644 --- a/src/renderer/locales/zh-TW/game.json +++ b/src/renderer/locales/zh-TW/game.json @@ -24,7 +24,13 @@ "recent": { "title": "最近遊戲", "hide": "隱藏", - "empty": "暫無最近遊戲" + "remove": "從最近遊戲中移除", + "empty": "暫無最近遊戲", + "notifications": { + "removing": "正在從最近遊戲中移除…", + "removed": "已從最近遊戲中移除", + "removeError": "從最近遊戲中移除失敗" + } }, "filter": { "results": "篩選結果", @@ -410,6 +416,7 @@ "rename": "修改基本資訊", "editLogo": "編輯徽標", "editPlayTime": "修改遊玩時間", + "recalculateLastRunDate": "重新計算最後執行日期", "markNSFW": "標記為 NSFW", "unmarkNSFW": "取消標記為 NSFW", "downloadMetadata": "更新資料", @@ -425,7 +432,12 @@ "storageSizeCalculated": "遊戲大小已計算:{{size}}", "storageSizeError": "計算遊戲大小失敗", "storageSizeConfirm": "計算遊戲大小?這可能需要一些時間。", - "storageSizeRecalculateConfirm": "當前大小:{{currentSize}}。重新計算遊戲大小?" + "storageSizeRecalculateConfirm": "當前大小:{{currentSize}}。重新計算遊戲大小?", + "recalculatingLastRunDate": "正在重新計算最後執行日期…", + "lastRunDateRecalculated": "最後執行日期已更新為 {{date, niceDate}}", + "lastRunDateCleared": "沒有有效的計時器,最後執行日期已清除", + "lastRunDateRecalculateError": "重新計算最後執行日期失敗", + "lastRunDateRecalculateConfirm": "將根據目前的計時器列表重新設定最後執行日期。若沒有有效的計時器,將清除該日期。" } }, "timersEditor": { diff --git a/src/renderer/src/components/Game/Config/ManageMenu/main.tsx b/src/renderer/src/components/Game/Config/ManageMenu/main.tsx index 2b9d069f..e5bc30ad 100644 --- a/src/renderer/src/components/Game/Config/ManageMenu/main.tsx +++ b/src/renderer/src/components/Game/Config/ManageMenu/main.tsx @@ -15,9 +15,10 @@ import { } from '~/components/ui/dropdown-menu' import { useConfigState, useGameLocalState, useGameState } from '~/hooks' import { useGameAdderStore } from '~/pages/GameAdder/store' +import { formatStorageSize } from '~/utils' +import { RecalculateLastRunDateAlertDialog } from '../../Overview/Record/RecalculateLastRunDateAlertDialog' import { useGameDetailStore } from '../../store' import { DeleteGameAlert } from './DeleteGameAlert' -import { formatStorageSize } from '~/utils' export function ManageMenu({ gameId, @@ -176,6 +177,12 @@ export function ManageMenu({ {t('detail.manage.calculateStorageSize')} )} + {/* Recalculate Last Run Date */} + + e.preventDefault()}> + {t('detail.manage.recalculateLastRunDate')} + + diff --git a/src/renderer/src/components/Game/Overview/Record/RecalculateLastRunDateAlertDialog.tsx b/src/renderer/src/components/Game/Overview/Record/RecalculateLastRunDateAlertDialog.tsx new file mode 100644 index 00000000..3fcc3222 --- /dev/null +++ b/src/renderer/src/components/Game/Overview/Record/RecalculateLastRunDateAlertDialog.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { ipcManager } from '~/app/ipc' +import { useLibrarybarStore } from '~/components/Librarybar/store' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '~/components/ui/alert-dialog' + +export function RecalculateLastRunDateAlertDialog({ + gameId, + children +}: { + gameId: string + children: React.ReactNode +}): React.JSX.Element { + const { t } = useTranslation('game') + const refreshGameList = useLibrarybarStore((state) => state.refreshGameList) + + const handleRecalculate = (): void => { + toast.promise( + ipcManager.invoke('game:recalculate-last-run-date', gameId).then((lastRunDate) => { + refreshGameList() + return lastRunDate + }), + { + loading: t('detail.manage.notifications.recalculatingLastRunDate'), + success: (lastRunDate: string) => + lastRunDate + ? t('detail.manage.notifications.lastRunDateRecalculated', { + date: lastRunDate + }) + : t('detail.manage.notifications.lastRunDateCleared'), + error: () => t('detail.manage.notifications.lastRunDateRecalculateError') + } + ) + } + + return ( + + {children} + + + {t('detail.manage.recalculateLastRunDate')} + + {t('detail.manage.notifications.lastRunDateRecalculateConfirm')} + + + + {t('utils:common.cancel')} + + {t('utils:common.confirm')} + + + + + ) +} diff --git a/src/renderer/src/components/Game/Overview/Record/RecordCard.tsx b/src/renderer/src/components/Game/Overview/Record/RecordCard.tsx index 761b798c..1705f2db 100644 --- a/src/renderer/src/components/Game/Overview/Record/RecordCard.tsx +++ b/src/renderer/src/components/Game/Overview/Record/RecordCard.tsx @@ -3,26 +3,29 @@ import { Button } from '~/components/ui/button' import { PopoverTrigger } from '~/components/ui/popover' import { cn } from '~/utils' -export function RecordCard({ - title, - content, - icon, - onClick, - asPopoverTrigger, - className = '' -}: { +interface RecordCardProps extends Omit, 'onClick'> { title: string content: string icon?: string onClick?: () => void asPopoverTrigger?: boolean className?: string -}): React.JSX.Element { +} + +export const RecordCard = React.forwardRef(function RecordCard( + { title, content, icon, onClick, asPopoverTrigger, className = '', ...props }, + ref +): React.JSX.Element { const ButtonWrapper = ({ children }: { children: React.ReactNode }): React.JSX.Element => asPopoverTrigger ? {children} : <>{children} const isInteractive = onClick || asPopoverTrigger + return ( -
+
{isInteractive ? (
) -} +}) + +RecordCard.displayName = 'RecordCard' diff --git a/src/renderer/src/components/Game/Overview/Record/main.tsx b/src/renderer/src/components/Game/Overview/Record/main.tsx index 57a63876..4e3564a6 100644 --- a/src/renderer/src/components/Game/Overview/Record/main.tsx +++ b/src/renderer/src/components/Game/Overview/Record/main.tsx @@ -8,6 +8,7 @@ import { useGameState } from '~/hooks' import { cn, formatStorageSize } from '~/utils' import { useGameDetailStore } from '../../store' import { CalculateStorageSizeAlertDialog } from './CalculateStorageSizeAlertDialog' +import { RecalculateLastRunDateAlertDialog } from './RecalculateLastRunDateAlertDialog' import { RecordCard } from './RecordCard' import { PLAY_STATUS_ICONS } from './RecordIcon' @@ -45,16 +46,18 @@ export function Record({ gameId }: { gameId: string }): React.JSX.Element { onClick={() => setIsPlayTimeEditorDialogOpen(true)} /> {/* Last Run Date */} - + + + {/* Play Status */} state.gameMetaIndex) + const games = getRecentGameIds( + 5, filterGamesByLocal(localFilterMode, filterGamesByNSFW(nsfwFilterMode)) ) - .slice(0, 5) - .filter((id) => { - const date = getGameStore(id).getState().getValue('record.lastRunDate') - return date && date !== '' - }) const { t } = useTranslation('game') return ( <> diff --git a/src/renderer/src/components/Librarybar/GameNav.tsx b/src/renderer/src/components/Librarybar/GameNav.tsx index 06285327..6427261f 100644 --- a/src/renderer/src/components/Librarybar/GameNav.tsx +++ b/src/renderer/src/components/Librarybar/GameNav.tsx @@ -352,6 +352,7 @@ export function GameNav({ openNameEditorDialog={() => setIsInformationDialogOpen(true)} openPlayTimeEditorDialog={() => setIsPlayTimeEditorDialogOpen(true)} openPropertiesDialog={() => setIsPropertiesDialogOpen(true)} + showRemoveFromRecent={groupId === 'recentGames'} /> )} diff --git a/src/renderer/src/components/Showcase/RecentGames.tsx b/src/renderer/src/components/Showcase/RecentGames.tsx index 081541a6..92a6c438 100644 --- a/src/renderer/src/components/Showcase/RecentGames.tsx +++ b/src/renderer/src/components/Showcase/RecentGames.tsx @@ -5,7 +5,12 @@ import { useTranslation } from 'react-i18next' import { Button } from '~/components/ui/button' import { useConfigState } from '~/hooks' import { useLibraryStore } from '~/pages/Library/store' -import { filterGamesByLocal, filterGamesByNSFW, getGameStore, sortGames } from '~/stores/game' +import { + filterGamesByLocal, + filterGamesByNSFW, + getRecentGameIds, + useGameRegistry +} from '~/stores/game' import { cn } from '~/utils' import { BigGamePoster } from './posters/BigGamePoster' import { GamePoster } from './posters/GamePoster' @@ -13,16 +18,11 @@ import { GamePoster } from './posters/GamePoster' export function RecentGames(): React.JSX.Element { const [nsfwFilterMode] = useConfigState('appearances.nsfwFilterMode') const [localFilterMode] = useConfigState('appearances.localGameFilterMode') - const games = sortGames( - 'record.lastRunDate', - 'desc', + useGameRegistry((state) => state.gameMetaIndex) + const games = getRecentGameIds( + 15, filterGamesByLocal(localFilterMode, filterGamesByNSFW(nsfwFilterMode)) ) - .slice(0, 15) - .filter((id) => { - const date = getGameStore(id).getState().getValue('record.lastRunDate') - return date && date !== '' - }) const scrollContainerRef = useRef(null) const [showRecentGamesInGameList] = useConfigState('game.gameList.showRecentGames') const libraryBarWidth = useLibraryStore((state) => state.libraryBarWidth) @@ -90,9 +90,14 @@ export function RecentGames(): React.JSX.Element { )} > {showRecentGamesInGameList ? ( - + ) : ( - + )} ) : ( @@ -103,9 +108,9 @@ export function RecentGames(): React.JSX.Element { )} > {index < 5 && showRecentGamesInGameList ? ( - + ) : ( - + )} ) diff --git a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx index ce47c01d..9fe79444 100644 --- a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx +++ b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx @@ -22,11 +22,13 @@ export function BigGamePoster({ gameId, groupId, className, + showRemoveFromRecent = false, inViewGames = [] // TODO: Support shift selection in BigGamePoster }: { gameId: string groupId?: string className?: string + showRemoveFromRecent?: boolean inViewGames?: string[] }): React.JSX.Element { const navigate = useNavigate() @@ -228,6 +230,7 @@ export function BigGamePoster({ openNameEditorDialog={() => setIsNameEditorDialogOpen(true)} openPlayTimeEditorDialog={() => setIsPlayTimeEditorDialogOpen(true)} openPropertiesDialog={() => setIsPropertiesDialogOpen(true)} + showRemoveFromRecent={showRemoveFromRecent} /> )} diff --git a/src/renderer/src/components/Showcase/posters/GamePoster.tsx b/src/renderer/src/components/Showcase/posters/GamePoster.tsx index 58f3a300..fb123fa9 100644 --- a/src/renderer/src/components/Showcase/posters/GamePoster.tsx +++ b/src/renderer/src/components/Showcase/posters/GamePoster.tsx @@ -64,6 +64,7 @@ export function GamePoster({ dragScenario, parentGap = 0, position = 'center', + showRemoveFromRecent = false, inViewGames = [] // TODO: Support shift+click selection }: { gameId: string @@ -72,6 +73,7 @@ export function GamePoster({ dragScenario?: string parentGap?: number position?: 'right' | 'left' | 'center' + showRemoveFromRecent?: boolean inViewGames?: string[] }): React.JSX.Element { const navigate = useNavigate() @@ -354,6 +356,7 @@ export function GamePoster({ openNameEditorDialog={() => setIsInformationDialogOpen(true)} openPlayTimeEditorDialog={() => setIsPlayTimeEditorDialogOpen(true)} openPropertiesDialog={() => setIsPropertiesDialogOpen(true)} + showRemoveFromRecent={showRemoveFromRecent} /> )} diff --git a/src/renderer/src/components/contextMenu/GameNavCM/ManageMenu.tsx b/src/renderer/src/components/contextMenu/GameNavCM/ManageMenu.tsx index c3b2446e..a7a6ba11 100644 --- a/src/renderer/src/components/contextMenu/GameNavCM/ManageMenu.tsx +++ b/src/renderer/src/components/contextMenu/GameNavCM/ManageMenu.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { eventBus } from '~/app/events' import { ipcManager } from '~/app/ipc' import { DeleteGameAlert } from '~/components/Game/Config/ManageMenu/DeleteGameAlert' +import { RecalculateLastRunDateAlertDialog } from '~/components/Game/Overview/Record/RecalculateLastRunDateAlertDialog' import { useLibrarybarStore } from '~/components/Librarybar/store' import { Button } from '~/components/ui/button' import { @@ -216,6 +217,12 @@ export function ManageMenu({ {t('detail.manage.calculateStorageSize')} )} + {/* Recalculate Last Run Date */} + + e.preventDefault()}> + {t('detail.manage.recalculateLastRunDate')} + + diff --git a/src/renderer/src/components/contextMenu/GameNavCM/main.tsx b/src/renderer/src/components/contextMenu/GameNavCM/main.tsx index d3c14925..6b2ae217 100644 --- a/src/renderer/src/components/contextMenu/GameNavCM/main.tsx +++ b/src/renderer/src/components/contextMenu/GameNavCM/main.tsx @@ -9,22 +9,41 @@ import { cn, startGame, stopGame } from '~/utils' import { CollectionMenu } from './CollectionMenu' import { ManageMenu } from './ManageMenu' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { ipcManager } from '~/app/ipc' +import { useLibrarybarStore } from '~/components/Librarybar/store' export function GameNavCM({ gameId, openAddCollectionDialog, openNameEditorDialog, openPlayTimeEditorDialog, - openPropertiesDialog + openPropertiesDialog, + showRemoveFromRecent = false }: { gameId: string openAddCollectionDialog: () => void openNameEditorDialog: () => void openPlayTimeEditorDialog: () => void openPropertiesDialog: () => void + showRemoveFromRecent?: boolean }): React.JSX.Element { const { runningGames } = useRunningGames() const { t } = useTranslation('game') + const refreshGameList = useLibrarybarStore((state) => state.refreshGameList) + + const removeFromRecentGames = (): void => { + toast.promise( + ipcManager.invoke('game:hide-from-recent-games', gameId).then(() => { + refreshGameList() + }), + { + loading: t('list.recent.notifications.removing'), + success: t('list.recent.notifications.removed'), + error: t('list.recent.notifications.removeError') + } + ) + } return ( @@ -40,6 +59,11 @@ export function GameNavCM({ > {runningGames.includes(gameId) ? t('detail.actions.stop') : t('detail.actions.start')} + {showRemoveFromRecent && ( + + {t('list.recent.remove')} + + )} {/* Collection Menu */} {/* Manage Menu */} diff --git a/src/renderer/src/pages/Light.tsx b/src/renderer/src/pages/Light.tsx index 6a2d4829..df79cb4e 100644 --- a/src/renderer/src/pages/Light.tsx +++ b/src/renderer/src/pages/Light.tsx @@ -6,9 +6,9 @@ import { useEffect, useRef, useState } from 'react' import { create } from 'zustand' import { useTheme } from '~/components/ThemeProvider' import { useConfigState, useGameState } from '~/hooks' -import { useAttachmentStore } from '~/stores' -import { sortGames, useGameCollectionStore } from '~/stores/game' import { useLibraryStore } from '~/pages/Library/store' +import { useAttachmentStore } from '~/stores' +import { getRecentGameIds, useGameCollectionStore, useGameRegistry } from '~/stores/game' import { cn } from '~/utils' // eslint-disable-next-line @@ -27,6 +27,7 @@ export function Light(): React.JSX.Element { const { getAttachmentInfo, setAttachmentError } = useAttachmentStore() const getGameCollectionValue = useGameCollectionStore((state) => state.getGameCollectionValue) const collections = useGameCollectionStore((state) => state.documents) + const gameMetaIndex = useGameRegistry((state) => state.gameMetaIndex) const [customBackground] = useConfigState('appearances.background.customBackground') const [darkGlassBlur] = useConfigState('appearances.glass.dark.blur') const [darkGlassOpacity] = useConfigState('appearances.glass.dark.opacity') @@ -73,7 +74,7 @@ export function Light(): React.JSX.Element { return `attachment://config/media/background-${isDark ? 'dark' : 'light'}.webp?t=${info?.timestamp}` } - const getRecentGameId = (): string => sortGames('record.lastRunDate', 'desc')[0] + const getRecentGameId = (): string => getRecentGameIds(1)[0] || '' const updateBackgroundImage = (newUrl: string, newGameId: string = ''): void => { if (newUrl === imageUrl) return @@ -82,6 +83,17 @@ export function Light(): React.JSX.Element { if (newGameId) setGameId(newGameId) } + const updateRecentGameBackgroundImage = (): void => { + const recentGameId = getRecentGameId() + if (recentGameId) { + updateBackgroundImage(getGameBackgroundUrl(recentGameId), recentGameId) + return + } + + updateBackgroundImage(defaultBackground) + setGameId('') + } + // Use requestAnimationFrame for smoother updates let ticking = false @@ -155,8 +167,7 @@ export function Light(): React.JSX.Element { if (customBackground) { updateBackgroundImage(getCustomBackgroundUrl()) } else { - const recentGameId = getRecentGameId() - updateBackgroundImage(getGameBackgroundUrl(recentGameId), recentGameId) + updateRecentGameBackgroundImage() } return } @@ -171,13 +182,13 @@ export function Light(): React.JSX.Element { return } - const recentGameId = getRecentGameId() - updateBackgroundImage(getGameBackgroundUrl(recentGameId), recentGameId) + updateRecentGameBackgroundImage() } }, [ pathname, getGameCollectionValue, collections, + gameMetaIndex, customBackground, isDark, refreshId, diff --git a/src/renderer/src/stores/game/gameRegistry.ts b/src/renderer/src/stores/game/gameRegistry.ts index 2f2d345a..c2117aeb 100644 --- a/src/renderer/src/stores/game/gameRegistry.ts +++ b/src/renderer/src/stores/game/gameRegistry.ts @@ -5,6 +5,7 @@ interface GameMetaInfo { genre?: string addDate?: string lastRunDate?: string + hideFromRecentGames?: boolean score?: number } diff --git a/src/renderer/src/stores/game/gameStoreFactory.ts b/src/renderer/src/stores/game/gameStoreFactory.ts index 8dc3e678..cd886c8d 100644 --- a/src/renderer/src/stores/game/gameStoreFactory.ts +++ b/src/renderer/src/stores/game/gameStoreFactory.ts @@ -36,6 +36,7 @@ function extractMetaInfo(data: gameDoc): { genres?: string[] addDate?: string lastRunDate?: string + hideFromRecentGames?: boolean score?: number } { return { @@ -45,6 +46,7 @@ function extractMetaInfo(data: gameDoc): { genres: data.metadata?.genres, addDate: data.record?.addDate, lastRunDate: data.record?.lastRunDate, + hideFromRecentGames: data.record?.hideFromRecentGames, score: data.record?.score } } @@ -93,6 +95,7 @@ export function getGameStore(gameId: string): GameStore { 'metadata.genre', 'record.addDate', 'record.lastRunDate', + 'record.hideFromRecentGames', 'record.score' ] if (metaFields.includes(path as string)) { diff --git a/src/renderer/src/stores/game/gameUtils.ts b/src/renderer/src/stores/game/gameUtils.ts index 6de4a9c1..bdb8082d 100644 --- a/src/renderer/src/stores/game/gameUtils.ts +++ b/src/renderer/src/stores/game/gameUtils.ts @@ -242,6 +242,20 @@ export function sortGames }) } +export function getRecentGameIds(count = 5, gameIds?: string[]): string[] { + if (!gameIds) gameIds = useGameRegistry.getState().gameIds + + const visibleRecentGameIds = gameIds.filter((id) => { + const store = getGameStore(id) + const lastRunDate = store.getState().getValue('record.lastRunDate') + const hideFromRecentGames = store.getState().getValue('record.hideFromRecentGames') + + return Boolean(lastRunDate) && hideFromRecentGames !== true + }) + + return sortGames('record.lastRunDate', 'desc', visibleRecentGameIds).slice(0, count) +} + export function filterGames( criteria: Partial, string[]>> ): string[] { @@ -892,7 +906,9 @@ export function getGameRecord(gameId: string): gameDoc['record'] { score: 0, playTime: 0, playStatus: 'unplayed', + hideFromRecentGames: false, timers: [], + storageSize: STORAGE_SIZE_NOT_CALCULATED, dailyPlayTimes: [] } ) @@ -904,6 +920,7 @@ export function getGameRecord(gameId: string): gameDoc['record'] { score: 0, playTime: 0, playStatus: 'unplayed', + hideFromRecentGames: false, timers: [], dailyPlayTimes: [], storageSize: STORAGE_SIZE_NOT_CALCULATED diff --git a/src/renderer/src/stores/game/recordUtils.ts b/src/renderer/src/stores/game/recordUtils.ts index 653385c1..f1291022 100644 --- a/src/renderer/src/stores/game/recordUtils.ts +++ b/src/renderer/src/stores/game/recordUtils.ts @@ -757,12 +757,13 @@ export function getRecentlyPlayedGames(count = 5): string[] { const gamesWithLastRunDate = gameIds.map((gameId) => { const store = getGameStore(gameId) const lastRunDate = store.getState().getValue('record.lastRunDate') - return { gameId, lastRunDate } + const hideFromRecentGames = store.getState().getValue('record.hideFromRecentGames') + return { gameId, lastRunDate, hideFromRecentGames } }) // Sort by last run date return gamesWithLastRunDate - .filter((game) => game.lastRunDate) // Filter out games with no run date + .filter((game) => game.lastRunDate && game.hideFromRecentGames !== true) .sort((a, b) => new Date(b.lastRunDate).getTime() - new Date(a.lastRunDate).getTime()) .slice(0, count) .map((game) => game.gameId) diff --git a/src/renderer/src/stores/sync.ts b/src/renderer/src/stores/sync.ts index 4ec42407..fcb5699d 100644 --- a/src/renderer/src/stores/sync.ts +++ b/src/renderer/src/stores/sync.ts @@ -91,7 +91,8 @@ const DB_CHANGE_HANDLERS = { name: data.metadata?.name || '', genre: data.metadata?.genre, addDate: data.record?.addDate, - lastRunDate: data.record?.lastRunDate + lastRunDate: data.record?.lastRunDate, + hideFromRecentGames: data.record?.hideFromRecentGames }) } diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 4def8fd8..920a7971 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -187,6 +187,8 @@ type MainIpcEvents = 'game:check-exits-by-path': (gamePath: string) => boolean 'game:delete': (gameId: string) => void 'game:calculate-storage-size': (gameId: string) => number + 'game:recalculate-last-run-date': (gameId: string) => string + 'game:hide-from-recent-games': (gameId: string) => void 'game:batch-calculate-storage-size': (gameIds: string[]) => { taskId: string wasCancelled: boolean diff --git a/src/types/models/game.ts b/src/types/models/game.ts index 4dbd77b1..f84e3ba7 100644 --- a/src/types/models/game.ts +++ b/src/types/models/game.ts @@ -36,6 +36,7 @@ export interface gameDoc { score: number playTime: number playStatus: 'unplayed' | 'playing' | 'partial' | 'finished' | 'multiple' | 'shelved' + hideFromRecentGames: boolean timers: { start: string end: string @@ -213,6 +214,7 @@ export const DEFAULT_GAME_VALUES: Readonly = { score: -1, playTime: 0, playStatus: 'unplayed', + hideFromRecentGames: false, timers: [], dailyPlayTimes: [], storageSize: STORAGE_SIZE_NOT_CALCULATED diff --git a/src/utils/common.ts b/src/utils/common.ts index 2ff326cb..89992fa4 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -67,6 +67,25 @@ export function generateUUID(): string { return v4() } +export function calculateLastRunDateFromTimers(timer: Timer[]): string { + if (!timer || timer.length === 0) { + return '' + } + + const latestEnd = timer.reduce((latest, timerItem) => { + const start = new Date(timerItem.start).getTime() + const end = new Date(timerItem.end).getTime() + + if (isNaN(start) || isNaN(end) || end < start) { + return latest + } + + return Math.max(latest, end) + }, Number.NEGATIVE_INFINITY) + + return Number.isFinite(latestEnd) ? new Date(latestEnd).toISOString() : '' +} + export const calculateDailyPlayTime = (date: Date, timer: Timer[]): number => { const dayStart = new Date(date) dayStart.setHours(0, 0, 0, 0)