From 3c366f104da4f1457ab3b5e3b0d1283071ce3177 Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 26 Jun 2023 22:55:59 +0100 Subject: [PATCH] feat: Game time logging fix: Proper screenshot sizing on sidebar --- lang/en.json | 8 ++ src/back/GameLauncher.ts | 1 - src/back/game/GameManager.ts | 17 ++++ src/back/index.ts | 47 +++++----- src/back/playlist.ts | 2 +- src/back/responses.ts | 14 +++ src/database/entity/Game.ts | 12 +++ .../migration/1687807237714-PlayTime.ts | 16 ++++ src/renderer/app.tsx | 15 ++++ .../components/RightBrowseSidebar.tsx | 89 ++++++++++++++++++- src/shared/lang.ts | 10 +++ static/window/styles/core.css | 47 +++++++++- static/window/styles/fancy.css | 1 + typings/flashpoint-launcher.d.ts | 6 ++ 14 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 src/database/migration/1687807237714-PlayTime.ts diff --git a/lang/en.json b/lang/en.json index 4a41e0a32..39e53ac6f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -257,6 +257,14 @@ "noTitle": "No Title", "by": "by", "play": "Play", + "lastPlayed": "Last Played", + "playtime": "Playtime", + "playCount": "Play Count", + "never": "Never", + "today": "Today", + "yesterday": "Yesterday", + "daysAgo": "{0} Days Ago", + "weeksAgo": "{0} Weeks Ago", "stop": "Stop", "noDeveloper": "No Developer", "alternateTitles": "Alternate Titles", diff --git a/src/back/GameLauncher.ts b/src/back/GameLauncher.ts index c282d6b1f..b929f2b1c 100644 --- a/src/back/GameLauncher.ts +++ b/src/back/GameLauncher.ts @@ -555,7 +555,6 @@ async function handleGameDataParams(opts: LaunchBaseOpts, serverOverride?: strin export async function checkAndInstallPlatform(platforms: Platform[], state: BackState, openMessageBox: ShowMessageBoxFunc) { const compsToInstall: ComponentStatus[] = []; for (const platform of platforms) { - console.log(JSON.stringify(state.componentStatuses.map(c => c.name), undefined, 2)); const compIdx = state.componentStatuses.findIndex(c => c.name.toLowerCase() === platform.primaryAlias.name.toLowerCase()); if (compIdx > -1) { if (state.componentStatuses[compIdx].state === ComponentState.UNINSTALLED) { diff --git a/src/back/game/GameManager.ts b/src/back/game/GameManager.ts index e2be40dd6..504a99079 100644 --- a/src/back/game/GameManager.ts +++ b/src/back/game/GameManager.ts @@ -610,3 +610,20 @@ async function getGameQuery( return query; } + +export async function logGameStart(gameId: string) { + const game = await findGame(gameId); + if (game) { + game.lastPlayed = (new Date()).toISOString(); + game.playCounter += 1; + await save(game); + } +} + +export async function addGamePlaytime(gameId: string, time: number) { + const game = await findGame(gameId); + if (game) { + game.playtime = game.playtime + Math.ceil(time / 1000); + await save(game); + } +} diff --git a/src/back/index.ts b/src/back/index.ts index be3a08bca..bfc9f411a 100644 --- a/src/back/index.ts +++ b/src/back/index.ts @@ -13,21 +13,21 @@ import { SourceFileURL1612435692266 } from '@database/migration/1612435692266-So import { SourceFileCount1612436426353 } from '@database/migration/1612436426353-SourceFileCount'; import { GameTagsStr1613571078561 } from '@database/migration/1613571078561-GameTagsStr'; import { GameDataParams1619885915109 } from '@database/migration/1619885915109-GameDataParams'; -import { BackIn, BackInit, BackInitArgs, BackOut, BackResParams, ComponentState, ComponentStatus, DownloadDetails } from '@shared/back/types'; -import { LoadedCuration } from '@shared/curate/types'; -import { getContentFolderByKey } from '@shared/curate/util'; -import { ILogoSet, LogoSet } from '@shared/extensions/interfaces'; -import { IBackProcessInfo, RecursivePartial } from '@shared/interfaces'; -import { getDefaultLocalization, LangFileContent } from '@shared/lang'; import { ILogEntry, LogLevel } from '@shared/Log/interface'; -import { PreferencesFile } from '@shared/preferences/PreferencesFile'; -import { defaultPreferencesData } from '@shared/preferences/util'; import { Theme } from '@shared/ThemeFile'; import { createErrorProxy, deepCopy, removeFileExtension, stringifyArray } from '@shared/Util'; +import { BackIn, BackInit, BackInitArgs, BackOut, BackResParams, ComponentState, ComponentStatus, DownloadDetails } from '@shared/back/types'; +import { LoadedCuration } from '@shared/curate/types'; +import { getContentFolderByKey } from '@shared/curate/util'; +import { ILogoSet, LogoSet } from '@shared/extensions/interfaces'; +import { IBackProcessInfo, RecursivePartial } from '@shared/interfaces'; +import { LangFileContent, getDefaultLocalization } from '@shared/lang'; +import { PreferencesFile } from '@shared/preferences/PreferencesFile'; +import { defaultPreferencesData } from '@shared/preferences/util'; import { validateSemiUUID } from '@shared/utils/uuid'; import * as child_process from 'child_process'; import { EventEmitter } from 'events'; @@ -36,7 +36,7 @@ import { http as httpFollow, https as httpsFollow } from 'follow-redirects'; import * as fs from 'fs-extra'; import * as http from 'http'; import * as mime from 'mime'; -import { extractFull, Progress } from 'node-7z'; +import { Progress, extractFull } from 'node-7z'; import * as path from 'path'; import 'reflect-metadata'; import { genCurationWarnings, loadCurationFolder } from './curate/util'; @@ -47,6 +47,10 @@ import { RemoveSources1676712700000 } from '@database/migration/1676712700000-Re import { RemovePlaylist1676713895000 } from '@database/migration/1676713895000-RemovePlaylist'; import { TagifyPlatform1677943090621 } from '@database/migration/1677943090621-TagifyPlatform'; import { AddPlatformsRedundancyFieldToGame1677951346785 } from '@database/migration/1677951346785-AddPlatformsRedundancyFieldToGame'; +import { GDIndex1680813346696 } from '@database/migration/1680813346696-GDIndex'; +import { MoveLaunchPath1681561150000 } from '@database/migration/1681561150000-MoveLaunchPath'; +import { PrimaryPlatform1684673859425 } from '@database/migration/1684673859425-PrimaryPlatform'; +import { PlayTime1687807237714 } from '@database/migration/1687807237714-PlayTime'; import { CURATIONS_FOLDER_EXPORTED, CURATIONS_FOLDER_EXTRACTING, @@ -56,32 +60,32 @@ import { import { Tail } from 'tail'; import { DataSource, DataSourceOptions } from 'typeorm'; import { ConfigFile } from './ConfigFile'; +import { loadExecMappingsFile } from './Execs'; +import { ExtConfigFile } from './ExtConfigFile'; +import { InstancedAbortController } from './InstancedAbortController'; +import { ManagedChildProcess, onServiceChange } from './ManagedChildProcess'; +import { PlaylistFile } from './PlaylistFile'; +import { ServicesFile } from './ServicesFile'; +import { SocketServer } from './SocketServer'; +import { newThemeWatcher } from './Themes'; import { CONFIG_FILENAME, EXT_CONFIG_FILENAME, PREFERENCES_FILENAME, SERVICES_SOURCE } from './constants'; import { loadCurationIndexImage } from './curate/parse'; import { readCurationMeta } from './curate/read'; import { onFileServerRequestCurationFileFactory, onFileServerRequestPostCuration } from './curate/util'; -import { loadExecMappingsFile } from './Execs'; -import { ExtConfigFile } from './ExtConfigFile'; import { ApiEmitter } from './extensions/ApiEmitter'; import { ExtensionService } from './extensions/ExtensionService'; import { FPLNodeModuleFactory, INodeModuleFactory, + SqliteInterceptorFactory, installNodeInterceptor, - registerInterceptor, - SqliteInterceptorFactory + registerInterceptor } from './extensions/NodeInterceptor'; import { Command } from './extensions/types'; import * as GameManager from './game/GameManager'; import { onWillImportCuration } from './importGame'; -import { InstancedAbortController } from './InstancedAbortController'; -import { ManagedChildProcess, onServiceChange } from './ManagedChildProcess'; -import { PlaylistFile } from './PlaylistFile'; import { registerRequestCallbacks } from './responses'; import { genContentTree } from './rust'; -import { ServicesFile } from './ServicesFile'; -import { SocketServer } from './SocketServer'; -import { newThemeWatcher } from './Themes'; import { BackState, ImageDownloadItem } from './types'; import { EventQueue } from './util/EventQueue'; import { FileServer, serveFile } from './util/FileServer'; @@ -90,9 +94,6 @@ import { LogFile } from './util/LogFile'; import { logFactory } from './util/logging'; import { createContainer, exit, getMacPATH, runService } from './util/misc'; import { uuid } from './util/uuid'; -import { GDIndex1680813346696 } from '@database/migration/1680813346696-GDIndex'; -import { MoveLaunchPath1681561150000 } from '@database/migration/1681561150000-MoveLaunchPath'; -import { PrimaryPlatform1684673859425 } from '@database/migration/1684673859425-PrimaryPlatform'; const dataSourceOptions: DataSourceOptions = { type: 'better-sqlite3', @@ -101,7 +102,7 @@ const dataSourceOptions: DataSourceOptions = { migrations: [Initial1593172736527, AddExtremeToPlaylist1599706152407, GameData1611753257950, SourceDataUrlPath1612434225789, SourceFileURL1612435692266, SourceFileCount1612436426353, GameTagsStr1613571078561, GameDataParams1619885915109, RemoveSources1676712700000, RemovePlaylist1676713895000, TagifyPlatform1677943090621, AddPlatformsRedundancyFieldToGame1677951346785, GDIndex1680813346696, MoveLaunchPath1681561150000, - PrimaryPlatform1684673859425 + PrimaryPlatform1684673859425, PlayTime1687807237714 ] }; export let AppDataSource: DataSource = new DataSource(dataSourceOptions); diff --git a/src/back/playlist.ts b/src/back/playlist.ts index 1f11c159f..057a62e95 100644 --- a/src/back/playlist.ts +++ b/src/back/playlist.ts @@ -152,7 +152,7 @@ export async function importPlaylist(state: BackState, filePath: string, library state.socketServer.send(event.client, BackOut.IMPORT_PLAYLIST, newPlaylist); } } catch (e) { - console.log(e); + log.error('Launcher', `Error importing playlist: ${e}`); } } diff --git a/src/back/responses.ts b/src/back/responses.ts index dab80e3f9..a592f7e74 100644 --- a/src/back/responses.ts +++ b/src/back/responses.ts @@ -2363,12 +2363,26 @@ function runGameFactory(state: BackState) { kill: true } ); + if (proc.getState() === ProcessState.RUNNING) { + // Update game last played + GameManager.logGameStart(gameLaunchInfo.game.id); + } else { + proc.on('change', async () => { + if (proc.getState() === ProcessState.RUNNING) { + // Update game last played + await GameManager.logGameStart(gameLaunchInfo.game.id); + } + }); + } // Remove game service when it exits proc.on('change', () => { if (proc.getState() === ProcessState.STOPPED) { + // Update game playtime counter + GameManager.addGamePlaytime(gameLaunchInfo.game.id, Date.now() - proc.getStartTime()); removeService(state, proc.id); } }); + return proc; }; } diff --git a/src/database/entity/Game.ts b/src/database/entity/Game.ts index 4e356c3ca..f0b72d0bb 100644 --- a/src/database/entity/Game.ts +++ b/src/database/entity/Game.ts @@ -152,6 +152,18 @@ export class Game { @OneToMany(() => GameData, datas => datas.game) data?: GameData[]; + /** Last Played Date */ + @Column({ type: 'datetime' }) + lastPlayed?: string; + + /** Total Playtime (seconds) */ + @Column({ default: 0 }) + playtime: number; + + /** Number of plays */ + @Column({ default: 0 }) + playCounter: number; + // This doesn't run... sometimes. @BeforeUpdate() updateTagsStr() { diff --git a/src/database/migration/1687807237714-PlayTime.ts b/src/database/migration/1687807237714-PlayTime.ts new file mode 100644 index 000000000..6436573f9 --- /dev/null +++ b/src/database/migration/1687807237714-PlayTime.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class PlayTime1687807237714 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "game" ADD COLUMN "lastPlayed" datetime`); + await queryRunner.query(`ALTER TABLE "game" ADD COLUMN "playtime" integer DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "game" ADD COLUMN "playCounter" integer DEFAULT 0`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "game" DROP COLUMN "lastPlayed"`); + await queryRunner.query(`ALTER TABLE "game" DROP COLUMN "playtime"`); + await queryRunner.query(`ALTER TABLE "game" DROP COLUMN "playCounter"`); + } + +} diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index af7d004a5..88f633ed1 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -325,6 +325,21 @@ export class App extends React.Component { window.Shared.back.register(BackOut.SERVICE_CHANGE, (event, data) => { if (data.id) { + // Check if game just stopped, update to reflect time played changes if so + if (this.props.main.currentGame && data.state === ProcessState.STOPPED) { + if (data.id.startsWith('game.') && data.id.length > 5) { + const id = data.id.slice(5); + if (id === this.props.main.currentGame.id) { + // Reload game in sidebar + window.Shared.back.request(BackIn.GET_GAME, this.props.main.currentGame.id) + .then((game) => { + if (game && this.props.main.selectedGameId === game.id) { + this.props.setMainState({ currentGame: game }); + } + }); + } + } + } const newServices = [...this.props.main.services]; const service = newServices.find(item => item.id === data.id); if (service) { diff --git a/src/renderer/components/RightBrowseSidebar.tsx b/src/renderer/components/RightBrowseSidebar.tsx index 7377b8c99..a40e7732d 100644 --- a/src/renderer/components/RightBrowseSidebar.tsx +++ b/src/renderer/components/RightBrowseSidebar.tsx @@ -386,6 +386,27 @@ export class RightBrowseSidebar extends React.Component ) } + {/** Gameplay Statistics */} + { isPlaceholder || this.props.fpfssEditMode ? undefined : ( +
+
+
+ {strings.lastPlayed} +
+
+ {game.lastPlayed ? formatLastPlayed(game.lastPlayed, strings) : strings.never} +
+
+
+
+ {strings.playtime} +
+
+ {formatPlaytime(game.playtime)} +
+
+
+ )}
-
+
@@ -743,6 +764,10 @@ export class RightBrowseSidebar extends React.Component ) : undefined } +
+ )} + { !this.props.fpfssEditMode && ( +
this.setState({ gameDataBrowserOpen: !this.state.gameDataBrowserOpen })}/> @@ -1291,3 +1316,65 @@ function openContextMenu(template: MenuItemConstructorOptions[]): Menu { menu.popup({ window: remote.getCurrentWindow() }); return menu; } + +/** + * Get a formatted last played string (Rounded to the nearest useful amount) + * + * @param lastPlayed Last Played Date + * @param strings localized strings + */ +function formatLastPlayed(lastPlayed: string, strings: any): string { + const secondsInDay = 60 * 60 * 24; + const lpdate = new Date(lastPlayed); + const diff = Math.ceil((Date.now() - lpdate.getTime()) / 1000); + + if (diff < (secondsInDay * 2)) { + if ((new Date()).getDate() === lpdate.getDate()) { + return strings.today; + } else { + return strings.yesterday; + } + } else if (diff < (secondsInDay * 8)) { + return formatString(strings.daysAgo, Math.floor(diff / secondsInDay).toString()) as string; + } else if (diff < (secondsInDay * 7 * 4)) { + return formatString(strings.weeksAgo, Math.floor(diff / (secondsInDay * 7)).toString()) as string; + } else { + const ordinal = ordinalSuffixOf(lpdate.getDate()); + const month = lpdate.toLocaleString('default', { month: 'long' }); + return `${ordinal} ${month} ${lpdate.getFullYear()}`; + } +} + +/** + * Get a formatted playtime string (Rounded to the nearest useful amount) + * + * @param playtime Seconds of playtime + */ +function formatPlaytime(playtime: number): string { + // Less than 1 minute + if (playtime <= 60) { + return `${playtime} Seconds`; + } + // Less than 2 hours + if (playtime <= (60 * 120)) { + return `${Math.floor(playtime / 60)} Minutes`; + } else { + return `${(playtime / (60 * 60)).toFixed(1)} Hours`; + } +} + +// https://stackoverflow.com/questions/13627308/add-st-nd-rd-and-th-ordinal-suffix-to-a-number +function ordinalSuffixOf(i: number) { + const j = i % 10, + k = i % 100; + if (j == 1 && k != 11) { + return i + 'st'; + } + if (j == 2 && k != 12) { + return i + 'nd'; + } + if (j == 3 && k != 13) { + return i + 'rd'; + } + return i + 'th'; +} diff --git a/src/shared/lang.ts b/src/shared/lang.ts index b0b4d2b44..46e72709e 100644 --- a/src/shared/lang.ts +++ b/src/shared/lang.ts @@ -266,6 +266,14 @@ const langTemplate = { 'noTitle', 'by', 'play', + 'lastPlayed', + 'playtime', + 'playCount', + 'never', + 'today', + 'yesterday', + 'daysAgo', + 'weeksAgo', 'stop', 'noDeveloper', 'alternateTitles', @@ -694,6 +702,8 @@ export function getDefaultLocalization(): LangContainer { lang.browse.dropGameOnLeft += ' {0}'; lang.browse.setFlashpointPathQuestion += ' {0} {1}'; lang.browse.noteSaveAndRestart += ' {0}'; + lang.browse.daysAgo += ' {0}'; + lang.browse.weeksAgo += ' {0}'; lang.misc.noBlankFound = ' {0} ' + lang.misc.noBlankFound; lang.misc.addBlank += ' {0}'; lang.misc.deleteAllBlankImages += ' {0}'; diff --git a/static/window/styles/core.css b/static/window/styles/core.css index f75cd7e19..d34fd5163 100644 --- a/static/window/styles/core.css +++ b/static/window/styles/core.css @@ -1532,17 +1532,57 @@ body { overflow-x: hidden; display: flex; flex-grow: 1; - min-height: 30%; + min-height: 40%; flex-direction: column; padding: 0 0.8rem; } -.browse-right-sidebar__bottom { +.browse-right-sidebar__super-bottom { flex-shrink: 0; margin-top: 0.5rem; display: flex; flex-direction: column; padding: 0 0.8rem 0.5rem 0.8rem; } +.browse-right-sidebar__bottom { + flex-shrink: 0; + margin-top: 0.5rem; + display: flex; + max-height: 30%; + flex-direction: column; + padding: 0 0.8rem 0 0.8rem; +} +.browse-right-sidebar__bottom > .browse-right-sidebar__section { + height: 100%; +} +.browse-right-sidebar__bottom > .browse-right-sidebar__section > .browse-right-sidebar__row__screenshot-container { + height: 100%; +} +.browse-right-sidebar__bottom > .browse-right-sidebar__section > .browse-right-sidebar__row__screenshot-container > .browse-right-sidebar__row__screenshot { + height: 100%; +} +.browse-right-sidebar__bottom > .browse-right-sidebar__section > .browse-right-sidebar__row__screenshot-container > .browse-right-sidebar__row__screenshot> img { + height: 100%; +} + +/* Browse game stats */ +.browse-right-sidebar__stats { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; +} +.browse-right-sidebar__stats-row { + display: flex; + flex-direction: row; + justify-content: space-between; +} +.browse-right-sidebar__stats-row-left { + font-weight: bold; +} +.browse-right-sidebar__stats-row-right { + text-align: left; +} + + /* Browse-Right-Sidebar Play Button */ .browse-right-sidebar__play-button, @@ -1553,7 +1593,7 @@ body { font-size: 2.5em; text-align: center; margin: auto; - margin-bottom: 0.5rem; + margin-bottom: 0.2rem; user-select: none; cursor: pointer; max-height: 3.2rem; @@ -1687,6 +1727,7 @@ body { } .browse-right-sidebar__row__screenshot-image { width: 100%; + object-fit: contain; cursor: zoom-in; } .browse-right-sidebar__row__screenshot-image--hidden { diff --git a/static/window/styles/fancy.css b/static/window/styles/fancy.css index 5d6d32ed3..cafaa1df0 100644 --- a/static/window/styles/fancy.css +++ b/static/window/styles/fancy.css @@ -832,6 +832,7 @@ body { .browse-right-sidebar__row__screenshot-image--hidden:hover { background-color: var(--layout__simple-button-background); } + /* GameImageSplit */ .game-image-split { --inner-border-color: var(--layout__secondary-background); diff --git a/typings/flashpoint-launcher.d.ts b/typings/flashpoint-launcher.d.ts index 6b9462cdf..f55314273 100644 --- a/typings/flashpoint-launcher.d.ts +++ b/typings/flashpoint-launcher.d.ts @@ -599,6 +599,12 @@ declare module 'flashpoint-launcher' { /** Whether the data is present on disk */ activeDataOnDisk: boolean; data?: GameData[]; + /** Last Played Date */ + lastPlayed?: string; + /** Total Playtime (seconds) */ + playtime: number; + /** Number of plays */ + playCounter: number; updateTagsStr: () => void; };