├── .eslintignore ├── .npmrc ├── src ├── renderer │ ├── src │ │ ├── types │ │ │ ├── index.ts │ │ │ ├── util.ts │ │ │ └── ScrollElement.ts │ │ ├── enums │ │ │ ├── index.ts │ │ │ ├── ScrollType.ts │ │ │ └── Input.ts │ │ ├── atoms │ │ │ ├── store │ │ │ │ └── store.ts │ │ │ ├── defaults │ │ │ │ └── gameDisplayTypes.ts │ │ │ ├── emulators.ts │ │ │ ├── util │ │ │ │ └── objConfigAtom.ts │ │ │ ├── appConfig.ts │ │ │ ├── runningGame.ts │ │ │ ├── notifications.ts │ │ │ ├── collections.ts │ │ │ └── systems.ts │ │ ├── scss │ │ │ ├── util.scss │ │ │ └── colors.scss │ │ ├── eventHandler │ │ │ └── index.ts │ │ ├── components │ │ │ ├── Showcase │ │ │ │ ├── presets │ │ │ │ │ ├── game │ │ │ │ │ │ ├── gamePreset.module.scss │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── system │ │ │ │ │ │ ├── systemPreset.module.scss │ │ │ │ │ │ └── index.ts │ │ │ │ └── Showcase.module.scss │ │ │ ├── EmuNotFoundModal │ │ │ │ ├── EmuNotFound.module.scss │ │ │ │ └── EmuNotFound.tsx │ │ │ ├── ControllerHints │ │ │ │ ├── ControllerHints.module.scss │ │ │ │ └── ControllerHints.tsx │ │ │ ├── Label │ │ │ │ ├── Label.module.scss │ │ │ │ └── Label.tsx │ │ │ ├── Modal │ │ │ │ ├── Modal.module.scss │ │ │ │ └── Modal.tsx │ │ │ ├── Pill │ │ │ │ ├── Pill.module.scss │ │ │ │ ├── hooks │ │ │ │ │ ├── useSystemPills.ts │ │ │ │ │ └── useGamePills.ts │ │ │ │ └── index.tsx │ │ │ ├── ScrapeModal │ │ │ │ ├── ScrapeModal.module.scss │ │ │ │ └── ScrapeModal.tsx │ │ │ ├── CollectionModal │ │ │ │ ├── CollectionModal.module.scss │ │ │ │ └── CollectionModal.tsx │ │ │ ├── Marquee │ │ │ │ └── index.tsx │ │ │ ├── GameListPage │ │ │ │ ├── GameListPage.module.scss │ │ │ │ └── GameListPage.tsx │ │ │ ├── GameSettingsModal │ │ │ │ ├── GameSettingsModal.module.scss │ │ │ │ └── GameSettingsModal.tsx │ │ │ ├── GridScroller │ │ │ │ └── GridScroller.module.scss │ │ │ ├── Scroller │ │ │ │ └── Scroller.module.scss │ │ │ ├── MediaTile │ │ │ │ ├── Presets │ │ │ │ │ ├── SystemTile.tsx │ │ │ │ │ └── GameTile.tsx │ │ │ │ ├── MediaTile.tsx │ │ │ │ └── MediaTile.module.scss │ │ │ ├── Settings │ │ │ │ ├── Power │ │ │ │ │ └── Power.tsx │ │ │ │ ├── SectionSelector │ │ │ │ │ ├── SectionSelector.module.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Settings.module.scss │ │ │ │ ├── Stores │ │ │ │ │ ├── AlphabetSelector │ │ │ │ │ │ ├── AlphabetSelector.module.scss │ │ │ │ │ │ └── AlphabetSelector.tsx │ │ │ │ │ └── Stores.module.scss │ │ │ │ ├── Interface │ │ │ │ │ └── Interface.tsx │ │ │ │ └── Collections │ │ │ │ │ └── Collections.tsx │ │ │ ├── NavHeader │ │ │ │ └── NavHeader.module.scss │ │ │ ├── InputModal │ │ │ │ ├── InputModal.module.scss │ │ │ │ └── keyboard.scss │ │ │ ├── TabSelector │ │ │ │ ├── TabSelector.module.scss │ │ │ │ └── TabSelector.tsx │ │ │ ├── Notifications │ │ │ │ ├── Notifications.module.scss │ │ │ │ └── Notifications.tsx │ │ │ ├── SelectModal │ │ │ │ ├── SelectModal.module.scss │ │ │ │ └── SelectModal.tsx │ │ │ ├── ConfirmationModal │ │ │ │ ├── ConfirmationModal.module.scss │ │ │ │ └── ConfirmationModal.tsx │ │ │ ├── VerticalGameInfo │ │ │ │ ├── VerticalGameInfo.module.scss │ │ │ │ └── index.tsx │ │ │ ├── IconButtons │ │ │ │ ├── IconButtons.module.scss │ │ │ │ └── index.tsx │ │ │ ├── MediaImage │ │ │ │ └── MediaImage.tsx │ │ │ ├── GameArtSelection │ │ │ │ └── GameArtSelection.module.scss │ │ │ ├── ControllerForm │ │ │ │ └── ControllerForm.module.scss │ │ │ └── Scrollers │ │ │ │ └── Scrollers.tsx │ │ ├── assets │ │ │ ├── fonts │ │ │ │ └── Figtree-VariableFont_wght.ttf │ │ │ └── react.svg │ │ ├── const │ │ │ ├── scrapers.ts │ │ │ ├── inputPriorities.ts │ │ │ └── const.ts │ │ ├── util │ │ │ ├── wrappedIndex.ts │ │ │ └── queryParams │ │ │ │ └── IndexParam.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useDeferredValue.ts │ │ │ ├── useWindowFocus.ts │ │ │ ├── useWaveHeight.ts │ │ │ ├── useShrinkToFit.ts │ │ │ ├── useRecommendationScrollers.ts │ │ │ ├── useKeepVisible.ts │ │ │ └── useOnInput.ts │ │ ├── App.scss │ │ ├── vite-env.d.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── GameView │ │ │ │ ├── GameInfo │ │ │ │ │ ├── GameInfo.module.scss │ │ │ │ │ └── GameInfo.tsx │ │ │ │ ├── Recommendations │ │ │ │ │ └── Recommendations.tsx │ │ │ │ └── GameView.module.scss │ │ │ ├── Home │ │ │ │ └── Home.module.scss │ │ │ ├── AllGames │ │ │ │ └── index.tsx │ │ │ ├── Init │ │ │ │ └── Init.module.scss │ │ │ ├── SystemView │ │ │ │ └── index.tsx │ │ │ └── SearchView │ │ │ │ └── SearchView.tsx │ │ ├── colors │ │ │ ├── useChangeListener.ts │ │ │ └── colorSchemes.ts │ │ ├── index.scss │ │ └── apiWrappers │ │ │ └── IGDB.ts │ └── index.html ├── common │ ├── types │ │ ├── NameMapper.ts │ │ ├── ColorSchemes.ts │ │ ├── index.ts │ │ ├── Input.ts │ │ ├── InternalMediaType.ts │ │ ├── AppConfig.ts │ │ ├── Emulator.ts │ │ ├── Game.ts │ │ └── System.ts │ └── features │ │ ├── nameMapping │ │ ├── maps │ │ │ └── index.ts │ │ └── index.ts │ │ └── RetroArch │ │ └── index.ts ├── preload │ ├── fs │ │ └── index.ts │ ├── util │ │ ├── systemHasFlatpak.ts │ │ ├── initRomDir.ts │ │ ├── installEmulator.ts │ │ ├── removeGameFiles.ts │ │ ├── const.ts │ │ ├── getRomFileInfo.ts │ │ ├── getSystemPaths.ts │ │ ├── configStorage.ts │ │ ├── downloadGameMedia.ts │ │ ├── loadSystemStore.ts │ │ ├── loadMedia.ts │ │ ├── downloadGame.ts │ │ ├── launchGame.ts │ │ └── scanRoms.ts │ ├── index.d.ts │ └── index.ts └── main │ └── index.ts ├── .gitignore ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── resources ├── icon.png ├── input │ └── base │ │ ├── A.png │ │ ├── B.png │ │ ├── LB.png │ │ ├── LT.png │ │ ├── RB.png │ │ ├── RT.png │ │ ├── UP.png │ │ ├── X.png │ │ ├── Y.png │ │ ├── DOWN.png │ │ ├── DPAD.png │ │ ├── LEFT.png │ │ ├── RIGHT.png │ │ ├── START.png │ │ ├── DPADLR.png │ │ ├── DPADUD.png │ │ └── SELECT.png └── systems │ ├── gb │ └── logo.svg │ ├── wii │ └── logo.svg │ ├── gba │ └── logo.svg │ └── nds │ └── logo.svg ├── .prettierrc.yaml ├── .prettierignore ├── dev-app-update.yml ├── tsconfig.json ├── .editorconfig ├── .eslintrc.cjs ├── tsconfig.node.json ├── tsconfig.web.json ├── electron.vite.config.ts ├── CHANGELOG.md ├── electron-builder.yml └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ 2 | -------------------------------------------------------------------------------- /src/renderer/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ScrollElement' 2 | -------------------------------------------------------------------------------- /src/common/types/NameMapper.ts: -------------------------------------------------------------------------------- 1 | export type NameMapper = 'vita' | 'ps3' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | *.log* 5 | .env 6 | **/.DS_Store 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/icon.png -------------------------------------------------------------------------------- /src/renderer/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Input' 2 | export * from './ScrollType' 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /src/common/features/nameMapping/maps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './vita' 2 | export * from './ps3' 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /resources/input/base/A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/A.png -------------------------------------------------------------------------------- /resources/input/base/B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/B.png -------------------------------------------------------------------------------- /resources/input/base/LB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/LB.png -------------------------------------------------------------------------------- /resources/input/base/LT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/LT.png -------------------------------------------------------------------------------- /resources/input/base/RB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/RB.png -------------------------------------------------------------------------------- /resources/input/base/RT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/RT.png -------------------------------------------------------------------------------- /resources/input/base/UP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/UP.png -------------------------------------------------------------------------------- /resources/input/base/X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/X.png -------------------------------------------------------------------------------- /resources/input/base/Y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/Y.png -------------------------------------------------------------------------------- /src/renderer/src/types/util.ts: -------------------------------------------------------------------------------- 1 | export type PartialBy = Omit & Partial 2 | -------------------------------------------------------------------------------- /resources/input/base/DOWN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/DOWN.png -------------------------------------------------------------------------------- /resources/input/base/DPAD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/DPAD.png -------------------------------------------------------------------------------- /resources/input/base/LEFT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/LEFT.png -------------------------------------------------------------------------------- /resources/input/base/RIGHT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/RIGHT.png -------------------------------------------------------------------------------- /resources/input/base/START.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/START.png -------------------------------------------------------------------------------- /src/preload/fs/index.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'fs/promises' 2 | 3 | export default { 4 | readdir 5 | } 6 | -------------------------------------------------------------------------------- /resources/input/base/DPADLR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/DPADLR.png -------------------------------------------------------------------------------- /resources/input/base/DPADUD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/DPADUD.png -------------------------------------------------------------------------------- /resources/input/base/SELECT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/resources/input/base/SELECT.png -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: generic 2 | url: https://example.com/auto-updates 3 | updaterCacheDirName: electron-app-updater 4 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/store/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'jotai' 2 | 3 | export const jotaiStore = createStore() 4 | -------------------------------------------------------------------------------- /src/renderer/src/scss/util.scss: -------------------------------------------------------------------------------- 1 | @mixin hide-scrollbar { 2 | &::-webkit-scrollbar { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /src/renderer/src/eventHandler/index.ts: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents' 2 | export const eventHandler = createNanoEvents() 3 | -------------------------------------------------------------------------------- /src/renderer/src/enums/ScrollType.ts: -------------------------------------------------------------------------------- 1 | export enum ScrollType { 2 | HORIZONTAL, 3 | VERTICAL, 4 | GRID_HORIZONTAL, 5 | GRID_VERTICAL 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/src/components/Showcase/presets/game/gamePreset.module.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | font-size: 2rem; 3 | padding: 0 3rem; 4 | text-align: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/src/assets/fonts/Figtree-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhythmerc/emu-hub/HEAD/src/renderer/src/assets/fonts/Figtree-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/renderer/src/types/ScrollElement.ts: -------------------------------------------------------------------------------- 1 | export interface ScrollElement { 2 | id: number | string 3 | name?: string 4 | poster?: string 5 | logo?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/src/components/EmuNotFoundModal/EmuNotFound.module.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | h2 { 3 | margin: 0; 4 | } 5 | 6 | p { 7 | margin-bottom: 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/common/types/ColorSchemes.ts: -------------------------------------------------------------------------------- 1 | export type ColorSchemeId = 'default' 2 | | 'burgendy' 3 | | 'violet' 4 | | 'aquamarine' 5 | | 'copper' 6 | | 'crimson' 7 | | 'pc-classic' 8 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/defaults/gameDisplayTypes.ts: -------------------------------------------------------------------------------- 1 | export const DefaultGameDisplayType = { 2 | showcase: 'screenshot', 3 | gamePage: 'screenshot', 4 | gameTile: 'fanart' 5 | } as const 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /src/renderer/src/const/scrapers.ts: -------------------------------------------------------------------------------- 1 | export const scrapers = [ 2 | { 3 | id: 'screenscraper', 4 | label: 'ScreenScraper' 5 | }, 6 | { 7 | id: 'igdb', 8 | label: 'IGDB' 9 | } 10 | ] as const 11 | -------------------------------------------------------------------------------- /src/renderer/src/util/wrappedIndex.ts: -------------------------------------------------------------------------------- 1 | export const wrappedIndex = (index: number, listLength: number) => { 2 | if(index < 0) return 0 3 | if(index > listLength - 1) return listLength - 1 4 | 5 | return index 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/src/components/ControllerHints/ControllerHints.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .hints { 4 | color: hsla(0, 0%, 100%, 60%); 5 | } 6 | 7 | .icon { 8 | height: 45%; 9 | opacity: .7; 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/src/const/inputPriorities.ts: -------------------------------------------------------------------------------- 1 | export const InputPriority = { 2 | HEADER_BAR: 30, 3 | HEADER_BAR_OPEN_CLOSE: 19, 4 | SETTINGS_MODAL: 20, 5 | SETTINGS_MODAL_OPEN_CLOSE: 29, 6 | GENERAL_MODAL: 30, 7 | INPUT_MODAL: 99 8 | } as const 9 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useOnInput' 2 | export * from './useKeepVisible' 3 | export * from './useWaveHeight' 4 | export * from './useRecommendationScrollers' 5 | export * from './useWindowFocus' 6 | export * from './useDeferredValue' 7 | -------------------------------------------------------------------------------- /src/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Emulator' 2 | export * from './Game' 3 | export * from './System' 4 | export * from './ColorSchemes' 5 | export * from './InternalMediaType' 6 | export * from './NameMapper' 7 | export * from './System' 8 | export * from './AppConfig' -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended', 5 | 'plugin:react/jsx-runtime', 6 | '@electron-toolkit/eslint-config-ts/recommended', 7 | '@electron-toolkit/eslint-config-prettier' 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/src/App.scss: -------------------------------------------------------------------------------- 1 | @import './scss/colors.scss'; 2 | 3 | .wave { 4 | position: fixed; 5 | bottom: 0; 6 | height: 100%; 7 | z-index: -1; 8 | } 9 | 10 | .flipper { 11 | height: 100%; 12 | width: 100%; 13 | } 14 | 15 | .motion-wrapper { 16 | height: 100%; 17 | width: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/Label/Label.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .label { 4 | font-family: 5 | Figtree Variable, 6 | sans-serif; 7 | font-weight: 500; 8 | font-size: 1.2em; 9 | color: $faded; 10 | 11 | span { 12 | color: $fadedplus; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly RENDERER_VITE_SCREENSCRAPER_DEVID: string 5 | readonly RENDERER_VITE_SCREENSCRAPER_DEVPASSWORD: string 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/preload/util/systemHasFlatpak.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import os from "os"; 3 | 4 | let hasFlatpak = false; 5 | 6 | if(os.platform() === "linux") { 7 | try { 8 | execSync('which flatpak'); 9 | hasFlatpak = true; 10 | } catch {} 11 | } 12 | 13 | export { hasFlatpak } 14 | -------------------------------------------------------------------------------- /src/renderer/src/components/Showcase/presets/system/systemPreset.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .systemShowcaseLogo { 4 | height: 80%; 5 | width: 80%; 6 | } 7 | 8 | .systemShowcaseName { 9 | font-size: 1.8rem; 10 | } 11 | 12 | .systemShowcaseRight { 13 | gap: 1.5rem; 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useDeferredValue.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | export const useDeferredValue = (deferred: T, initial: T) => { 4 | const [value, setValue] = useState(initial) 5 | useEffect(() => { 6 | setValue(deferred) 7 | }, [deferred]) 8 | 9 | return value 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/src/enums/Input.ts: -------------------------------------------------------------------------------- 1 | export enum Input { 2 | LEFT = 'LEFT', 3 | RIGHT = 'RIGHT', 4 | UP = 'UP', 5 | DOWN = 'DOWN', 6 | A = 'A', 7 | B = 'B', 8 | X = 'X', 9 | Y = 'Y', 10 | START = 'START', 11 | SELECT = 'SELECT', 12 | LB = 'LB', 13 | RB = 'RB', 14 | LT = 'LT', 15 | RT = 'RT' 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/src/components/Modal/Modal.module.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | width: 100vw; 4 | height: 100vh; 5 | background-color: rgba(0, 0, 0, 0.6); 6 | z-index: 4; 7 | top: 0; 8 | 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | backdrop-filter: blur(2px); 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/src/components/Pill/Pill.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .pill { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | width: max-content; 8 | 9 | font-size: 1rem; 10 | font-family: Figtree Variable; 11 | gap: 0.5rem; 12 | color: $text-secondary; 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/src/components/ScrapeModal/ScrapeModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | border-radius: 20px; 5 | overflow: hidden; 6 | } 7 | 8 | .container { 9 | width: 600px; 10 | background-color: var(--background); 11 | display: flex; 12 | flex-direction: column; 13 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 14 | } 15 | -------------------------------------------------------------------------------- /src/common/features/RetroArch/index.ts: -------------------------------------------------------------------------------- 1 | export const raEmulatorEntry = { 2 | name: 'RetroArch', 3 | id: 'retroarch', 4 | location: { 5 | linux: { 6 | flatpak: 'org.libretro.RetroArch', 7 | appImage: 'RetroArch', 8 | binName: 'retroarch' 9 | }, 10 | darwin: { 11 | name: 'RetroArch' 12 | } 13 | }, 14 | args: ['-f'] 15 | } as const 16 | -------------------------------------------------------------------------------- /src/renderer/src/components/CollectionModal/CollectionModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | border-radius: 20px; 5 | overflow: hidden; 6 | } 7 | 8 | .container { 9 | width: 30vw; 10 | background-color: var(--background); 11 | display: flex; 12 | flex-direction: column; 13 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 14 | } 15 | -------------------------------------------------------------------------------- /src/common/types/Input.ts: -------------------------------------------------------------------------------- 1 | export enum Input { 2 | LEFT = 'LEFT', 3 | RIGHT = 'RIGHT', 4 | UP = 'UP', 5 | DOWN = 'DOWN', 6 | A = 'A', 7 | B = 'B', 8 | X = 'X', 9 | Y = 'Y', 10 | START = 'START', 11 | SELECT = 'SELECT', 12 | LB = 'LB', 13 | RB = 'RB', 14 | LT = 'LT', 15 | RT = 'RT' 16 | } 17 | 18 | export type InputLabel = Input | "DPAD" | "DPADLR" | "DPADUD" 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/Marquee/index.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | 3 | interface Props { 4 | className?: string 5 | delay?: number 6 | velocity?: number 7 | } 8 | 9 | const Marquee = ({ children, className }: PropsWithChildren) => { 10 | // TODO: Get marquee scroll working 11 | return
{children}
12 | } 13 | 14 | export default Marquee 15 | -------------------------------------------------------------------------------- /src/renderer/src/components/GameListPage/GameListPage.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: flex; 4 | } 5 | 6 | .left { 7 | flex: 4; 8 | overflow: hidden; 9 | padding: 1rem; 10 | } 11 | 12 | .right { 13 | flex: 2.5; 14 | overflow: hidden; 15 | height: 100%; 16 | background-color: rgba(0, 0, 0, 0.2); 17 | } 18 | 19 | .gameView { 20 | padding: 2rem; 21 | height: 100%; 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/emulators.ts: -------------------------------------------------------------------------------- 1 | import { Emulator } from '@common/types/Emulator' 2 | import { arrayConfigAtoms } from './util/arrayConfigAtom' 3 | import defaultEmulators from './defaults/emulators' 4 | 5 | export default arrayConfigAtoms({ 6 | storageKey: 'emulators', 7 | default: defaultEmulators, 8 | splitUserEntries: { 9 | arrOverrides: { 10 | args: "overwrite", 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | EmuHub 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/*", "src/preload/*", "src/preload/*/*", "src/common/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["electron-vite/node"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "@common/*": [ 10 | "src/common/*" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/common/types/InternalMediaType.ts: -------------------------------------------------------------------------------- 1 | import { InputLabel } from "./Input" 2 | 3 | export type InternalMediaType = 'system' 4 | export type MediaImageData = 5 | | { 6 | resourceCollection: 'systems' 7 | resourceId: string 8 | resourceType: 'logo' | 'image' | 'logoAlt' 9 | } 10 | | { 11 | resourceCollection: 'input' 12 | resourceId: 'base' 13 | resourceType: InputLabel 14 | } 15 | | string 16 | -------------------------------------------------------------------------------- /src/renderer/src/components/Pill/hooks/useSystemPills.ts: -------------------------------------------------------------------------------- 1 | import { System } from '@common/types' 2 | import { Pill } from '@renderer/components/Showcase' 3 | import { useMemo } from 'react' 4 | 5 | const useSystemPills = (system: System | null) => { 6 | const pillElems = useMemo(() => { 7 | if (!system) return [] 8 | return [] 9 | }, [system]) 10 | 11 | return pillElems 12 | } 13 | 14 | export default useSystemPills 15 | -------------------------------------------------------------------------------- /src/renderer/src/components/GameSettingsModal/GameSettingsModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | width: 600px; 5 | background-color: var(--background); 6 | border-radius: 20px; 7 | font-family: Figtree Variable; 8 | color: white; 9 | overflow: hidden; 10 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 11 | } 12 | 13 | .label { 14 | padding: 30px 45px; 15 | text-align: center; 16 | font-size: 1.5rem; 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/util/objConfigAtom.ts: -------------------------------------------------------------------------------- 1 | import { withImmer } from 'jotai-immer' 2 | import { atomWithStorage } from 'jotai/utils' 3 | 4 | interface ObjConfigOptions { 5 | storageKey: string 6 | defaults: T 7 | } 8 | 9 | export const objConfigAtom = >(options: ObjConfigOptions) => 10 | withImmer( 11 | atomWithStorage(options.storageKey, options.defaults, window.configStorage, { 12 | getOnInit: true 13 | }) 14 | ) 15 | -------------------------------------------------------------------------------- /src/common/features/nameMapping/index.ts: -------------------------------------------------------------------------------- 1 | import { NameMapper } from '@common/types/NameMapper' 2 | import { vitaMap, ps3Map } from './maps' 3 | 4 | const nameMaps: Record> = { 5 | vita: vitaMap, 6 | ps3: ps3Map 7 | } 8 | 9 | export const nameMappers = Object.entries(nameMaps).reduce( 10 | (acc, [name, map]) => ({ 11 | ...acc, 12 | [name]: (key: string) => map[key] ?? key 13 | }), 14 | {} as Record string> 15 | ) 16 | -------------------------------------------------------------------------------- /src/renderer/src/components/Label/Label.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import css from './Label.module.scss' 3 | 4 | const Label = ({ 5 | className, 6 | label, 7 | sublabel 8 | }: { 9 | label: string 10 | sublabel?: string 11 | className?: string 12 | }) => { 13 | return ( 14 |
15 | {label} 16 | {sublabel && - {sublabel}} 17 |
18 | ) 19 | } 20 | 21 | export default Label 22 | -------------------------------------------------------------------------------- /src/common/types/AppConfig.ts: -------------------------------------------------------------------------------- 1 | import { ColorSchemeId } from "@common/types" 2 | 3 | export interface AppConfig { 4 | ui: { 5 | grid: { 6 | columnCount: number 7 | } 8 | colorScheme: ColorSchemeId, 9 | controllerHints: boolean 10 | } 11 | paths: { 12 | RetroArch?: string 13 | roms: string 14 | }, 15 | credentials: { 16 | screenscraper: { 17 | username: string 18 | password: string 19 | } 20 | }, 21 | game: { 22 | enableQuitShortcut: boolean 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.scss' 5 | import { Provider } from 'jotai' 6 | import { jotaiStore } from './atoms/store/store' 7 | import '@fontsource-variable/figtree' 8 | import '@fontsource-variable/figtree/wght-italic.css' 9 | 10 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /src/renderer/src/pages/GameView/GameInfo/GameInfo.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | gap: 1.5rem; 7 | } 8 | 9 | .pills { 10 | display: flex; 11 | gap: 1rem; 12 | } 13 | 14 | .description { 15 | max-height: 65vh; 16 | max-width: 1300px; 17 | background-color: rgba(0, 0, 0, 0.3); 18 | padding: 1.8rem; 19 | border-radius: 1rem; 20 | 21 | .descriptionText { 22 | height: 100%; 23 | white-space: pre-wrap; 24 | overflow: hidden; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/src/components/Pill/index.tsx: -------------------------------------------------------------------------------- 1 | import { IconType } from 'react-icons' 2 | import css from './Pill.module.scss' 3 | import classNames from 'classnames' 4 | 5 | export interface PillType { 6 | Icon: IconType 7 | text: string 8 | id: string 9 | } 10 | 11 | export default ({ 12 | Icon, 13 | label, 14 | className 15 | }: { 16 | Icon: IconType 17 | label: string 18 | className?: string 19 | }) => { 20 | return ( 21 |
22 | 23 | {label} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/src/**/*", 6 | "src/renderer/src/**/*.tsx", 7 | "src/preload/*.d.ts", 8 | "src/common/**/*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "jsx": "react-jsx", 13 | "baseUrl": ".", 14 | "paths": { 15 | "@renderer/*": [ 16 | "src/renderer/src/*" 17 | ], 18 | "@common/*": [ 19 | "src/common/*" 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/common/types/Emulator.ts: -------------------------------------------------------------------------------- 1 | export interface Emulator { 2 | id: string 3 | name: string 4 | args?: string[] 5 | launchCommand?: string 6 | launchCommands?: { 7 | [ext: string]: string 8 | }, 9 | killSignal?: string 10 | location: 11 | | { 12 | core: string 13 | } 14 | | { 15 | bin: string 16 | } 17 | | { 18 | darwin?: { name: string } 19 | linux?: { 20 | flatpak?: string 21 | appImage?: string 22 | binName?: string 23 | snap?: string 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/src/pages/Home/Home.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .landing { 4 | height: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: stretch; 8 | position: relative; 9 | } 10 | 11 | .scrollers { 12 | overflow: hidden; 13 | scroll-behavior: smooth; 14 | padding: 0 0 20px 0; 15 | height: 100%; 16 | 17 | transition: opacity 0.2s ease; 18 | 19 | &.hidden { 20 | opacity: 0.4; 21 | } 22 | } 23 | 24 | .showcase { 25 | z-index: 2; 26 | } 27 | 28 | .shadowLong { 29 | box-shadow: 0px 2px 12px 3px rgba(0, 0, 0, 0.6); 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/src/const/const.ts: -------------------------------------------------------------------------------- 1 | export const APPNAME = 'EmuHub' 2 | export const version = '1.0.0-beta.2' 3 | export const SOFTNAME = `${APPNAME} v${version}` 4 | 5 | export const displayTypeMap = { 6 | tile: { 7 | screenshot: 'Screenshot + Logo', 8 | fanart: 'Fanart + Logo', 9 | poster: 'Poster' 10 | }, 11 | showcase: { 12 | screenshot: 'Screenshot', 13 | fanart: 'Fanart', 14 | poster: 'Poster' 15 | }, 16 | gameView: { 17 | screenshot: 'Screenshot', 18 | fanart: 'Fanart', 19 | poster: 'Poster' 20 | } 21 | } as const 22 | 23 | export const hintsHeight = "7vh" 24 | -------------------------------------------------------------------------------- /src/renderer/src/scss/colors.scss: -------------------------------------------------------------------------------- 1 | $background: hsl(200, 15%, 20%); 2 | $background-medium: hsl(200, 15%, 15%); 3 | $background-dark: hsl(200, 15%, 10%); 4 | $background-light: hsl(200, 15%, 30%); 5 | $background-lighter: hsl(200, 15%, 40%); 6 | $primary: hsl(200, 30%, 84%); 7 | 8 | $warning: hsl(0, 74%, 51%); 9 | $caution: hsl(0, 71.61%, 56.24%); 10 | $confirm: hsl(75, 70%, 60%); 11 | $highlight: hsl(36, 100%, 49%); 12 | 13 | $text-primary: white; 14 | $text-secondary: hsl(0, 0%, 90%); 15 | 16 | $fadedminus: hsla(0, 0%, 100%, 80%); 17 | $faded: hsla(0, 0%, 100%, 50%); 18 | $fadedplus: hsla(0, 0%, 100%, 30%); 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/GridScroller/GridScroller.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/util'; 2 | 3 | .label { 4 | padding-bottom: 10px; 5 | padding-left: 10px; 6 | overflow: hidden; 7 | text-wrap: nowrap; 8 | text-overflow: ellipsis; 9 | } 10 | 11 | .tile { 12 | height: unset; 13 | width: 100%; 14 | } 15 | 16 | .grid { 17 | scroll-behavior: smooth; 18 | } 19 | 20 | .hideScrollbar { 21 | @include hide-scrollbar; 22 | overflow-y: hidden; 23 | } 24 | 25 | .container { 26 | height: 100%; 27 | width: 100%; 28 | 29 | display: flex; 30 | flex-direction: column; 31 | } 32 | 33 | .gridWrapper { 34 | flex: 1; 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/src/components/Scroller/Scroller.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors.scss'; 2 | 3 | .container { 4 | width: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | padding: 0 10px; 8 | 9 | transition: filter 0.3s ease; 10 | 11 | &.inActive { 12 | filter: brightness(70%) opacity(50%); 13 | /* opacity: .7; */ 14 | } 15 | } 16 | 17 | .main { 18 | width: 100%; 19 | display: flex; 20 | gap: 10px; 21 | padding: 10px 5px 10px 15px; 22 | justify-content: flex-start; 23 | align-items: center; 24 | border-radius: 10px; 25 | overflow: hidden; 26 | } 27 | 28 | .label { 29 | padding: 15px 0 0 18px; 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/src/components/MediaTile/Presets/SystemTile.tsx: -------------------------------------------------------------------------------- 1 | import { System } from '@common/types' 2 | import MediaTile, { MediaTileProps } from '../MediaTile' 3 | 4 | type Props = Omit & { system: System } 5 | 6 | const SystemTile = ({ system, aspectRatio = 'landscape', ...props }: Props) => { 7 | const bundledLogo = window.loadMedia({ 8 | resourceType: 'logo', 9 | resourceCollection: 'systems', 10 | resourceId: system.id 11 | }) 12 | 13 | const tileMedia = { 14 | foreground: bundledLogo || system.logo 15 | } as const 16 | 17 | return 18 | } 19 | 20 | export default SystemTile 21 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useWindowFocus.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from 'jotai' 2 | import { useEffect } from 'react' 3 | 4 | export const focusAtom = atom(true) 5 | 6 | export const useWindowFocus = () => { 7 | const [focus, setFocus] = useAtom(focusAtom) 8 | 9 | useEffect(() => { 10 | const onFocus = () => { 11 | setFocus(true) 12 | } 13 | const onBlur = () => { 14 | setFocus(false) 15 | } 16 | 17 | window.addEventListener('focus', onFocus) 18 | window.addEventListener('blur', onBlur) 19 | 20 | return () => { 21 | window.removeEventListener('focus', onFocus) 22 | window.removeEventListener('blur', onBlur) 23 | } 24 | }, []) 25 | 26 | return focus 27 | } 28 | -------------------------------------------------------------------------------- /src/preload/util/initRomDir.ts: -------------------------------------------------------------------------------- 1 | import { System } from '@common/types' 2 | import { AppConfig } from '@common/types' 3 | import { mkdir } from 'fs/promises' 4 | import path from 'path' 5 | import { ROMS_PATH } from './const' 6 | 7 | const initRomDir = async (paths: AppConfig['paths'], systems: System[]) => { 8 | const { roms: configRomPath } = paths 9 | const romPath = configRomPath || ROMS_PATH 10 | 11 | try { 12 | await mkdir(romPath) 13 | } catch (e) {} 14 | 15 | for (const system of systems) { 16 | if (system.romdir) continue 17 | 18 | const systemPath = path.join(romPath, system.id) 19 | await mkdir(systemPath, { recursive: true }) 20 | } 21 | 22 | return romPath 23 | } 24 | 25 | export default initRomDir 26 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | main: { 7 | plugins: [externalizeDepsPlugin()] 8 | }, 9 | preload: { 10 | plugins: [ 11 | externalizeDepsPlugin({ exclude: ['mime', 'buffer-to-data-url', 'unzipper', 'change-case'] }) 12 | ], 13 | resolve: { 14 | alias: { 15 | '@common': resolve('src/common') 16 | } 17 | } 18 | }, 19 | renderer: { 20 | resolve: { 21 | alias: { 22 | '@renderer': resolve('src/renderer/src'), 23 | '@common': resolve('src/common') 24 | } 25 | }, 26 | plugins: [react()] 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /src/renderer/src/components/Showcase/presets/game/index.ts: -------------------------------------------------------------------------------- 1 | import css from './gamePreset.module.scss' 2 | import { Game } from '@common/types' 3 | import { Pill, ShowcaseContent } from '../..' 4 | 5 | export const getGameShowcaseConfig = (game: Game, pills: Pill[]): ShowcaseContent => { 6 | const gameLogo = window.loadMedia(game.logo || '') 7 | 8 | return { 9 | left: [ 10 | { 11 | type: 'media', 12 | media: (game.showcaseDisplayType === 'fanart' ? game.hero : game.screenshot) ?? '' 13 | } 14 | ], 15 | right: [ 16 | gameLogo 17 | ? { type: 'media', media: gameLogo } 18 | : { type: 'text', text: game.name ?? game.romname, className: css.text }, 19 | { type: 'pills', pills } 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/preload/util/installEmulator.ts: -------------------------------------------------------------------------------- 1 | import { Emulator } from "@common/types"; 2 | import { exec as execCb } from "child_process"; 3 | import { promisify } from "util"; 4 | 5 | const exec = promisify(execCb); 6 | 7 | const installEmulator = (emulator: Emulator, type: "flatpak") => { 8 | switch(type) { 9 | case "flatpak": { 10 | if(!("linux" in emulator.location) 11 | || !emulator.location.linux 12 | || !("flatpak" in emulator.location.linux) 13 | || !emulator.location.linux.flatpak 14 | ) { 15 | throw "Can't install flatpak for an emulator without a flatpak ID!" 16 | } 17 | 18 | return exec(`flatpak install ${emulator.location.linux.flatpak} -y --noninteractive --system`); 19 | } 20 | } 21 | } 22 | 23 | export { installEmulator } 24 | -------------------------------------------------------------------------------- /src/renderer/src/pages/AllGames/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai' 2 | import { useOnInput } from '@renderer/hooks' 3 | import { Input } from '@renderer/enums' 4 | import { useNavigate } from 'react-router-dom' 5 | import { GameListPage } from '@renderer/components/GameListPage/GameListPage' 6 | import games from '@renderer/atoms/games' 7 | 8 | export const AllGames = () => { 9 | const [gamesList] = useAtom(games.lists.all) 10 | 11 | const navigate = useNavigate() 12 | 13 | useOnInput((input) => { 14 | switch (input) { 15 | case Input.B: 16 | return history.back() 17 | } 18 | }) 19 | 20 | if (!gamesList.length) { 21 | navigate(-1) 22 | return null 23 | } 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/src/util/queryParams/IndexParam.ts: -------------------------------------------------------------------------------- 1 | import { withDefault, NumberParam, useQueryParam } from "use-query-params" 2 | import { wrappedIndex } from "../wrappedIndex" 3 | export const IndexParam = withDefault(NumberParam, 0) 4 | export const useIndexParam = (name: string, listLength: number) => { 5 | const [index, setIndex] = useQueryParam(name, IndexParam, { updateType: 'replaceIn' }) 6 | 7 | const set = (setter: number | ((old: number) => number)) => { 8 | setIndex(old => { 9 | const newIndex = typeof setter === 'number' 10 | ? wrappedIndex(setter, listLength) 11 | : wrappedIndex(setter(wrappedIndex(old, listLength)), listLength) 12 | 13 | return newIndex 14 | }) 15 | } 16 | 17 | return [wrappedIndex(index, listLength), set] as const 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/pages/Init/Init.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .welcome { 10 | display: flex; 11 | flex-direction: column; 12 | gap: 0.5rem; 13 | 14 | div { 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | h1 { 20 | padding: 0; 21 | margin: 0; 22 | margin-bottom: 2.5rem; 23 | text-align: center; 24 | } 25 | 26 | .dir { 27 | text-align: center; 28 | font-style: italic; 29 | margin: 1.2rem 0; 30 | } 31 | 32 | ul { 33 | display: flex; 34 | flex-direction: column; 35 | gap: 1rem; 36 | margin-top: 0.3rem; 37 | margin-bottom: 0.3rem; 38 | } 39 | } 40 | 41 | .welcomeContainer { 42 | width: max(60vw, 1000px); 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/appConfig.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from "@common/types/AppConfig"; 2 | import { objConfigAtom } from "./util/objConfigAtom"; 3 | import deepmerge from "deepmerge"; 4 | 5 | const defaults: AppConfig = { 6 | ui: { 7 | grid: { 8 | columnCount: 3 9 | }, 10 | colorScheme: "default", 11 | controllerHints: true 12 | }, 13 | paths: { 14 | roms: '' 15 | }, 16 | credentials: { 17 | screenscraper: { 18 | username: '', 19 | password: '' 20 | } 21 | }, 22 | game: { 23 | enableQuitShortcut: true 24 | } 25 | } 26 | 27 | const merged = deepmerge(defaults, window.configStorage.getItem('config', {})) 28 | window.configStorage.setItem('config', merged) 29 | 30 | export const appConfigAtom = objConfigAtom({ 31 | defaults, 32 | storageKey: 'config' 33 | }) 34 | -------------------------------------------------------------------------------- /src/preload/util/removeGameFiles.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Game } from '@common/types' 3 | import { rmSync } from 'fs' 4 | import { loadConfig } from './configStorage' 5 | import { AppConfig } from '@common/types' 6 | 7 | const removeGameFiles = (game: Game) => { 8 | const { paths: { roms: configRomPath }} = loadConfig( 9 | 'config', 10 | {} /* we don't need to supply a default; jotai initializes this config on boot */ 11 | ) as AppConfig 12 | 13 | const romPath = path.join(configRomPath, game.system, ...(game.rompath ?? []), game.romname) 14 | const mediaPaths = [game.hero, game.screenshot, game.poster].filter(Boolean) as string[] 15 | 16 | rmSync(romPath, { recursive: true }) 17 | for (const mediaPath of mediaPaths) { 18 | rmSync(mediaPath) 19 | } 20 | } 21 | 22 | export default removeGameFiles 23 | -------------------------------------------------------------------------------- /src/renderer/src/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion' 2 | import { PropsWithChildren } from 'react' 3 | import css from './Modal.module.scss' 4 | import { createPortal } from 'react-dom' 5 | 6 | interface Props { 7 | open: boolean 8 | id: string 9 | } 10 | 11 | const Modal = ({ open, children }: PropsWithChildren) => { 12 | return createPortal( 13 | 14 | {open && ( 15 | 22 | {children} 23 | 24 | )} 25 | , 26 | document.body 27 | ) 28 | } 29 | 30 | export default Modal 31 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useWaveHeight.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | 3 | // allows setting Wavify height as a ratio of the parent element 4 | export const useWaveHeight = (ratio: number) => { 5 | const [waveHeight, setWaveHeight] = useState(0) 6 | const parentRef = useRef(null) 7 | 8 | useEffect(() => { 9 | const applyHeight = (entries: ResizeObserverEntry[]) => { 10 | const entry = entries[0] 11 | if (!entry) return 12 | 13 | setWaveHeight(Math.round(entry.target.clientHeight * (1 - ratio))) 14 | } 15 | 16 | if (!parentRef.current) return 17 | const observer = new ResizeObserver(applyHeight) 18 | observer.observe(parentRef.current) 19 | 20 | return () => { 21 | observer.disconnect() 22 | } 23 | }, [ratio]) 24 | 25 | return { parentRef, waveHeight } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/types/Game.ts: -------------------------------------------------------------------------------- 1 | export interface GameMedia { 2 | mediaType: string 3 | url: string 4 | format: string 5 | } 6 | 7 | export interface MediaTypes { 8 | poster?: string 9 | hero?: string 10 | logo?: string 11 | screenshot?: string 12 | } 13 | 14 | export type Game = { 15 | id: string 16 | romname: string 17 | system: string 18 | rompath?: string[] 19 | players?: string 20 | description?: string 21 | lastPlayed?: string 22 | lastViewed?: string 23 | timesPlayed?: number 24 | developer?: string 25 | publisher?: string 26 | genre?: string 27 | name?: string 28 | added?: string 29 | crc?: string 30 | romsize?: string 31 | showcaseDisplayType?: 'screenshot' | 'fanart' 32 | gamePageDisplayType?: 'screenshot' | 'fanart' 33 | gameTileDisplayType?: 'screenshot' | 'fanart' | 'poster' 34 | emulator?: string 35 | } & MediaTypes 36 | -------------------------------------------------------------------------------- /src/renderer/src/pages/SystemView/index.tsx: -------------------------------------------------------------------------------- 1 | import systems from '@renderer/atoms/systems' 2 | import { useAtom } from 'jotai' 3 | import { useOnInput } from '@renderer/hooks' 4 | import { Input } from '@renderer/enums' 5 | import { useNavigate, useParams } from 'react-router-dom' 6 | import { GameListPage } from '@renderer/components/GameListPage/GameListPage' 7 | 8 | export const SystemView = () => { 9 | const { systemId } = useParams() 10 | const [system] = useAtom(systems.withGames(systemId || '')) 11 | 12 | const navigate = useNavigate() 13 | 14 | useOnInput((input) => { 15 | switch (input) { 16 | case Input.B: 17 | return history.back() 18 | } 19 | }) 20 | 21 | if (!system) { 22 | navigate(-1) 23 | return null 24 | } 25 | 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Power/Power.tsx: -------------------------------------------------------------------------------- 1 | import { SectionProps } from '..' 2 | import MultiPageControllerForm, { 3 | MultiFormPage 4 | } from '@renderer/components/ControllerForm/MultiPage' 5 | import { IoExitOutline } from 'react-icons/io5' 6 | 7 | const Power = ({ onExit, inputPriority, isActive }: SectionProps) => { 8 | const pages: MultiFormPage[] = [ 9 | { 10 | id: 'main', 11 | entries: [ 12 | { 13 | id: 'exit', 14 | label: 'Exit EmuHub', 15 | type: 'action', 16 | Icon: IoExitOutline, 17 | onSelect: () => { 18 | window.close() 19 | } 20 | } 21 | ] 22 | } 23 | ] 24 | 25 | return ( 26 | 33 | ) 34 | } 35 | 36 | export default Power 37 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/runningGame.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "@common/types"; 2 | import { atom } from "jotai"; 3 | 4 | const defaults = { 5 | execInstance: null, 6 | abort: null, 7 | game: null 8 | } 9 | 10 | interface RunningGame { 11 | execInstance: Promise | null, 12 | abort: (() => void) | null, 13 | game: Game | null 14 | } 15 | 16 | const baseRunningGameAtom = atom(defaults); 17 | const runningGameAtom = atom( 18 | (get) => get(baseRunningGameAtom), 19 | (_, set, newGame: RunningGame) => { 20 | newGame.execInstance?.catch().finally(() => { 21 | set(baseRunningGameAtom, defaults); 22 | window.focusApp(); 23 | }) 24 | 25 | set(baseRunningGameAtom, newGame); 26 | } 27 | ) 28 | 29 | const exitGameAtom = atom(null, (get, set) => { 30 | const runningGame = get(runningGameAtom); 31 | runningGame.abort?.() 32 | set(runningGameAtom, defaults); 33 | }) 34 | 35 | export { 36 | runningGameAtom, 37 | exitGameAtom 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/SectionSelector/SectionSelector.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors.scss'; 2 | 3 | .sectionButton { 4 | font-family: 5 | Figtree Variable, 6 | sans-serif; 7 | 8 | width: 100%; 9 | border-radius: 20px; 10 | color: white; 11 | 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | padding: 15px 30px; 16 | font-size: 1.2rem; 17 | 18 | background-color: hsla(0, 0%, 0%, 0%); 19 | color: var(--primary); 20 | 21 | transition: 22 | background-color 0.1s ease, 23 | color 0.1s ease; 24 | 25 | &.active { 26 | background-color: var(--primary); 27 | color: var(--background); 28 | } 29 | 30 | @media screen and (max-width: 1050px) { 31 | padding: 15px; 32 | } 33 | } 34 | 35 | .sectionSelector { 36 | display: flex; 37 | flex-direction: column; 38 | gap: 15px; 39 | transition: filter 0.1s ease; 40 | 41 | &.inactive { 42 | filter: opacity(50%); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Settings.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .modal { 4 | position: fixed; 5 | width: 100%; 6 | height: 100%; 7 | background-color: rgba(0, 0, 0, 0.6); 8 | top: 0; 9 | 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | backdrop-filter: blur(2px); 14 | } 15 | 16 | .body { 17 | width: min(80vw, 1400px); 18 | height: min(80vh, 1000px); 19 | background-color: var(--background); 20 | border-radius: 20px; 21 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 22 | 23 | display: flex; 24 | overflow: hidden; 25 | 26 | @media screen and (max-width: 1050px) { 27 | width: 95%; 28 | height: 95%; 29 | } 30 | } 31 | 32 | .left { 33 | flex: 1; 34 | background-color: var(--background-medium); 35 | padding: 20px; 36 | } 37 | 38 | .right { 39 | flex: 3; 40 | height: 100%; 41 | transition: filter 0.1s ease; 42 | 43 | &.inactive { 44 | filter: opacity(50%); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/src/pages/SearchView/SearchView.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai' 2 | import { useOnInput } from '@renderer/hooks' 3 | import { Input } from '@renderer/enums' 4 | import { useNavigate, useParams } from 'react-router-dom' 5 | import { GameListPage } from '@renderer/components/GameListPage/GameListPage' 6 | import games from '@renderer/atoms/games' 7 | 8 | export const SearchView = () => { 9 | const { searchQuery } = useParams() 10 | const search = decodeURIComponent(searchQuery ?? '') 11 | 12 | const [searchGames] = useAtom(games.lists.search) 13 | const gamesList = searchGames(search) 14 | 15 | const navigate = useNavigate() 16 | 17 | useOnInput((input) => { 18 | switch (input) { 19 | case Input.B: 20 | return history.back() 21 | } 22 | }) 23 | 24 | if (!search) { 25 | navigate(-1) 26 | return null 27 | } 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/src/components/NavHeader/NavHeader.module.scss: -------------------------------------------------------------------------------- 1 | .bgOverlay { 2 | position: absolute; 3 | width: 100%; 4 | height: 100%; 5 | content: ''; 6 | z-index: 3; 7 | background-color: black; 8 | } 9 | 10 | .navContainer { 11 | position: absolute; 12 | width: 100%; 13 | z-index: 3; 14 | display: flex; 15 | justify-content: center; 16 | align-items: start; 17 | gap: 1rem; 18 | 19 | .navEntry { 20 | background-color: var(--background-light); 21 | transition: all .1s ease; 22 | color: var(--primary); 23 | border-radius: 20px; 24 | border-top-left-radius: 0; 25 | border-top-right-radius: 0; 26 | font-family: Figtree Variable; 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | gap: 1.5rem; 31 | font-size: 1.4rem; 32 | box-shadow: 0px 3px 5px rgba(0, 0, 0, .7); 33 | position: relative; 34 | 35 | &.active { 36 | background-color: var(--primary); 37 | color: var(--background-dark); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/src/pages/GameView/GameInfo/GameInfo.tsx: -------------------------------------------------------------------------------- 1 | import useGamePills from '@renderer/components/Pill/hooks/useGamePills' 2 | import classNames from 'classnames' 3 | import css from './GameInfo.module.scss' 4 | import Pill from '@renderer/components/Pill' 5 | import { TabContentProps } from '@renderer/components/TabSelector/TabSelector' 6 | import Marquee from '@renderer/components/Marquee' 7 | 8 | const GameInfo = ({ className, game }: TabContentProps) => { 9 | const pills = useGamePills(game ?? null) 10 | 11 | if (!game) return null 12 | return ( 13 |
14 |
15 | {game.description} 16 |
17 |
18 | {pills && 19 | pills.length && 20 | pills.map((pill) => )} 21 |
22 |
23 | ) 24 | } 25 | 26 | export default GameInfo 27 | -------------------------------------------------------------------------------- /src/renderer/src/components/InputModal/InputModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | @keyframes blink { 4 | from { 5 | opacity: 1; 6 | } 7 | to { 8 | opacity: 0; 9 | } 10 | } 11 | 12 | .inputModal { 13 | width: min(1088px, 85vw); 14 | 15 | .input { 16 | width: 100%; 17 | height: 100px; 18 | padding: 0 50px; 19 | background-color: var(--background); 20 | border-top-left-radius: 20px; 21 | border-top-right-radius: 20px; 22 | border: none; 23 | display: flex; 24 | font-size: 2rem; 25 | font-family: Figtree Variable; 26 | color: white; 27 | align-items: center; 28 | 29 | &:focus { 30 | outline: none; 31 | } 32 | } 33 | 34 | .inputCaret { 35 | animation-name: blink; 36 | animation-duration: 0.8s; 37 | animation-timing-function: ease-in-out; 38 | animation-direction: alternate; 39 | animation-iteration-count: infinite; 40 | } 41 | } 42 | 43 | .activeBtn { 44 | background-color: var(--background-dark) !important; 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/src/pages/GameView/Recommendations/Recommendations.tsx: -------------------------------------------------------------------------------- 1 | import Scrollers from '@renderer/components/Scrollers/Scrollers' 2 | import { TabContentProps } from '@renderer/components/TabSelector/TabSelector' 3 | import { useRecommendationScrollers } from '@renderer/hooks' 4 | import { useNavigate } from 'react-router-dom' 5 | import { Game } from '@common/types' 6 | 7 | const Recommendations = ({ game, className, onExitUp, isDisabled }: TabContentProps) => { 8 | const navigate = useNavigate() 9 | const onSelectGame = (game: Game) => { 10 | navigate(`/game/${game.id}`, { replace: true }) 11 | } 12 | const scrollers = useRecommendationScrollers(game, onSelectGame) 13 | 14 | if (!game) return null 15 | return ( 16 |
17 | 24 |
25 | ) 26 | } 27 | 28 | export default Recommendations 29 | -------------------------------------------------------------------------------- /src/renderer/src/components/TabSelector/TabSelector.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .tabs { 4 | display: flex; 5 | gap: 2rem; 6 | transition: opacity 0.1s ease; 7 | } 8 | 9 | .opacityTransition { 10 | transition: opacity 0.1s ease; 11 | } 12 | 13 | .tab { 14 | padding: 1rem 1.5rem; 15 | border-radius: 2rem; 16 | 17 | color: white; 18 | /* border: 2px solid var(--primary); */ 19 | 20 | /* background-color: var(--primary); */ 21 | /* background-clip: padding-box; */ 22 | 23 | /* border: 2px solid transparent; */ 24 | 25 | font-family: Figtree Variable; 26 | font-size: 1.2rem; 27 | 28 | transition: all 0.1s ease; 29 | 30 | &.active { 31 | background-color: var(--primary); 32 | color: var(--background-dark); 33 | transform: scale(1.1); 34 | } 35 | } 36 | 37 | .container { 38 | font-family: Figtree Variable; 39 | color: white; 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | gap: 2rem; 44 | } 45 | 46 | .inactive { 47 | opacity: 0.5; 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useShrinkToFit.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react' 2 | 3 | export const useShrinkToFit = (ref: RefObject, scale = 1) => { 4 | useEffect(() => { 5 | const transformHandler = () => { 6 | const elem = ref.current 7 | if (!elem) return 8 | 9 | const parent = elem.parentElement 10 | if (!parent) return 11 | 12 | const elemHeight = elem.scrollHeight 13 | const parentHeight = parent.clientHeight * scale 14 | 15 | const elemWidth = elem.scrollWidth 16 | const parentWidth = parent.clientWidth * scale 17 | 18 | if (elemHeight <= parentHeight && elemWidth <= parentWidth) return 19 | 20 | const ratio = Math.min(parentHeight / elemHeight, parentWidth / elemWidth) 21 | elem.style.scale = String(ratio) 22 | } 23 | 24 | transformHandler() 25 | window.addEventListener('resize', transformHandler) 26 | 27 | return () => { 28 | window.removeEventListener('resize', transformHandler) 29 | } 30 | }, [ref.current]) 31 | } 32 | -------------------------------------------------------------------------------- /src/preload/util/const.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync } from 'fs' 2 | import os from 'os' 3 | import path from 'path' 4 | import { getSystemPaths } from './getSystemPaths' 5 | 6 | const systemPaths = getSystemPaths() 7 | 8 | const CONFIG_PATH = systemPaths.config 9 | const ROMS_PATH = systemPaths.romsDefault 10 | const ASSETS_PATH = path.join(systemPaths.data, 'assets') 11 | const GAME_ASSETS_PATH = path.join(ASSETS_PATH, 'games') 12 | 13 | const SNAP_PATHS = [path.join('/', 'snap')] 14 | const LINUX_APPLICATION_PATHS = [ 15 | path.join(os.homedir(), 'Applications'), 16 | path.join(os.homedir(), '.local', 'share', 'applications'), 17 | path.join(os.homedir(), '.local', 'bin'), 18 | path.join(os.homedir(), '.bin') 19 | ] 20 | 21 | 22 | for (const path of [CONFIG_PATH, ASSETS_PATH, GAME_ASSETS_PATH, ROMS_PATH]) { 23 | if (!existsSync(path)) { 24 | mkdirSync(path, { recursive: true }) 25 | } 26 | } 27 | 28 | export { 29 | CONFIG_PATH, 30 | ASSETS_PATH, 31 | GAME_ASSETS_PATH, 32 | LINUX_APPLICATION_PATHS, 33 | SNAP_PATHS, 34 | ROMS_PATH 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/src/components/Notifications/Notifications.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .notificationsOverlay { 4 | position: fixed; 5 | 6 | height: 100%; 7 | width: 100%; 8 | top: 0; 9 | left: 0; 10 | 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: flex-end; 14 | align-items: flex-end; 15 | gap: 1.5rem; 16 | 17 | z-index: 99; 18 | 19 | pointer-events: none; 20 | } 21 | 22 | .notificationDisplay { 23 | background-color: var(--primary); 24 | padding: 1.5rem 2rem; 25 | font-family: Figtree Variable; 26 | font-size: 1.2rem; 27 | 28 | color: var(--background-dark); 29 | border-radius: 1rem; 30 | max-width: 450px; 31 | 32 | display: flex; 33 | justify-content: space-between; 34 | align-items: center; 35 | gap: 2rem; 36 | box-shadow: 0px 3px 3px rgba(0, 0, 0, .5); 37 | 38 | .icon { 39 | font-size: 1.6rem; 40 | display: block; 41 | flex-shrink: 0; 42 | } 43 | 44 | &.error { 45 | background-color: $caution; 46 | } 47 | 48 | &.success { 49 | background-color: $confirm; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Stores/AlphabetSelector/AlphabetSelector.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | 8 | width: 100%; 9 | height: 100%; 10 | 11 | display: flex; 12 | justify-content: flex-end; 13 | } 14 | 15 | .selectorWrapper { 16 | height: 100%; 17 | width: 100px; 18 | background: rgba(0, 0, 0, 0.6); 19 | color: hsl(0, 0%, 70%); 20 | transition: translate 0.2s ease; 21 | 22 | font-family: Figtree Variable; 23 | font-size: 1.3rem; 24 | 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | justify-content: center; 29 | gap: 3px; 30 | 31 | &.hidden { 32 | translate: 100px; 33 | } 34 | } 35 | 36 | .selector { 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | gap: 3px; 42 | } 43 | 44 | .alphabetEntry { 45 | transition: 46 | color 0.1s ease, 47 | transform 0.1s ease; 48 | 49 | &.active { 50 | color: var(--primary); 51 | transform: scale(1.5); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/src/components/SelectModal/SelectModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | width: 650px; 5 | background-color: var(--background); 6 | border-radius: 20px; 7 | font-family: Figtree Variable; 8 | font-size: 1.5rem; 9 | color: white; 10 | overflow: hidden; 11 | padding: 3rem; 12 | display: flex; 13 | flex-direction: column; 14 | gap: 2rem; 15 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 16 | 17 | .options { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | gap: 2rem; 22 | 23 | > div { 24 | padding: 1rem 1.5rem; 25 | border-radius: 20px; 26 | border: 1px solid white; 27 | 28 | transition: 29 | color 0.1s ease, 30 | background-color 0.1s ease; 31 | 32 | &.active { 33 | background-color: $confirm; 34 | color: var(--background-dark); 35 | border: 1px solid transparent; 36 | 37 | &.default { 38 | background-color: var(--primary); 39 | } 40 | &.confirm { 41 | background-color: $confirm; 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 Beta 2 2 | * Feature: Select default emulators per-system (Settings -> Games -> Default Emulators) 3 | * Fix: Reset Home scrollers after launching a game 4 | * This brings us in line with expected behavior of Home highlighting our recently-launched game 5 | * This fixes the most obvious crash case for navigating back to an invalid scroller index 6 | * Fix: Wrap URL-based scroller params for access safety 7 | * This fixes less obvious crash cases for navigating back to an invalid scroller index (ex: deleting the last game in a list) 8 | * Feature: Add Jump to Collections button on Home (Y) 9 | * Feature: Add native keyboard support for input modals (setting ROMs directory, rename game, search, etc) 10 | * Feature: Add support for moving input caret in keyboard 11 | * Feature: Support renaming collections (Settings -> Collections -> [Some Collection] -> Rename "Some Collection") 12 | * Fix: Don't overwrite user's game tile art selection when re-scraping a game 13 | * Add emulator: Ryujinx (Switch) 14 | * Fix: Remove Yuzu from default emulators for MacOS 15 | * Feature: Add app icon 16 | * Fix: Add .zip to supported extensions for NDS 17 | * EXPERIMENTAL: Build for Linux ARM 18 | -------------------------------------------------------------------------------- /src/common/types/System.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypes, Game } from '@common/types/Game' 2 | import { NameMapper } from './NameMapper' 3 | 4 | export type SystemStore = { 5 | id: string 6 | name: string 7 | } & ( 8 | | { 9 | type: 'html' 10 | url: string 11 | selector: string 12 | } 13 | | { 14 | type: 'emudeck' 15 | } 16 | ) 17 | 18 | export interface StoreEntry { 19 | href: string 20 | name: string 21 | description?: string 22 | genre?: string 23 | media?: Record< 24 | keyof MediaTypes, 25 | { 26 | url: string 27 | format: string 28 | } 29 | > 30 | } 31 | 32 | export interface System { 33 | id: string 34 | name: string 35 | fileExtensions: string[] 36 | releaseYear: string 37 | company: string 38 | handheld?: boolean 39 | ssId?: number 40 | igdbId?: number 41 | emulators?: string[] 42 | stores?: SystemStore[] 43 | logo?: string 44 | romdir?: string 45 | defaultNames?: { 46 | [ext: string]: { 47 | type: 'pathToken' 48 | token: number 49 | map?: NameMapper 50 | } 51 | } 52 | defaultEmulator?: string 53 | } 54 | 55 | export type SystemWithGames = System & { games: Game[]; screenshot?: string } 56 | -------------------------------------------------------------------------------- /src/renderer/src/components/InputModal/keyboard.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .hg-theme-default { 4 | background-color: var(--background-dark); 5 | color: var(--primary); 6 | height: min(50vh, 400px); 7 | 8 | font-family: Figtree Variable; 9 | padding: 1rem; 10 | border-radius: 0; 11 | border-bottom-left-radius: 20px; 12 | border-bottom-right-radius: 20px; 13 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 14 | 15 | .hg-rows { 16 | display: flex; 17 | flex-direction: column; 18 | gap: 0.5rem; 19 | height: 100%; 20 | 21 | .hg-row { 22 | margin-bottom: 0; 23 | flex-basis: 100%; 24 | gap: 0.5rem; 25 | 26 | .hg-button { 27 | background-color: var(--background-light); 28 | border-bottom: unset; 29 | height: 100%; 30 | margin: 0; 31 | font-size: 1.4rem; 32 | 33 | &.hg-keyMarker { 34 | box-shadow: 0 0 0 2px var(--primary) !important; 35 | } 36 | 37 | &.hg-button-space { 38 | flex: 6 39 | } 40 | 41 | &.hg-button-shift-active:nth-child(1) { 42 | background-color: var(--background) !important; 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/src/components/ConfirmationModal/ConfirmationModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | width: 650px; 5 | background-color: var(--background); 6 | border-radius: 20px; 7 | font-family: Figtree Variable; 8 | font-size: 1.5rem; 9 | color: white; 10 | overflow: hidden; 11 | padding: 3rem; 12 | display: flex; 13 | flex-direction: column; 14 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 15 | gap: 2rem; 16 | 17 | .options { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | gap: 2rem; 22 | 23 | > div { 24 | padding: 1rem 1.5rem; 25 | border-radius: 20px; 26 | border: 1px solid white; 27 | 28 | transition: 29 | color 0.1s ease, 30 | background-color 0.1s ease; 31 | } 32 | 33 | .confirm { 34 | &.active { 35 | background-color: $confirm; 36 | color: var(--background-dark); 37 | border: 1px solid transparent; 38 | } 39 | } 40 | 41 | .cancel { 42 | &.active { 43 | background-color: var(--primary); 44 | color: var(--background-dark); 45 | border: 1px solid transparent; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/src/components/VerticalGameInfo/VerticalGameInfo.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | overflow: hidden; 7 | 8 | .bgAndLogo { 9 | width: 100%; 10 | aspect-ratio: 4 / 3; 11 | object-fit: cover; 12 | border-radius: 8px; 13 | position: relative; 14 | flex-shrink: 0; 15 | overflow: hidden; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | 20 | .hero { 21 | position: absolute; 22 | height: 100%; 23 | width: 100%; 24 | object-fit: cover; 25 | filter: brightness(60%) blur(1px); 26 | } 27 | 28 | .logo { 29 | max-width: 60%; 30 | max-height: 60%; 31 | z-index: 1; 32 | } 33 | } 34 | 35 | .description { 36 | font-family: 'Figtree Variable', sans-serif; 37 | font-size: 1rem; 38 | font-weight: 400; 39 | color: $text-secondary; 40 | margin-top: 1rem; 41 | max-height: 100%; 42 | overflow: hidden; 43 | margin-bottom: 1.3rem; 44 | text-overflow: ellipsis; 45 | white-space: pre-wrap; 46 | } 47 | } 48 | 49 | .pills { 50 | display: flex; 51 | flex-wrap: wrap; 52 | gap: 1rem; 53 | row-gap: .5rem; 54 | } 55 | -------------------------------------------------------------------------------- /src/renderer/src/components/VerticalGameInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import { Game } from '@common/types' 2 | import css from './VerticalGameInfo.module.scss' 3 | import classNames from 'classnames' 4 | import Marquee from '../Marquee' 5 | import useGamePills from '../Pill/hooks/useGamePills' 6 | import { useMemo } from 'react' 7 | import Pill from '../Pill' 8 | import MediaImage from '../MediaImage/MediaImage' 9 | 10 | interface Props { 11 | className?: string 12 | game: Game 13 | } 14 | 15 | const VerticalGameInfo = ({ className, game }: Props) => { 16 | const pills = useGamePills(game) 17 | const pillElements = useMemo( 18 | () => 19 | pills.map((pill) => ( 20 | 21 | )), 22 | [pills] 23 | ) 24 | 25 | return ( 26 |
27 |
28 | 29 | 30 |
31 | {game.description} 32 | {Boolean(pillElements.length) &&
{pillElements}
} 33 |
34 | ) 35 | } 36 | 37 | export default VerticalGameInfo 38 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/notifications.ts: -------------------------------------------------------------------------------- 1 | import { PartialBy } from '@renderer/types/util' 2 | import { atom } from 'jotai' 3 | import ShortUniqueId from 'short-unique-id' 4 | const uid = new ShortUniqueId() 5 | 6 | export interface Notification { 7 | id: string 8 | text: string 9 | type: 'download' | 'error' | 'info' | 'success' 10 | timeout?: number 11 | } 12 | 13 | const notifications = atom([]) 14 | 15 | const remove = atom(null, (_, set, id: Notification['id']) => { 16 | set(notifications, (notifs) => notifs.filter((notif) => notif.id !== id)) 17 | }) 18 | 19 | const add = atom(null, (get, set, notification: PartialBy) => { 20 | const currentNotifications = get(notifications) 21 | if(currentNotifications.some(notif => notif.id === notification.id)) return 22 | 23 | const newNotification = { 24 | ...notification, 25 | id: notification.id ?? uid.rnd() 26 | } 27 | 28 | set(notifications, (notifs) => [...notifs, newNotification]) 29 | const notificationTimeout = newNotification.timeout ?? 3 30 | 31 | if(notificationTimeout) setTimeout(() => { set(remove, newNotification.id) }, notificationTimeout * 1000) 32 | }) 33 | 34 | 35 | const clear = atom(null, (_, set) => { 36 | set(notifications, []) 37 | }) 38 | 39 | export default { 40 | list: notifications, 41 | add, 42 | remove, 43 | clear, 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Stores/Stores.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .loading { 4 | height: 100%; 5 | width: 100%; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | } 10 | 11 | .storeWrapper { 12 | height: 100%; 13 | width: 100%; 14 | position: relative; 15 | } 16 | 17 | .emudeckWrapper { 18 | display: flex; 19 | flex-direction: column; 20 | height: 100%; 21 | } 22 | 23 | .gamePreviewOuter { 24 | background-color: var(--background-dark); 25 | padding: 1.5rem 2rem; 26 | } 27 | 28 | .gamePreview { 29 | height: 100%; 30 | width: 100%; 31 | display: flex; 32 | justify-content: space-between; 33 | gap: 2rem; 34 | 35 | .img { 36 | height: 250px; 37 | border-radius: 1rem; 38 | aspect-ratio: 4 / 3; 39 | } 40 | 41 | .imgFallback { 42 | height: 250px; 43 | width: 0; 44 | } 45 | 46 | .description { 47 | display: flex; 48 | flex-direction: column; 49 | justify-content: center; 50 | align-items: flex-start; 51 | gap: 1rem; 52 | font-size: 1rem; 53 | text-align: left; 54 | width: 100%; 55 | font-family: Figtree Variable; 56 | color: white; 57 | 58 | &.centered { 59 | align-items: center; 60 | width: 100%; 61 | } 62 | } 63 | } 64 | 65 | .pill { 66 | font-size: 1rem; 67 | padding: none; 68 | gap: 0.5rem; 69 | } 70 | -------------------------------------------------------------------------------- /src/renderer/src/components/Showcase/presets/system/index.ts: -------------------------------------------------------------------------------- 1 | import css from './systemPreset.module.scss' 2 | import { LuCalendar } from 'react-icons/lu' 3 | import { GrOrganization } from 'react-icons/gr' 4 | import { SystemWithGames } from '@common/types' 5 | import { Pill, ShowcaseContent } from '../..' 6 | 7 | export const getSystemShowcaseConfig = (system: SystemWithGames): ShowcaseContent => { 8 | return { 9 | classNames: { right: css.systemShowcaseRight }, 10 | left: [ 11 | { 12 | type: 'media', 13 | media: system.screenshot || '' 14 | } 15 | ], 16 | right: [ 17 | { 18 | type: 'media', 19 | media: 20 | system.logo || 21 | window.loadMedia({ 22 | resourceId: system.id, 23 | resourceType: 'logoAlt', 24 | resourceCollection: 'systems' 25 | }) || 26 | window.loadMedia({ 27 | resourceId: system.id, 28 | resourceType: 'logo', 29 | resourceCollection: 'systems' 30 | })!, 31 | className: css.systemShowcaseLogo 32 | }, 33 | { 34 | type: 'pills', 35 | pills: [ 36 | { id: 'release-year', Icon: LuCalendar, text: system.releaseYear }, 37 | { id: 'company', Icon: GrOrganization, text: system.company } 38 | ].filter((p) => p.text) as Pill[] 39 | } 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/preload/util/getRomFileInfo.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Game } from '@common/types' 3 | import crc32 from 'crc/crc32' 4 | import { stat } from 'fs/promises' 5 | import { createReadStream } from 'fs' 6 | import { loadConfig } from './configStorage' 7 | import { AppConfig } from '@common/types/' 8 | 9 | // we won't calculate crc32 if file size is greater than this; takes too long and slows down UI 10 | const MAX_CRC_SIZE = 25000000 // 25MB 11 | 12 | const getRomFileInfo = async (game: Game) => { 13 | const { paths: { roms: romPath } } = loadConfig( 14 | 'config', 15 | {} /* we don't need to supply a default; jotai initializes this config on boot */ 16 | ) as AppConfig 17 | const romLocation = path.join(romPath, game.system, ...(game.rompath ?? []), game.romname) 18 | 19 | const stats = await stat(romLocation) 20 | 21 | // avoid crash when interpreting a folder as a rom file (as with .ps3 directories) 22 | if (stats.isDirectory()) return { crc: undefined, size: undefined } 23 | 24 | const fileStream = createReadStream(romLocation) 25 | 26 | const size = stats.size 27 | if (size > MAX_CRC_SIZE) return { size: String(size) } 28 | 29 | let crc: number 30 | for await (const data of fileStream) { 31 | crc = crc32(data, crc! ?? undefined) 32 | } 33 | 34 | return { 35 | crc: crc!.toString(16), 36 | size: String(size) 37 | } 38 | } 39 | 40 | export default getRomFileInfo 41 | -------------------------------------------------------------------------------- /src/renderer/src/components/Pill/hooks/useGamePills.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '@common/types' 2 | import systems from '@renderer/atoms/systems' 3 | import { Pill } from '@renderer/components/Showcase' 4 | import { formatDistanceToNowStrict } from 'date-fns' 5 | import { useAtom } from 'jotai' 6 | import { useMemo } from 'react' 7 | 8 | import { LuCalendarClock } from 'react-icons/lu' 9 | import { MdOutlineCategory, MdOutlinePeopleAlt } from 'react-icons/md' 10 | import { GiGameConsole } from 'react-icons/gi' 11 | 12 | const useGamePills = (game: Game | null) => { 13 | const [gameSystem] = useAtom(systems.single(game?.system ?? '')) 14 | 15 | const pillElems = useMemo(() => { 16 | if (!game) return [] 17 | return [ 18 | { 19 | text: game.lastPlayed 20 | ? formatDistanceToNowStrict(new Date(game.lastPlayed), { addSuffix: true }) 21 | : undefined, 22 | Icon: LuCalendarClock, 23 | id: `${game.id}-lastPlayed` 24 | }, 25 | { 26 | text: game.genre, 27 | Icon: MdOutlineCategory, 28 | id: `${game.id}-genre` 29 | }, 30 | { 31 | text: game.players, 32 | Icon: MdOutlinePeopleAlt, 33 | id: `${game.id}-players` 34 | }, 35 | { 36 | text: gameSystem?.name, 37 | Icon: GiGameConsole, 38 | id: `${game.id}-system` 39 | } 40 | ].filter((elem): elem is Pill => Boolean(elem.text)) 41 | }, [game, gameSystem]) 42 | 43 | return pillElems 44 | } 45 | 46 | export default useGamePills 47 | -------------------------------------------------------------------------------- /src/renderer/src/colors/useChangeListener.ts: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai' 2 | import { useEffect } from 'react' 3 | import { colorSchemes, defaultSaturation, modifiableColors } from './colorSchemes' 4 | import { appConfigAtom } from '@renderer/atoms/appConfig' 5 | 6 | export const useColorChangeListener = () => { 7 | const [appConfig] = useAtom(appConfigAtom) 8 | 9 | useEffect(() => { 10 | let colorSchemeId = appConfig.ui.colorScheme 11 | let colorScheme = colorSchemes.find((scheme) => scheme.id === colorSchemeId) 12 | 13 | if (!colorScheme) { 14 | colorSchemeId = 'default' 15 | colorScheme = colorSchemes.find((scheme) => scheme.id === colorSchemeId)! 16 | } 17 | 18 | const { hue, saturation: colorSchemeSaturation } = colorScheme 19 | 20 | for (const modifiableColor of modifiableColors) { 21 | let { id, lightness, saturation: baseSaturation } = modifiableColor 22 | const saturation = 23 | baseSaturation === defaultSaturation 24 | ? colorSchemeSaturation ?? baseSaturation 25 | : baseSaturation 26 | 27 | const values = { hue, saturation, lightness } 28 | if (colorScheme.overrides?.[modifiableColor.id]) { 29 | for (const value of ['hue', 'saturation', 'lightness']) { 30 | const override = colorScheme.overrides[modifiableColor.id]?.[value] 31 | if (override) values[value] = override 32 | } 33 | } 34 | 35 | document.documentElement.style.setProperty( 36 | `--${id}`, 37 | `hsl(${values.hue}, ${values.saturation}%, ${values.lightness}%)` 38 | ) 39 | } 40 | }, [appConfig.ui.colorScheme]) 41 | } 42 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.emuhub.app 2 | productName: EmuHub 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!electron.vite.config.{js,ts,mjs,cjs}' 9 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 10 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 11 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 12 | asarUnpack: 13 | - resources/** 14 | - '**/node_modules/sharp/**/*' 15 | - '**/node_modules/@img/**/*' 16 | win: 17 | executableName: EmuHub 18 | nsis: 19 | artifactName: ${name}-${version}-setup.${ext} 20 | shortcutName: ${productName} 21 | uninstallDisplayName: ${productName} 22 | createDesktopShortcut: always 23 | mac: 24 | entitlementsInherit: build/entitlements.mac.plist 25 | extendInfo: 26 | - NSCameraUsageDescription: Application requests access to the device's camera. 27 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 28 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 29 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 30 | notarize: false 31 | dmg: 32 | artifactName: ${name}-${version}.${ext} 33 | linux: 34 | target: 35 | - target: AppImage 36 | arch: 37 | - x64 38 | - armv7l 39 | - arm64 40 | maintainer: electronjs.org 41 | category: Utility 42 | appImage: 43 | artifactName: ${name}-${version}-${arch}.${ext} 44 | npmRebuild: false 45 | publish: 46 | provider: generic 47 | url: https://example.com/auto-updates 48 | -------------------------------------------------------------------------------- /src/renderer/src/components/IconButtons/IconButtons.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .button { 4 | border: 2px solid; 5 | padding: 12px; 6 | gap: 10px; 7 | 8 | display: flex; 9 | align-items: center; 10 | 11 | font-family: Figtree Variable; 12 | font-size: 1.1em; 13 | color: white; 14 | 15 | transition: all 0.2s ease; 16 | border-radius: 50%; 17 | 18 | filter: drop-shadow(0px 3px 3px rgba(0, 0, 0, 0.4)); 19 | 20 | &.active { 21 | transform: scale(1.1); 22 | 23 | background-color: var(--primary); 24 | color: hsl(0, 0%, 20%); 25 | 26 | border-color: transparent; 27 | 28 | .label { 29 | max-width: 150px; 30 | } 31 | 32 | &.default { 33 | background-color: var(--primary); 34 | } 35 | &.caution { 36 | background-color: $caution; 37 | } 38 | &.warning { 39 | background-color: $warning; 40 | color: var(--background-dark); 41 | } 42 | &.confirm { 43 | background-color: $confirm; 44 | } 45 | } 46 | } 47 | 48 | .buttons { 49 | display: flex; 50 | gap: 15px; 51 | justify-content: center; 52 | transition: filter ease 0.1s; 53 | 54 | &.inactive { 55 | filter: saturate(0%) opacity(50%); 56 | } 57 | } 58 | 59 | .label { 60 | padding: unset; 61 | position: absolute; 62 | left: 50%; 63 | transform: translate(-50%, 250%); 64 | color: white; 65 | overflow: visible; 66 | text-wrap: nowrap; 67 | filter: drop-shadow(0px 3px 3px rgba(0, 0, 0, 0.3)); 68 | } 69 | 70 | .labelWrapper { 71 | transform-origin: center; 72 | } 73 | 74 | .buttonContainer { 75 | position: relative; 76 | } 77 | 78 | .disabled { 79 | filter: brightness(50%) opacity(50%); 80 | } 81 | -------------------------------------------------------------------------------- /src/renderer/src/components/MediaTile/Presets/GameTile.tsx: -------------------------------------------------------------------------------- 1 | import { Game } from '@common/types' 2 | import MediaTile, { MediaTileProps, TileMedia } from '../MediaTile' 3 | import { DefaultGameDisplayType } from '@renderer/atoms/defaults/gameDisplayTypes' 4 | 5 | type Props = Omit & { 6 | game: Game 7 | displayType?: Game['gameTileDisplayType'] 8 | } 9 | 10 | const GameTile = ({ 11 | game, 12 | aspectRatio = 'landscape', 13 | displayType: displayTypeFromProps, 14 | ...props 15 | }: Props) => { 16 | const tileMedia: TileMedia = (() => { 17 | const displayType = 18 | displayTypeFromProps ?? game.gameTileDisplayType ?? DefaultGameDisplayType.gameTile 19 | 20 | const art = displayType === 'fanart' ? 'hero' : displayType 21 | let background = game[art] 22 | if (!background) background = game.screenshot 23 | 24 | // we can only use poster on landscape tiles; if set, use screenshot + logo instead 25 | if (aspectRatio !== 'landscape') { 26 | return { 27 | background: art === 'poster' ? game.hero ?? game.screenshot : background, 28 | foreground: game.logo, 29 | foregroundText: game.name ?? game.romname 30 | } 31 | } 32 | 33 | return { 34 | background: background, 35 | foreground: (() => { 36 | if (art === 'poster' && background) return undefined 37 | return game.logo 38 | })(), 39 | foregroundText: (() => { 40 | if (art === 'poster' && background) return undefined 41 | return game.name ?? game.romname 42 | })() 43 | } 44 | })() 45 | 46 | return 47 | } 48 | 49 | export default GameTile 50 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | import type { ConfigStorage, writeDefaultConfig } from './util/configStorage' 3 | import { PromiseWithChild } from 'child_process' 4 | import { PlatformPath } from 'path' 5 | 6 | import launchGame from './util/launchGame' 7 | import scanRoms from './util/scanRoms' 8 | import downloadGame from './util/downloadGame' 9 | import downloadGameMedia from './util/downloadGameMedia' 10 | import getRomFileInfo from './util/getRomFileInfo' 11 | import loadSystemStore from './util/loadSystemStore' 12 | import loadMedia, { loadMediaAsync } from './util/loadMedia' 13 | import removeGameFiles from './util/removeGameFiles' 14 | import initRomDir from './util/initRomDir' 15 | import { installEmulator } from './util/installEmulator' 16 | 17 | declare global { 18 | interface Window { 19 | electron: ElectronAPI 20 | api: unknown 21 | configStorage: ConfigStorage 22 | launchGame: typeof launchGame 23 | path: PlatformPath 24 | scanRoms: typeof scanRoms 25 | loadMedia: typeof loadMedia 26 | loadMediaAsync: typeof loadMediaAsync 27 | loadSystemStore: typeof loadSystemStore 28 | downloadGame: typeof downloadGame 29 | downloadGameMedia: typeof downloadGameMedia 30 | getRomFileInfo: typeof getRomFileInfo 31 | removeGameFiles: typeof removeGameFiles 32 | platform: 'darwin' | 'linux' | 'win32' 33 | homedir: string 34 | checkDir: (dir: string) => boolean 35 | initRomDir: typeof initRomDir 36 | restart: () => void 37 | quit: () => void 38 | focusApp: () => void 39 | hasFlatpak: boolean 40 | installEmulator: typeof installEmulator 41 | writeDefaultConfig: typeof writeDefaultConfig 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/src/components/MediaImage/MediaImage.tsx: -------------------------------------------------------------------------------- 1 | import { MediaImageData } from '@common/types/InternalMediaType' 2 | import { CSSProperties, PropsWithChildren, useEffect, useState } from 'react' 3 | 4 | interface Props { 5 | className?: string 6 | fallbackClassName?: string 7 | onLoaded?: () => void 8 | media?: MediaImageData 9 | style?: CSSProperties 10 | async?: boolean 11 | } 12 | 13 | const MediaImage = ({ 14 | children, 15 | className, 16 | fallbackClassName, 17 | onLoaded, 18 | media, 19 | style, 20 | async 21 | }: PropsWithChildren) => { 22 | const [isErr, setIsErr] = useState(false) 23 | const [asyncUrl, setAsyncUrl] = useState("") 24 | 25 | useEffect(() => { 26 | if(!async) return; 27 | 28 | const getMedia = async () => { 29 | try { 30 | const url = await window.loadMediaAsync(media ?? ''); 31 | console.log(url) 32 | setAsyncUrl(url); 33 | } catch (e) { 34 | setAsyncUrl('fail') 35 | } 36 | } 37 | 38 | getMedia(); 39 | }, [media, async]) 40 | 41 | const fallback = children ? ( 42 | <>{children} 43 | ) : ( 44 |
45 | ) 46 | if (!media) return fallback 47 | 48 | const url = async ? '' : window.loadMedia(media); 49 | if(!async && !url) return fallback 50 | 51 | if(async && !asyncUrl) return null; 52 | if(async && asyncUrl === "fail") return fallback; 53 | 54 | if (isErr) return fallback 55 | return ( 56 | { 62 | setIsErr(true) 63 | }} 64 | /> 65 | ) 66 | } 67 | 68 | export default MediaImage 69 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/SectionSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@renderer/enums' 2 | import { useOnInput } from '@renderer/hooks' 3 | import classNames from 'classnames' 4 | import css from './SectionSelector.module.scss' 5 | import { Dispatch } from 'react' 6 | import { SetStateAction } from 'jotai' 7 | import { Section } from '..' 8 | 9 | interface Props { 10 | activeSection: number 11 | setActiveSection: Dispatch> 12 | isActive: boolean 13 | sections: Section[] 14 | inputPriority?: number 15 | } 16 | 17 | const SectionSelector = ({ 18 | activeSection, 19 | setActiveSection, 20 | sections, 21 | isActive, 22 | inputPriority 23 | }: Props) => { 24 | useOnInput( 25 | (input) => { 26 | switch (input) { 27 | case Input.UP: 28 | return setActiveSection((active) => (active === 0 ? sections.length - 1 : active - 1)) 29 | case Input.DOWN: 30 | return setActiveSection((active) => (active === sections.length - 1 ? 0 : active + 1)) 31 | } 32 | }, 33 | { 34 | disabled: !isActive, 35 | priority: inputPriority 36 | } 37 | ) 38 | 39 | return ( 40 |
41 | {sections.map((section, i) => ( 42 | 43 | ))} 44 |
45 | ) 46 | } 47 | 48 | interface SectionButtonProps { 49 | section: Section 50 | active: boolean 51 | } 52 | 53 | const SectionButton = ({ section, active }: SectionButtonProps) => { 54 | const Icon = active ? section.IconActive : section.Icon 55 | return ( 56 |
59 | 60 |
{section.label}
61 |
62 | ) 63 | } 64 | 65 | export default SectionSelector 66 | -------------------------------------------------------------------------------- /src/renderer/src/components/MediaTile/MediaTile.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import css from './MediaTile.module.scss' 3 | import { CSSProperties } from 'react' 4 | import MediaImage from '../MediaImage/MediaImage' 5 | import { MediaImageData } from '@common/types/InternalMediaType' 6 | 7 | export interface TileMedia { 8 | background?: MediaImageData 9 | foreground?: MediaImageData 10 | foregroundText?: string 11 | } 12 | 13 | export type AspectRatio = 'landscape' | 'square' 14 | 15 | export interface MediaTileProps { 16 | active?: boolean 17 | activeRef?: React.RefObject 18 | aspectRatio?: AspectRatio 19 | style?: CSSProperties 20 | swapTransform?: boolean 21 | className?: string 22 | screenshot?: string 23 | media: TileMedia 24 | } 25 | 26 | const MediaTile = ({ 27 | aspectRatio = 'landscape', 28 | active = false, 29 | activeRef, 30 | className, 31 | style, 32 | swapTransform, 33 | media 34 | }: MediaTileProps) => { 35 | return ( 36 |
49 | {media.background && ( 50 | 54 | )} 55 | {media.foreground && } 56 | {media.foregroundText && !media.foreground && !media.background && ( 57 |
58 |
{media.foregroundText}
59 |
60 | )} 61 |
62 | ) 63 | } 64 | 65 | export default MediaTile 66 | -------------------------------------------------------------------------------- /src/renderer/src/index.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | :root { 4 | --background: hsl(200, 15%, 20%); 5 | --background-medium: hsl(200, 15%, 15%); 6 | --background-dark: hsl(200, 15%, 10%); 7 | --background-light: hsl(200, 15%, 30%); 8 | --background-lighter: hsl(200, 15%, 40%); 9 | --primary: hsl(200, 30%, 84%); 10 | 11 | transition: background-color 0.5s ease; 12 | background-color: var(--background); 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | } 18 | *, 19 | *:before, 20 | *:after { 21 | box-sizing: inherit; 22 | } 23 | 24 | :root { 25 | font-synthesis: none; 26 | text-rendering: optimizeLegibility; 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | -webkit-text-size-adjust: 100%; 30 | } 31 | 32 | html { 33 | height: 100%; 34 | } 35 | 36 | body { 37 | margin: 0; 38 | padding: 0; 39 | height: 100%; 40 | overflow: hidden; 41 | } 42 | 43 | #root, 44 | .App { 45 | height: 100%; 46 | } 47 | 48 | a { 49 | font-weight: 500; 50 | color: #646cff; 51 | text-decoration: inherit; 52 | } 53 | a:hover { 54 | color: #535bf2; 55 | } 56 | 57 | h1 { 58 | font-size: 3.2em; 59 | line-height: 1.1; 60 | } 61 | 62 | button { 63 | border-radius: 8px; 64 | border: 1px solid transparent; 65 | padding: 0.6em 1.2em; 66 | font-size: 1em; 67 | font-weight: 500; 68 | font-family: inherit; 69 | background-color: #1a1a1a; 70 | cursor: pointer; 71 | transition: border-color 0.25s; 72 | } 73 | button:hover { 74 | border-color: #646cff; 75 | } 76 | button:focus, 77 | button:focus-visible { 78 | outline: 4px auto -webkit-focus-ring-color; 79 | } 80 | 81 | /* @media (prefers-color-scheme: light) { */ 82 | /* :root { */ 83 | /* color: #213547; */ 84 | /* background-color: #ffffff; */ 85 | /* } */ 86 | /* a:hover { */ 87 | /* color: #747bff; */ 88 | /* } */ 89 | /* button { */ 90 | /* background-color: #f9f9f9; */ 91 | /* } */ 92 | /* } */ 93 | -------------------------------------------------------------------------------- /src/preload/util/getSystemPaths.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import os from 'os' 3 | import path from 'path'; 4 | import { execSync } from 'child_process' 5 | 6 | export const getSystemPaths = () => { 7 | let data: string; 8 | let config: string; 9 | let romsDefault: string; 10 | 11 | const HOME_PATH = os.homedir() 12 | 13 | const APP_NAME = 'EmuHub' 14 | const APP_CONFIG_SUBDIR = 'EmuHubConfig' 15 | 16 | switch(os.platform()) { 17 | case 'linux': { 18 | data = path.join( 19 | (process.env.XDG_DATA_HOME ?? path.join(HOME_PATH, '.local', 'share')), 20 | APP_NAME 21 | ) 22 | 23 | config = path.join( 24 | (process.env.XDG_CONFIG_HOME ?? path.join(HOME_PATH, '.config')), 25 | APP_NAME, 26 | APP_CONFIG_SUBDIR 27 | ) 28 | 29 | const emudeckConfig = path.join(HOME_PATH, '.config', 'EmuDeck', 'backend', 'functions', 'all.sh') 30 | if(existsSync(emudeckConfig)) { 31 | try { 32 | const stdout = execSync(`source ${emudeckConfig} && echo $romsPath`, { encoding: 'utf8', shell: '/bin/bash' }) 33 | if(stdout) { 34 | romsDefault = stdout.trim() 35 | break 36 | } 37 | } catch {} 38 | } 39 | 40 | romsDefault = path.join(data, 'roms') 41 | break 42 | } 43 | case 'darwin': { 44 | data = path.join(HOME_PATH, 'Documents', APP_NAME) 45 | config = path.join(HOME_PATH, 'Library', 'Application Support', APP_NAME, APP_CONFIG_SUBDIR) 46 | romsDefault = path.join(data, 'roms') 47 | break 48 | } 49 | case 'win32': { 50 | data = path.join(HOME_PATH, 'Documents', APP_NAME) 51 | config = path.join(HOME_PATH, 'AppData', 'Roaming', APP_NAME, APP_CONFIG_SUBDIR) 52 | romsDefault = path.join(data, 'roms') 53 | break 54 | } 55 | default: { 56 | throw('os not supported') 57 | } 58 | } 59 | 60 | return { 61 | data, 62 | config, 63 | romsDefault 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/src/colors/colorSchemes.ts: -------------------------------------------------------------------------------- 1 | interface ColorScheme { 2 | id: string 3 | label: string 4 | hue: number 5 | saturation?: number 6 | overrides?: { 7 | [color: string]: { 8 | hue?: number 9 | saturation?: number 10 | lightness?: number 11 | } 12 | } 13 | } 14 | 15 | export const colorSchemes: ColorScheme[] = [ 16 | { 17 | id: 'default', 18 | label: 'Night Ocean (Default)', 19 | hue: 200 20 | }, 21 | { 22 | id: 'violet', 23 | label: 'Violet', 24 | hue: 250, 25 | saturation: 25 26 | }, 27 | { 28 | id: 'aquamarine', 29 | label: 'Aquamarine', 30 | hue: 165, 31 | saturation: 70, 32 | overrides: { 33 | 'background-medium': { lightness: 12 } 34 | } 35 | }, 36 | { 37 | id: 'copper', 38 | label: 'Copper', 39 | hue: 360, 40 | saturation: 18, 41 | overrides: { 42 | background: { lightness: 18 }, 43 | 'background-medium': { lightness: 13 }, 44 | 'background-dark': { lightness: 8 } 45 | } 46 | }, 47 | { 48 | id: 'crimson', 49 | label: 'Crimson', 50 | hue: 0, 51 | saturation: 50 52 | }, 53 | { 54 | id: 'pc-classic', 55 | label: 'PC Classic', 56 | hue: 99, 57 | saturation: 15, 58 | overrides: { 59 | background: { lightness: 30 }, 60 | 'background-medium': { lightness: 12 }, 61 | 'background-light': { lightness: 35 } 62 | } 63 | } 64 | ] 65 | 66 | export const defaultSaturation = 15 67 | 68 | export const modifiableColors = [ 69 | { id: 'background', saturation: defaultSaturation, lightness: 20 } as const, 70 | { id: 'background-medium', saturation: defaultSaturation, lightness: 12 } as const, 71 | { id: 'background-dark', saturation: defaultSaturation, lightness: 10 } as const, 72 | { id: 'background-light', saturation: defaultSaturation, lightness: 30 } as const, 73 | { id: 'background-lighter', saturation: defaultSaturation, lightness: 40 } as const, 74 | { id: 'primary', saturation: 30, lightness: 84 } as const 75 | ] as const 76 | -------------------------------------------------------------------------------- /src/preload/util/configStorage.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, rmSync, mkdirSync } from 'fs' 2 | import YAML from 'yaml' 3 | import path from 'path' 4 | 5 | import { CONFIG_PATH } from './const' 6 | 7 | const configFilePath = (configType: string) => path.join(CONFIG_PATH, `${configType}.yml`) 8 | 9 | const saveConfig = (configType: string, value: T) => { 10 | const yaml = YAML.stringify(value, { aliasDuplicateObjects: false }) 11 | 12 | mkdirSync(CONFIG_PATH, { recursive: true }); 13 | writeFileSync(configFilePath(configType), yaml, { encoding: 'utf8' }) 14 | } 15 | 16 | export const writeDefaultConfig = (configType: string, value: any) => { 17 | const defaultsPath = path.join(CONFIG_PATH, 'defaults'); 18 | mkdirSync(defaultsPath, { recursive: true }); 19 | 20 | const readmePath = path.join(defaultsPath, "README.txt"); 21 | writeFileSync(readmePath, "The files in this folder are provided for reference. Any changes made will be ignored and overwritten!\n\nTo modify defaults or add new entries, use the respective config files in the main config folder.") 22 | 23 | const configPath = path.join(defaultsPath, `${configType}.yml`); 24 | writeFileSync(configPath, YAML.stringify(value, { aliasDuplicateObjects: false })); 25 | } 26 | 27 | export const loadConfig = (configType: string, defaultValue: T) => { 28 | try { 29 | const file = readFileSync(configFilePath(configType), { encoding: 'utf8' }) 30 | 31 | return YAML.parse(file) as T 32 | } catch (e) { 33 | saveConfig(configType, defaultValue) 34 | return defaultValue 35 | } 36 | } 37 | 38 | const deleteConfig = (configType: string) => { 39 | rmSync(configFilePath(configType)) 40 | } 41 | 42 | export interface ConfigStorage { 43 | getItem: (configType: string, initialValue: T) => T 44 | setItem: (configType: string, value: T) => void 45 | removeItem: (configType: string) => void 46 | } 47 | 48 | const configStorage: ConfigStorage = { 49 | getItem: loadConfig, 50 | setItem: saveConfig, 51 | removeItem: deleteConfig 52 | } 53 | 54 | export default configStorage 55 | -------------------------------------------------------------------------------- /src/renderer/src/components/Showcase/Showcase.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .main { 4 | width: 100vw; 5 | height: 35vh; 6 | position: relative; 7 | overflow: hidden; 8 | flex-shrink: 0; 9 | 10 | transition: height 0.3s ease; 11 | 12 | display: flex; 13 | 14 | &.hidden { 15 | height: 0; 16 | } 17 | 18 | box-shadow: 0px 0px 5px 1px rgba(0, 0, 0, 0.5); 19 | transition: box-shadow 0.5s ease; 20 | } 21 | 22 | .outerContent { 23 | background: var(--background-medium); 24 | width: 100%; 25 | height: 100%; 26 | } 27 | 28 | .innerContent { 29 | height: 100%; 30 | width: 100%; 31 | position: relative; 32 | 33 | display: flex; 34 | overflow: hidden; 35 | align-items: center; 36 | justify-content: center; 37 | } 38 | 39 | .left { 40 | flex: 6.5; 41 | height: 100%; 42 | background-color: var(--background-dark); 43 | } 44 | 45 | .right { 46 | flex: 4; 47 | display: flex; 48 | flex-direction: column; 49 | align-items: center; 50 | justify-content: center; 51 | gap: 2rem; 52 | height: 100%; 53 | } 54 | 55 | .text, 56 | .pills { 57 | font-family: 'Figtree Variable', sans-serif; 58 | font-weight: 400; 59 | color: white; 60 | } 61 | 62 | .text { 63 | font-size: 1.5rem; 64 | } 65 | 66 | .pills { 67 | width: 100%; 68 | display: flex; 69 | flex-wrap: wrap; 70 | justify-content: center; 71 | gap: 0.8rem; 72 | padding: 0 4rem; 73 | 74 | @media screen and (max-width: 1050px) { 75 | padding: 0 1rem; 76 | } 77 | } 78 | 79 | .imgLeft { 80 | width: 100%; 81 | height: 100%; 82 | object-fit: cover; 83 | filter: brightness(0.8); 84 | } 85 | 86 | .imgRight { 87 | max-width: 60%; 88 | max-height: 50%; 89 | object-fit: contain; 90 | } 91 | 92 | .name { 93 | font-family: Figtree Variable; 94 | color: white; 95 | font-size: 1.5rem; 96 | } 97 | 98 | .background { 99 | position: absolute; 100 | height: 100%; 101 | width: 100%; 102 | object-fit: cover; 103 | filter: blur(2px) brightness(50%); 104 | z-index: -1; 105 | } 106 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/collections.ts: -------------------------------------------------------------------------------- 1 | import { atomFamily } from 'jotai/utils' 2 | import { arrayConfigAtoms } from './util/arrayConfigAtom' 3 | import { atom } from 'jotai' 4 | import games from './games' 5 | import { Game } from '@common/types' 6 | 7 | interface Collection { 8 | id: string 9 | name: string 10 | games: string[] 11 | } 12 | 13 | const mainAtoms = arrayConfigAtoms({ default: [], storageKey: 'collections' }) 14 | const allWithGames = atom((get) => { 15 | const collections = get(mainAtoms.lists.all) 16 | return collections.map((collection) => ({ 17 | ...collection, 18 | games: collection.games.map((game) => get(games.single(game))).filter(Boolean) as Game[] 19 | })) 20 | }) 21 | 22 | const getWithGames = atomFamily((id: string) => 23 | atom((get) => { 24 | const collection = get(allWithGames).find((collection) => collection.id === id) 25 | if (!collection) return null 26 | 27 | return collection 28 | }) 29 | ) 30 | 31 | const curriedGetWithGames = atom((get) => (id: string) => get(getWithGames(id))) 32 | 33 | const addGame = atom(null, (get, set, collectionId: string, gameId: string) => { 34 | const collection = get(mainAtoms.single(collectionId)) 35 | if (!collection) return 36 | 37 | const currentGames = collection.games 38 | set(mainAtoms.single(collectionId), { 39 | games: [...currentGames, gameId] 40 | }) 41 | }) 42 | 43 | const removeGame = atom(null, (get, set, collectionId: string, gameId: string) => { 44 | const collection = get(mainAtoms.single(collectionId)) 45 | if (!collection) throw `Tried to add game to invalid collection: ${collectionId}` 46 | 47 | const currentGames = collection.games 48 | set(mainAtoms.single(collectionId), { 49 | games: currentGames.filter((game) => game !== gameId) 50 | }) 51 | }) 52 | 53 | export default { 54 | ...mainAtoms, 55 | lists: { 56 | ...mainAtoms.lists, 57 | withGames: allWithGames 58 | }, 59 | single: { 60 | base: mainAtoms.single, 61 | withGames: getWithGames, 62 | curried: mainAtoms.curriedSingle, 63 | curriedWithGames: curriedGetWithGames 64 | }, 65 | addGame, 66 | removeGame 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/src/components/MediaTile/MediaTile.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | @import url('https://fonts.googleapis.com/css2?family=Lora&family=Ubuntu:wght@700&family=Young+Serif&display=swap'); 3 | 4 | .foreground { 5 | position: absolute; 6 | height: 100%; 7 | width: 100%; 8 | top: 0; 9 | left: 0; 10 | object-fit: contain; 11 | } 12 | 13 | .background { 14 | object-fit: cover; 15 | width: 100%; 16 | height: 100%; 17 | 18 | &.withForeground { 19 | filter: brightness(65%) blur(0.6px); 20 | } 21 | } 22 | 23 | .elem { 24 | height: clamp(120px, 12vh, 160px); 25 | filter: brightness(85%); 26 | transition: all 0.15s ease; 27 | border-radius: 3px; 28 | border: 3px solid transparent; 29 | box-shadow: 1px 4px 3px rgba(0, 0, 0, 0.5); 30 | overflow: hidden; 31 | flex-shrink: 0; 32 | position: relative; 33 | box-sizing: border-box; 34 | background-color: var(--background-lighter); 35 | background-clip: content-box; 36 | 37 | &.swapTransform { 38 | transform: scale(0.96); 39 | } 40 | 41 | &.square { 42 | aspect-ratio: 1 / 1; 43 | height: clamp(150px, 15vh, 170px); 44 | 45 | .foreground { 46 | padding: 10%; 47 | } 48 | } 49 | 50 | &.landscape { 51 | aspect-ratio: 92 / 43; 52 | 53 | .foreground { 54 | padding: 4% 12%; 55 | } 56 | } 57 | 58 | &.active { 59 | filter: brightness(100%); 60 | border: 3px solid var(--primary); 61 | transform: scale(1.04); 62 | 63 | &.swapTransform { 64 | transform: unset; 65 | } 66 | } 67 | } 68 | 69 | .foregroundTextWrapper { 70 | position: absolute; 71 | height: 100%; 72 | width: 100%; 73 | top: 0; 74 | left: 0; 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | padding: 3.5% 8%; 79 | } 80 | 81 | .foregroundText { 82 | font-family: Figree, sans-serif; 83 | font-size: 1.5em; 84 | color: hsl(200, 15%, 90%); 85 | 86 | display: flex; 87 | align-items: center; 88 | 89 | display: inline-block; 90 | overflow: hidden; 91 | text-overflow: ellipsis; 92 | display: -webkit-box; 93 | -webkit-line-clamp: 3; // max nb lines to show 94 | -webkit-box-orient: vertical; 95 | } 96 | -------------------------------------------------------------------------------- /src/preload/util/downloadGameMedia.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Game } from '@common/types' 3 | import { ASSETS_PATH } from './const' 4 | import { mkdir, writeFile } from 'fs/promises' 5 | import sharp from 'sharp' 6 | import { refreshImages } from './loadMedia' 7 | 8 | interface GameMedia { 9 | mediaType: string 10 | url: string 11 | format: string 12 | } 13 | 14 | const downloadGameMedia = async ( 15 | game: Game, 16 | medias: GameMedia[], 17 | headers?: Record 18 | ) => { 19 | const gameAssetsPath = path.join( 20 | ASSETS_PATH, 21 | 'games', 22 | game.system, 23 | ...(game.rompath || []), 24 | `${game.romname}` 25 | ) 26 | 27 | try { 28 | await mkdir(gameAssetsPath, { recursive: true }) 29 | } catch { } 30 | 31 | const requests = medias.map(({ mediaType, url, format }) => async () => { 32 | const mediaPath = path.join(gameAssetsPath, `${mediaType}.${format}`) 33 | 34 | const response = await fetch(url, { headers }) 35 | if (response.status === 404) throw 'Image not found' 36 | 37 | let buffer = Buffer.from(await response.arrayBuffer()) 38 | try { 39 | // screenscraper assets often come with padding that leads to layout inconsistency 40 | // get rid of it! 41 | buffer = await sharp(buffer).trim().toBuffer() 42 | } catch (e) { 43 | console.log(`Couldn't crop asset ${mediaType} for ${game.romname} -- ${e}`) 44 | } 45 | 46 | await writeFile(mediaPath, buffer) 47 | return { 48 | mediaType, 49 | mediaPath 50 | } 51 | }) 52 | 53 | const fulfilled = await Promise.allSettled(requests.map((req) => req())) 54 | const newGame = fulfilled.reduce((acc, fulfilledEntry) => { 55 | if (fulfilledEntry.status === 'rejected') return acc 56 | const { mediaType, mediaPath } = fulfilledEntry.value 57 | 58 | return { 59 | ...acc, 60 | [mediaType]: mediaPath 61 | } 62 | }, game) 63 | 64 | if (!newGame.gameTileDisplayType) { 65 | newGame.gameTileDisplayType = newGame.hero 66 | ? 'fanart' 67 | : newGame.screenshot 68 | ? 'screenshot' 69 | : undefined 70 | } 71 | 72 | refreshImages() 73 | return newGame 74 | } 75 | 76 | export default downloadGameMedia 77 | -------------------------------------------------------------------------------- /src/renderer/src/components/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { createPortal } from 'react-dom' 2 | import css from './Notifications.module.scss' 3 | import notifications, { Notification } from '@renderer/atoms/notifications' 4 | import { IconType } from 'react-icons' 5 | import { FaCircleInfo } from 'react-icons/fa6' 6 | import { MdDownloadForOffline } from 'react-icons/md' 7 | import { AnimatePresence, motion } from 'framer-motion' 8 | import classNames from 'classnames' 9 | import { FaCheckCircle, FaExclamationCircle } from 'react-icons/fa' 10 | import { useAtom } from 'jotai' 11 | import { appConfigAtom } from '@renderer/atoms/appConfig' 12 | import { hintsHeight } from '@renderer/const/const' 13 | 14 | const Notifications = () => { 15 | const [appConfig] = useAtom(appConfigAtom) 16 | const { ui: { controllerHints }} = appConfig 17 | 18 | const [notificationsList] = useAtom(notifications.list) 19 | 20 | return createPortal( 21 | 29 | 30 | {notificationsList.map((notification) => ( 31 | 32 | ))} 33 | 34 | , 35 | document.body 36 | ) 37 | } 38 | 39 | const IconMap: Partial> = { 40 | info: FaCircleInfo, 41 | download: MdDownloadForOffline, 42 | error: FaExclamationCircle, 43 | success: FaCheckCircle 44 | } 45 | 46 | const NotificationDisplay = ({ notification }: { notification: Notification }) => { 47 | const Icon = IconMap[notification.type] 48 | 49 | return ( 50 | 56 | {notification.text} 57 | {Icon && } 58 | 59 | ) 60 | } 61 | 62 | export default Notifications 63 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useRecommendationScrollers.ts: -------------------------------------------------------------------------------- 1 | import games from '@renderer/atoms/games' 2 | import { Game } from '@common/types' 3 | import { ScrollerConfig } from '@renderer/components/Scrollers/Scrollers' 4 | import { useAtom } from 'jotai' 5 | import { useMemo } from 'react' 6 | 7 | type RecommendationType = 'recs-genre' | 'recs-developer' | 'recs-publisher' 8 | 9 | export const useRecommendationScrollers = ( 10 | game: Game | undefined, 11 | onSelectGame?: (game: Game) => void, 12 | labelMap?: Record string> 13 | ) => { 14 | const [genreElems] = useAtom( 15 | games.lists.byAttribute({ 16 | limit: 10, 17 | attribute: 'genre', 18 | value: game?.genre, 19 | excludeId: game?.id, 20 | shuffle: true 21 | }) 22 | ) 23 | 24 | const [developerElems] = useAtom( 25 | games.lists.byAttribute({ 26 | limit: 10, 27 | attribute: 'developer', 28 | value: game?.developer, 29 | excludeId: game?.id, 30 | shuffle: true 31 | }) 32 | ) 33 | 34 | const [publisherElems] = useAtom( 35 | games.lists.byAttribute({ 36 | limit: 10, 37 | attribute: 'publisher', 38 | value: game?.publisher, 39 | excludeId: game?.id, 40 | shuffle: true 41 | }) 42 | ) 43 | 44 | return useMemo(() => { 45 | if (!game) return [] 46 | return [ 47 | { 48 | id: 'recs-genre', 49 | label: labelMap?.['recs-genre']?.(game.genre) ?? `More In Genre - ${game.genre}`, 50 | elems: genreElems, 51 | onSelect: onSelectGame 52 | }, 53 | { 54 | id: 'recs-developer', 55 | label: labelMap?.['recs-developer']?.(game.developer) ?? `More Developed By ${game.developer}`, 56 | elems: developerElems, 57 | onSelect: onSelectGame, 58 | aspectRatio: 'square' 59 | }, 60 | { 61 | id: 'recs-publisher', 62 | label: labelMap?.['recs-publisher']?.(game.publisher) ?? `More Published By ${game.publisher}`, 63 | elems: publisherElems, 64 | onSelect: onSelectGame 65 | } 66 | ].filter((scroller) => scroller.elems.length) as ScrollerConfig[] 67 | }, [genreElems, developerElems, publisherElems]) 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/src/components/GameArtSelection/GameArtSelection.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | background-color: var(--background); 5 | border-radius: 20px; 6 | overflow: hidden; 7 | box-shadow: 5px 0px 5px rgba(0, 0, 0, 0.4); 8 | 9 | display: flex; 10 | 11 | .left, 12 | .right { 13 | display: grid; 14 | grid-template-rows: repeat(3, minmax(0, 1fr)); 15 | } 16 | 17 | .row { 18 | padding: 2rem; 19 | } 20 | 21 | .left { 22 | .row { 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | font-family: Figtree Variable; 27 | font-size: 1.3rem; 28 | color: $fadedminus; 29 | 30 | min-width: 250px; 31 | 32 | .sub { 33 | font-size: 0.8rem; 34 | } 35 | 36 | transition: 37 | background-color 0.1s ease, 38 | color 0.1s ease; 39 | 40 | &.active { 41 | background-color: var(--primary); 42 | color: var(--background-dark); 43 | } 44 | } 45 | background-color: var(--background-dark); 46 | } 47 | 48 | .right { 49 | .row { 50 | display: flex; 51 | justify-content: space-around; 52 | gap: 3rem; 53 | 54 | transition: backdrop-filter 0.1s ease; 55 | 56 | &.active { 57 | backdrop-filter: brightness(140%); 58 | } 59 | 60 | .column { 61 | display: flex; 62 | justify-content: center; 63 | align-items: center; 64 | } 65 | } 66 | } 67 | 68 | .contentWithLabel { 69 | display: flex; 70 | flex-direction: column; 71 | align-items: center; 72 | justify-content: center; 73 | gap: 1rem; 74 | 75 | font-family: Figtree Variable; 76 | font-size: 1rem; 77 | color: $faded; 78 | 79 | .showcaseWrapper { 80 | display: block; 81 | } 82 | } 83 | 84 | .img { 85 | transition: 86 | border 0.15s ease, 87 | transform 0.15s ease; 88 | 89 | height: 15vh; 90 | object-fit: cover; 91 | box-shadow: 1px 4px 3px rgba(0, 0, 0, 0.5); 92 | border: 3px solid transparent; 93 | border-radius: 3px; 94 | 95 | &.active { 96 | border: 3px solid var(--primary); 97 | transform: scale(1.04); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Interface/Interface.tsx: -------------------------------------------------------------------------------- 1 | import ControllerForm, { 2 | ControllerFormEntry 3 | } from '@renderer/components/ControllerForm/ControllerForm' 4 | import { SectionProps } from '..' 5 | import { useOnInput } from '@renderer/hooks' 6 | import { Input } from '@renderer/enums' 7 | import { useAtom } from 'jotai' 8 | import { appConfigAtom } from '@renderer/atoms/appConfig' 9 | import { colorSchemes } from '@renderer/colors/colorSchemes' 10 | import { ColorSchemeId } from '@common/types' 11 | 12 | const Interface = ({ inputPriority, isActive, onExit }: SectionProps) => { 13 | const [appConfig, updateAppConfig] = useAtom(appConfigAtom) 14 | 15 | const entries: ControllerFormEntry[] = [ 16 | { 17 | id: 'color-scheme', 18 | type: 'selector', 19 | label: 'Color Scheme', 20 | value: appConfig.ui.colorScheme ?? 'default', 21 | options: colorSchemes.map((scheme) => ({ 22 | id: scheme.id, 23 | label: scheme.label 24 | })), 25 | onSelect: (scheme) => { 26 | updateAppConfig((config) => { 27 | config.ui.colorScheme = scheme as ColorSchemeId 28 | }) 29 | }, 30 | wraparound: true 31 | }, 32 | { 33 | id: 'show-controller-hint', 34 | type: 'toggle', 35 | label: 'Enable Controller Hints', 36 | enabled: appConfig.ui.controllerHints, 37 | setEnabled: (e) => { updateAppConfig((config) => { config.ui.controllerHints = e })} 38 | }, 39 | { 40 | id: 'column-count', 41 | type: 'number', 42 | label: 'Game Grid Columns', 43 | sublabel: 'Number of columns to display in game grids (search, all games, etc).', 44 | defaultValue: appConfig.ui.grid.columnCount, 45 | min: 2, 46 | max: 5, 47 | onNumber: (num) => { 48 | updateAppConfig((config) => { 49 | config.ui.grid.columnCount = num 50 | }) 51 | } 52 | } 53 | ] 54 | 55 | useOnInput( 56 | (input) => { 57 | switch (input) { 58 | case Input.B: 59 | case Input.LEFT: 60 | onExit() 61 | break 62 | } 63 | }, 64 | { 65 | disabled: !isActive, 66 | priority: inputPriority 67 | } 68 | ) 69 | 70 | return 71 | } 72 | 73 | export default Interface 74 | -------------------------------------------------------------------------------- /resources/systems/gb/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 15 | 18 | 20 | 22 | 25 | 28 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/preload/util/loadSystemStore.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { SystemStore, StoreEntry } from '@common/types' 3 | import { capitalCase } from 'change-case' 4 | 5 | const loadSystemStore = (systemStore: SystemStore, systemId: string): Promise => { 6 | switch (systemStore.type) { 7 | case 'html': 8 | return handleHTMLStore(systemStore) 9 | case 'emudeck': 10 | return handleEmuDeckStore(systemId) 11 | } 12 | } 13 | 14 | const handleHTMLStore = async (store: SystemStore & { type: 'html' }) => { 15 | const storeHTMLResponse = await fetch(store.url) 16 | const storeHTML = await storeHTMLResponse.text() 17 | 18 | const dom = new DOMParser().parseFromString(storeHTML, 'text/html') 19 | const nodes = dom.querySelectorAll(store.selector) 20 | 21 | return [...nodes].map((node) => { 22 | const rawHref = node.href 23 | const relativePath = path.basename(rawHref) 24 | const href = store.url + '/' + relativePath 25 | 26 | const rawName = node.innerText 27 | const name = path.basename(rawName, path.extname(rawName)) 28 | 29 | return { 30 | href, 31 | name 32 | } 33 | }) 34 | } 35 | 36 | const handleEmuDeckStore = async (systemId: string) => { 37 | const storeJSONResponse = await fetch( 38 | `https://api.github.com/repos/dragoonDorise/EmuDeck/contents/store/${systemId}?ref=main` 39 | ) 40 | const storeJSON = await storeJSONResponse.json() 41 | 42 | const games = await Promise.all( 43 | storeJSON.map(async (storeGameEntry) => { 44 | const gameDataUrl = storeGameEntry.download_url 45 | const gameDataResponse = await fetch(gameDataUrl) 46 | const gameData = await gameDataResponse.json() 47 | 48 | const screenshotUrl = gameData.pictures.screenshots?.[0] 49 | const screenshotFormat = path 50 | .extname(screenshotUrl ?? '') 51 | .split('?')[0] 52 | .slice(1) 53 | 54 | return { 55 | href: gameData.file, 56 | name: gameData.title, 57 | description: gameData.description, 58 | genre: gameData.tags?.map(capitalCase).join(' / '), 59 | media: 60 | screenshotUrl && screenshotFormat 61 | ? { 62 | screenshot: { 63 | url: screenshotUrl, 64 | format: screenshotFormat 65 | } 66 | } 67 | : undefined 68 | } as StoreEntry 69 | }) 70 | ) 71 | 72 | return games 73 | } 74 | 75 | export default loadSystemStore 76 | -------------------------------------------------------------------------------- /src/renderer/src/components/GameListPage/GameListPage.tsx: -------------------------------------------------------------------------------- 1 | import { Game } from '@common/types' 2 | import GridScroller from '@renderer/components/GridScroller/GridScroller' 3 | import css from './GameListPage.module.scss' 4 | import VerticalGameInfo from '@renderer/components/VerticalGameInfo' 5 | import { useMemo, useState } from 'react' 6 | import { useNavigate } from 'react-router-dom' 7 | import { ISortByObjectSorter, sort } from 'fast-sort' 8 | import { useAtom } from 'jotai' 9 | import { appConfigAtom } from '@renderer/atoms/appConfig' 10 | import { hintsHeight } from '@renderer/const/const' 11 | import { useOnInput } from '@renderer/hooks/useOnInput' 12 | import { Input } from '@renderer/enums' 13 | 14 | interface Props { 15 | games: Game[] 16 | label?: string 17 | disableSort?: boolean 18 | id?: string 19 | } 20 | 21 | type SortType = "name" 22 | type SortOrder = "desc" | "asc" 23 | 24 | export const GameListPage = ({ games, label, disableSort, id }: Props) => { 25 | const [sortType] = useState("name"); 26 | const [sortOrder] = useState("asc"); 27 | 28 | const [appConfig] = useAtom(appConfigAtom) 29 | const { ui: { controllerHints }} = appConfig 30 | 31 | const sortedGames = useMemo(() => ( 32 | disableSort 33 | ? games 34 | : sort(games).by( 35 | { [sortOrder]: (g: Game) => g.name } as unknown as ISortByObjectSorter 36 | ) 37 | ), [games, sortType, sortOrder]); 38 | 39 | const [activeGame, setActiveGame] = useState(sortedGames[0]); 40 | const navigate = useNavigate() 41 | 42 | // easier to drop the hint here than to include in every GameListPage route 43 | useOnInput(() => {}, { 44 | hints: [ 45 | { input: Input.B, text: "Back" } 46 | ] 47 | }) 48 | 49 | return ( 50 |
56 |
62 | navigate(`/game/${activeGame.id}`)} 66 | onHighlight={setActiveGame} 67 | id={id ? `game-list-${id}` : undefined} 68 | /> 69 |
70 |
71 | {activeGame && } 72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/renderer/src/pages/GameView/GameView.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .container { 4 | scroll-behavior: smooth; 5 | } 6 | 7 | .bg { 8 | height: 100%; 9 | width: 100%; 10 | object-fit: cover; 11 | filter: blur(2px) brightness(50%); 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | z-index: 0; 16 | } 17 | 18 | .disabled { 19 | opacity: 0.2; 20 | } 21 | 22 | .upperContainer { 23 | height: 100vh; 24 | position: relative; 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: center; 28 | align-items: flex-start; 29 | gap: 2rem; 30 | padding-left: 2rem; 31 | } 32 | 33 | @keyframes bounce { 34 | from { 35 | translate: 0px 0px; 36 | } 37 | to { 38 | translate: 0px -10px; 39 | } 40 | } 41 | 42 | .indicatorDown, 43 | .indicatorUp { 44 | z-index: 1; 45 | color: hsla(0, 0%, 100%, 0.6); 46 | font-size: 2rem; 47 | 48 | transition: opacity 0.8s ease; 49 | &.hidden { 50 | opacity: 0; 51 | } 52 | 53 | &.noAnimate { 54 | animation-name: unset; 55 | } 56 | } 57 | 58 | .indicatorDown { 59 | animation-name: bounce; 60 | animation-duration: 0.5s; 61 | animation-timing-function: ease; 62 | animation-direction: alternate; 63 | animation-iteration-count: infinite; 64 | 65 | position: absolute; 66 | left: 50%; 67 | transform: translateX(-50%); 68 | } 69 | 70 | .indicatorUp { 71 | opacity: 0.6; 72 | } 73 | 74 | .logo { 75 | max-width: 38vw; 76 | max-height: 60%; 77 | filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.3)); 78 | } 79 | 80 | .screenshot { 81 | width: 30%; 82 | z-index: 1; 83 | } 84 | 85 | .buttonsAndLogo { 86 | display: flex; 87 | flex-direction: column; 88 | align-items: start; 89 | justify-content: flex-end; 90 | gap: 2rem; 91 | filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.5)); 92 | } 93 | 94 | .tabsContainer { 95 | width: 100%; 96 | height: 101vh; 97 | display: flex; 98 | flex-direction: column; 99 | justify-content: flex-start; 100 | align-items: center; 101 | transition: opacity 0.2s ease; 102 | padding-top: 3rem; 103 | 104 | &.inactive { 105 | opacity: 0.5; 106 | } 107 | } 108 | 109 | .description { 110 | width: 70%; 111 | font-size: 1.2rem; 112 | } 113 | 114 | .recommendations { 115 | width: 70vw; 116 | } 117 | 118 | .name { 119 | font-family: Figtree Variable; 120 | font-size: 3rem; 121 | color: white; 122 | } 123 | 124 | .indicatorUpWrapper { 125 | width: 100%; 126 | display: flex; 127 | justify-content: center; 128 | padding: 1rem 0; 129 | } 130 | -------------------------------------------------------------------------------- /resources/systems/wii/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/renderer/src/components/ControllerForm/ControllerForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '@renderer/scss/colors'; 2 | 3 | .controllerForm { 4 | display: flex; 5 | flex-direction: column; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .entry { 11 | display: flex; 12 | flex-direction: row; 13 | align-items: center; 14 | justify-content: space-between; 15 | 16 | padding: 30px; 17 | 18 | font-family: 19 | Figtree Variable, 20 | sans-serif; 21 | font-size: 1.3rem; 22 | color: $fadedminus; 23 | transition: all 0.1s ease; 24 | 25 | &.active { 26 | color: black; 27 | 28 | &.default { 29 | background-color: var(--primary); 30 | } 31 | &.caution { 32 | background-color: $caution; 33 | } 34 | &.warning { 35 | background-color: $warning; 36 | } 37 | &.confirm { 38 | background-color: $confirm; 39 | } 40 | } 41 | } 42 | 43 | .left { 44 | display: flex; 45 | flex-direction: column; 46 | gap: 3px; 47 | 48 | .sublabel { 49 | font-size: 0.9rem; 50 | transition: opacity 0.1s ease; 51 | 52 | &.active { 53 | opacity: 1; 54 | } 55 | } 56 | } 57 | 58 | .transparentScrollBar { 59 | &::-webkit-scrollbar { 60 | display: none; 61 | } 62 | } 63 | 64 | .toggleOuter { 65 | height: 80%; 66 | width: 60px; 67 | transition: 68 | background-color 0.1s ease, 69 | opacity 0.1s ease; 70 | background-color: hsla(0, 0%, 50%, 60%); 71 | border-radius: 3rem; 72 | display: flex; 73 | justify-content: flex-end; 74 | padding: 0.3rem 0.4rem; 75 | opacity: 0.5; 76 | box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.15); 77 | 78 | .toggleInner { 79 | height: 100%; 80 | aspect-ratio: 1 / 1; 81 | border-radius: 100%; 82 | background-color: hsla(0, 0%, 70%, 60%); 83 | transition: 84 | background-color 0.4s ease, 85 | opacity 0.1s ease; 86 | &.enabled { 87 | background-color: var(--primary); 88 | } 89 | } 90 | 91 | &.enabled { 92 | justify-content: flex-start; 93 | } 94 | 95 | &.active { 96 | background-color: var(--background); 97 | opacity: 1; 98 | } 99 | } 100 | 101 | .round { 102 | border-radius: 20px; 103 | } 104 | 105 | .selectorInput { 106 | display: flex; 107 | align-items: center; 108 | gap: 0.25rem; 109 | opacity: 0.5; 110 | transition: 111 | opacity 0.15s ease, 112 | scale 0.1s ease; 113 | 114 | .caret { 115 | transition: width 0.2s ease; 116 | 117 | &.disabled { 118 | width: 0; 119 | } 120 | } 121 | 122 | &.active { 123 | opacity: 1; 124 | scale: 1.1; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/renderer/src/components/ScrapeModal/ScrapeModal.tsx: -------------------------------------------------------------------------------- 1 | import { SetStateAction, useAtom } from 'jotai' 2 | import { Dispatch, useState } from 'react' 3 | import Modal from '../Modal/Modal' 4 | import css from './ScrapeModal.module.scss' 5 | import ControllerForm, { ControllerFormEntry } from '../ControllerForm/ControllerForm' 6 | import { useOnInput } from '@renderer/hooks' 7 | import { Input } from '@renderer/enums/Input' 8 | import { Game } from '@common/types' 9 | import { InputPriority } from '@renderer/const/inputPriorities' 10 | import { IoCloudDownload } from 'react-icons/io5' 11 | import games from '@renderer/atoms/games' 12 | import { scrapers } from '@renderer/const/scrapers' 13 | 14 | interface Props { 15 | open: boolean 16 | setOpen: Dispatch> 17 | game: Game 18 | } 19 | 20 | const ScrapeModal = ({ open, setOpen, game }: Props) => { 21 | const [, scrapeGame] = useAtom(games.scrape) 22 | const [scrapeBy, setScrapeBy] = useState<'name' | 'rom'>('rom') 23 | 24 | useOnInput( 25 | (input) => { 26 | switch (input) { 27 | case Input.B: 28 | setOpen(false) 29 | } 30 | }, 31 | { 32 | disabled: !open, 33 | priority: InputPriority.GENERAL_MODAL 34 | } 35 | ) 36 | 37 | const [scraper, setScraper] = useState<'screenscraper' | 'igdb'>(scrapers[0].id) 38 | 39 | const entries: ControllerFormEntry[] = [ 40 | { 41 | id: 'scraper-selection', 42 | type: 'selector', 43 | label: 'Scraping Service', 44 | options: [...scrapers], 45 | wraparound: true, 46 | value: scraper, 47 | onSelect: (id) => { 48 | setScraper(id as 'screenscraper' | 'igdb') 49 | } 50 | }, 51 | { 52 | id: 'scrape-by', 53 | type: 'selector', 54 | label: 'Scrape By:', 55 | options: [ 56 | { id: 'rom', label: 'ROM Info (Name, Size, CRC)' }, 57 | { id: 'name', label: 'Game Name' } 58 | ], 59 | onSelect: (id) => setScrapeBy(id as 'name' | 'rom'), 60 | wraparound: true, 61 | value: scrapeBy 62 | }, 63 | { 64 | id: 'start', 65 | type: 'action', 66 | label: 'Scrape Now', 67 | colorScheme: 'confirm', 68 | onSelect: () => { 69 | scrapeGame({ gameId: game.id, scraper, scrapeBy }) 70 | }, 71 | Icon: IoCloudDownload 72 | } 73 | ] 74 | 75 | return ( 76 | 77 |
78 | 84 |
85 |
86 | ) 87 | } 88 | 89 | export default ScrapeModal 90 | -------------------------------------------------------------------------------- /resources/systems/gba/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/src/components/ConfirmationModal/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from 'jotai' 2 | import { Unsubscribe, createNanoEvents } from 'nanoevents' 3 | import Modal from '../Modal/Modal' 4 | import { useOnInput } from '@renderer/hooks' 5 | import { Input } from '@renderer/enums' 6 | import css from './ConfirmationModal.module.scss' 7 | import classNames from 'classnames' 8 | import { InputPriority } from '@renderer/const/inputPriorities' 9 | 10 | const openAtom = atom(false) 11 | const textAtom = atom(undefined) 12 | const isConfirmAtom = atom(false) 13 | 14 | const eventHandler = createNanoEvents() 15 | 16 | export interface Options { 17 | text?: string 18 | defaultToConfirmed?: boolean 19 | } 20 | 21 | export const useConfirmation = () => { 22 | const [, setOpen] = useAtom(openAtom) 23 | const [, setText] = useAtom(textAtom) 24 | const [, setIsConfirm] = useAtom(isConfirmAtom) 25 | 26 | return async ({ text, defaultToConfirmed = true }: Options): Promise => { 27 | setIsConfirm(defaultToConfirmed) 28 | setText(text) 29 | setOpen(true) 30 | 31 | let unbind: Unsubscribe 32 | const res = await new Promise((resolve) => { 33 | unbind = eventHandler.on('confirmation-response', (response: boolean) => { 34 | resolve(response) 35 | }) 36 | }) 37 | 38 | setOpen(false) 39 | unbind!() 40 | 41 | return res 42 | } 43 | } 44 | 45 | const ConfirmationModal = () => { 46 | const [open] = useAtom(openAtom) 47 | const [text] = useAtom(textAtom) 48 | const [isConfirm, setIsConfirm] = useAtom(isConfirmAtom) 49 | 50 | useOnInput( 51 | (input) => { 52 | switch (input) { 53 | case Input.A: 54 | eventHandler.emit('confirmation-response', isConfirm) 55 | break 56 | case Input.B: 57 | eventHandler.emit('confirmation-response', false) 58 | break 59 | case Input.LEFT: 60 | setIsConfirm(true) 61 | break 62 | case Input.RIGHT: 63 | setIsConfirm(false) 64 | break 65 | } 66 | }, 67 | { 68 | priority: InputPriority.INPUT_MODAL, 69 | disabled: !open, 70 | hints: [ 71 | { input: Input.B, text: "Cancel" }, 72 | { input: Input.A, text: "Select" } 73 | ] 74 | } 75 | ) 76 | 77 | return ( 78 | 79 |
80 |
{text}
81 |
82 |
Confirm
83 |
Cancel
84 |
85 |
86 |
87 | ) 88 | } 89 | 90 | export default ConfirmationModal 91 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useKeepVisible.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react' 2 | import { ScrollType } from '../enums' 3 | 4 | export interface OverrideParentBox { 5 | right: number 6 | left: number 7 | bottom: number 8 | top: number 9 | } 10 | 11 | export const useKeepVisible = ( 12 | ref: RefObject, 13 | padding: number = 0, 14 | scrollType: ScrollType = ScrollType.HORIZONTAL, 15 | active = true, 16 | overrideParentBox?: OverrideParentBox 17 | ) => { 18 | useEffect(() => { 19 | if (!ref.current?.parentElement || !active) return 20 | 21 | const elementBox = ref.current.getBoundingClientRect() 22 | const parentBox = 23 | (overrideParentBox as DOMRect) ?? ref.current.parentElement.getBoundingClientRect() 24 | 25 | switch (scrollType) { 26 | case ScrollType.HORIZONTAL: 27 | handleHorizontal(elementBox, parentBox, ref, padding) 28 | break 29 | case ScrollType.VERTICAL: 30 | handleVertical(elementBox, parentBox, ref, padding) 31 | break 32 | default: 33 | break 34 | } 35 | }, [ref.current, padding, scrollType, overrideParentBox]) 36 | } 37 | 38 | const handleHorizontal = ( 39 | elementBox: DOMRect, 40 | parentBox: DOMRect, 41 | elementRef: RefObject, 42 | padding: number 43 | ) => { 44 | if (!elementRef.current?.parentElement) return 45 | 46 | if (elementBox.right > parentBox.right) { 47 | elementRef.current.parentElement.scrollLeft = 48 | elementBox.right - parentBox.right + padding + elementRef.current.parentElement.scrollLeft 49 | } 50 | if (elementBox.left < parentBox.left) { 51 | elementRef.current.parentElement.scrollLeft = 52 | elementBox.left - parentBox.left + elementRef.current.parentElement.scrollLeft - padding 53 | } 54 | } 55 | 56 | const handleVertical = ( 57 | elementBox: DOMRect, 58 | parentBox: DOMRect, 59 | elementRef: RefObject, 60 | padding: number 61 | ) => { 62 | if (!elementRef.current?.parentElement) return 63 | 64 | elementRef.current.parentElement.scrollTop = 65 | elementBox.top - parentBox.top + elementRef.current.parentElement.scrollTop - padding 66 | 67 | // if (elementBox.bottom > parentBox.bottom) { 68 | // elementRef.current.parentElement.scrollTop = elementBox.bottom - parentBox.bottom + padding + elementRef.current.parentElement.scrollLeft; 69 | // } 70 | // if (elementBox.top < parentBox.top) { 71 | // elementRef.current.parentElement.scrollTop = elementBox.top - parentBox.top + elementRef.current.parentElement.scrollLeft - padding; 72 | // } 73 | 74 | // // if ((elementBox.bottom > parentBox.bottom) || (elementBox.top < parentBox.top)) { 75 | // elementRef.current.scrollIntoView({ 76 | // block: "start", 77 | // }); 78 | // return; 79 | // // } 80 | } 81 | -------------------------------------------------------------------------------- /src/renderer/src/components/Scrollers/Scrollers.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, useId, useRef, useState } from 'react' 2 | import { Scroller, ScrollerProps } from '../Scroller' 3 | import { useKeepVisible, useDeferredValue } from '@renderer/hooks' 4 | import { ScrollType } from '@renderer/enums' 5 | import { System, SystemWithGames } from '@common/types/System' 6 | import { Game } from '@common/types/Game' 7 | import { SetStateAction } from 'jotai' 8 | 9 | export type ScrollerConfig = Omit< 10 | ScrollerProps, 11 | 'isActive' | 'onPrevScroller' | 'onNextScroller' 12 | > & { id: string } 13 | interface Props { 14 | className?: string 15 | scrollers: Array | ScrollerConfig | ScrollerConfig> 16 | isDisabled?: boolean 17 | onExitUp?: () => void 18 | onExitDown?: () => void 19 | controlledIndex?: { 20 | index: number 21 | setIndex: Dispatch> 22 | }, 23 | id?: string 24 | } 25 | 26 | const Scrollers = ({ 27 | className, 28 | scrollers, 29 | isDisabled, 30 | onExitUp, 31 | onExitDown, 32 | controlledIndex, 33 | id: propsId 34 | }: Props) => { 35 | const [localActiveIndex, localSetActiveIndex] = useState(0) 36 | 37 | const instanceId = useId() 38 | const id = propsId ?? instanceId 39 | 40 | const activeIndex = controlledIndex?.index ?? localActiveIndex 41 | const setActiveIndex = controlledIndex?.setIndex ?? localSetActiveIndex 42 | const scrollBehavior = useDeferredValue('smooth', 'initial') 43 | 44 | const activeRef = useRef(null) 45 | 46 | const content = () => { 47 | const filteredScrollers = scrollers.filter((s) => s.elems.length) 48 | 49 | const onPrevScroller = () => { 50 | if (activeIndex === 0) return onExitUp?.() 51 | setActiveIndex((i) => Math.max(0, i - 1)) 52 | } 53 | const onNextScroller = () => { 54 | if (activeIndex === filteredScrollers.length - 1) return onExitDown?.() 55 | setActiveIndex((i) => Math.min(filteredScrollers.length - 1, i + 1)) 56 | } 57 | 58 | return filteredScrollers.map((scroller, i) => ( 59 | )} 61 | key={`${scroller.id}-${scroller.elems.length}`} 62 | isActive={activeIndex === i && !isDisabled} 63 | onPrevScroller={onPrevScroller} 64 | onNextScroller={onNextScroller} 65 | forwardedRef={activeIndex === i ? activeRef : undefined} 66 | style={i === filteredScrollers.length - 1 ? { marginBottom: '100vh' } : undefined} 67 | disableInput={isDisabled} 68 | id={`${id}-${scroller.id}`} 69 | /> 70 | )) 71 | } 72 | 73 | useKeepVisible(activeRef, 35, ScrollType.VERTICAL, true) 74 | 75 | return
{content()}
76 | } 77 | 78 | export default Scrollers 79 | -------------------------------------------------------------------------------- /src/preload/util/loadMedia.ts: -------------------------------------------------------------------------------- 1 | import mime from 'mime' 2 | import bufferToDataUrl from 'buffer-to-data-url' 3 | import { MediaImageData } from '@common/types/InternalMediaType' 4 | import { readFileSync } from 'fs' 5 | import { readFile } from 'fs/promises' 6 | 7 | import path from 'path' 8 | 9 | const localAssets = import.meta.glob<{ default: string }>('../../../resources/**/*', { 10 | eager: true 11 | }) 12 | 13 | let map: Record = {} 14 | 15 | const readLocalMedia = (mediaPath: string) => { 16 | try { 17 | const imgFile = readFileSync(mediaPath) 18 | const mimeType = mime.getType(mediaPath) 19 | 20 | if (!mimeType) throw "Couldn't get file MIME type!" 21 | 22 | const url = bufferToDataUrl(mimeType, imgFile) as unknown as string 23 | map[mediaPath] = url 24 | return url 25 | } catch (e) { 26 | return '' 27 | } 28 | } 29 | 30 | const readLocalMediaAsync = async (mediaPath: string) => { 31 | try { 32 | const imgFile = await readFile(mediaPath) 33 | const mimeType = mime.getType(mediaPath) 34 | 35 | if (!mimeType) throw "Couldn't get file MIME type!" 36 | 37 | const url = bufferToDataUrl(mimeType, imgFile) as unknown as string 38 | map[mediaPath] = url 39 | return url 40 | } catch (e) { 41 | return '' 42 | } 43 | } 44 | 45 | const getExternalPath = (mediaPath: string, mode: "sync" | "async") => { 46 | if (map[mediaPath]) return map[mediaPath] 47 | if ( 48 | mediaPath.includes('http://') || 49 | mediaPath.includes('https://') || 50 | mediaPath.includes('data:') 51 | ) 52 | return mediaPath 53 | return mode === "sync" ? readLocalMedia(mediaPath) : readLocalMediaAsync(mediaPath) 54 | } 55 | 56 | const loadMedia = (media: MediaImageData): string => { 57 | if (typeof media === 'string') { 58 | return getExternalPath(media, "sync") as string 59 | } 60 | 61 | // load from internal assets 62 | const dataUrl = Object.entries(localAssets).find(([key]) => { 63 | const extname = path.extname(key) 64 | return ( 65 | key === 66 | `../../../resources/${media.resourceCollection}/${media.resourceId}/${media.resourceType}${extname}` 67 | ) 68 | })?.[1]?.default 69 | 70 | return dataUrl ?? '' 71 | } 72 | 73 | export const loadMediaAsync = async (media: MediaImageData): Promise => { 74 | if (typeof media === 'string') { 75 | return getExternalPath(media, "async") as Promise 76 | } 77 | 78 | // load from internal assets 79 | const dataUrl = Object.entries(localAssets).find(([key]) => { 80 | const extname = path.extname(key) 81 | return ( 82 | key === 83 | `../../../resources/${media.resourceCollection}/${media.resourceId}/${media.resourceType}${extname}` 84 | ) 85 | })?.[1]?.default 86 | 87 | return dataUrl ?? '' 88 | } 89 | 90 | export const refreshImages = () => { 91 | map = {} 92 | } 93 | 94 | export default loadMedia 95 | -------------------------------------------------------------------------------- /src/renderer/src/components/CollectionModal/CollectionModal.tsx: -------------------------------------------------------------------------------- 1 | import { SetStateAction, useAtom } from 'jotai' 2 | import { Dispatch } from 'react' 3 | import Modal from '../Modal/Modal' 4 | import css from './CollectionModal.module.scss' 5 | import ControllerForm, { ControllerFormEntry } from '../ControllerForm/ControllerForm' 6 | import { useOnInput } from '@renderer/hooks' 7 | import { Input } from '@renderer/enums/Input' 8 | import { FaPlus } from 'react-icons/fa6' 9 | import { Game } from '@common/types' 10 | import collections from '@renderer/atoms/collections' 11 | import notifications from '@renderer/atoms/notifications' 12 | import { InputPriority } from '@renderer/const/inputPriorities' 13 | 14 | interface Props { 15 | open: boolean 16 | setOpen: Dispatch> 17 | game: Game 18 | } 19 | 20 | const CollectionModal = ({ open, setOpen, game }: Props) => { 21 | const [collectionsList] = useAtom(collections.lists.all) 22 | const [, addCollection] = useAtom(collections.add) 23 | const [, addGameToCollection] = useAtom(collections.addGame) 24 | const [, addNotification] = useAtom(notifications.add) 25 | 26 | useOnInput( 27 | (input) => { 28 | switch (input) { 29 | case Input.B: 30 | setOpen(false) 31 | } 32 | }, 33 | { 34 | disabled: !open, 35 | priority: InputPriority.GENERAL_MODAL 36 | } 37 | ) 38 | 39 | const entries: ControllerFormEntry[] = [ 40 | { 41 | id: 'new-collection', 42 | label: 'New Collection', 43 | type: 'input', 44 | Icon: FaPlus, 45 | colorScheme: 'confirm', 46 | onInput: (input) => { 47 | setOpen(false) 48 | addCollection({ name: input, games: game ? [game.id] : [] }) 49 | addNotification({ 50 | text: `Created "${input}" collection!`, 51 | type: 'success', 52 | timeout: 2 53 | }) 54 | } 55 | }, 56 | ...collectionsList 57 | .filter((collection) => !collection.games.includes(game?.id)) 58 | .map((collection) => ({ 59 | id: `collection-${collection.id}`, 60 | label: collection.name, 61 | type: 'action', 62 | onSelect: () => { 63 | addGameToCollection(collection.id, game.id) 64 | addNotification({ 65 | type: 'success', 66 | text: `Added ${game.name ?? game.romname} to "${collection.name}"!`, 67 | timeout: 2 68 | }) 69 | setOpen(false) 70 | } 71 | })) 72 | ] 73 | 74 | return ( 75 | 76 |
77 | 83 |
84 |
85 | ) 86 | } 87 | 88 | export default CollectionModal 89 | -------------------------------------------------------------------------------- /src/renderer/src/apiWrappers/IGDB.ts: -------------------------------------------------------------------------------- 1 | import { Game, GameMedia, System } from '@common/types' 2 | 3 | export class IGDB { 4 | private accessToken: string 5 | static clientId = import.meta.env.RENDERER_VITE_IGDB_CLIENT_ID 6 | static clientSecret = import.meta.env.RENDERER_VITE_IGDB_CLIENT_SECRET 7 | 8 | constructor(accessToken: string) { 9 | this.accessToken = accessToken 10 | } 11 | 12 | static async build() { 13 | const params = new URLSearchParams({ 14 | grant_type: 'client_credentials', 15 | client_id: IGDB.clientId, 16 | client_secret: IGDB.clientSecret 17 | }) 18 | 19 | const authRes = await fetch(`https://id.twitch.tv/oauth2/token?${params}`, { 20 | method: 'POST' 21 | }) 22 | 23 | const { access_token } = await authRes.json() 24 | return new IGDB(access_token) 25 | } 26 | 27 | async scrape(game: Game, system: System): Promise { 28 | const systemQuery = system.igdbId ? ` & release_dates.platform = (${system.igdbId})` : '' 29 | 30 | const query = ` 31 | search "${game.name}"; 32 | fields name, screenshots.image_id, artworks.image_id, genres.name, summary, involved_companies.*, involved_companies.company.name; 33 | where category=0${systemQuery}; 34 | limit 1; 35 | ` 36 | 37 | const authHeaders = { 38 | 'Client-ID': IGDB.clientId, 39 | Authorization: `Bearer ${this.accessToken}` 40 | } 41 | 42 | const res = await fetch('https://api.igdb.com/v4/games', { 43 | method: 'POST', 44 | headers: { 45 | Accept: 'application/json', 46 | ...authHeaders 47 | }, 48 | body: query 49 | }) 50 | 51 | const data = await res.json() 52 | const buildImageMediaReq = (id: string, mediaType: string): GameMedia | null => 53 | id 54 | ? { 55 | url: `https://images.igdb.com/igdb/image/upload/t_1080p/${id}.jpg`, 56 | format: 'jpg', 57 | mediaType 58 | } 59 | : null 60 | 61 | const gameResponse = data[0] 62 | const medias: GameMedia[] = [ 63 | buildImageMediaReq(gameResponse.screenshots?.[0]?.['image_id'], 'screenshot'), 64 | buildImageMediaReq(gameResponse.artworks?.[0]?.['image_id'], 'hero') 65 | ].filter((media): media is GameMedia => Boolean(media)) 66 | 67 | const gameWithMedia = await window.downloadGameMedia(game, medias, authHeaders) 68 | const metadata = [ 69 | { 70 | key: 'developer', 71 | value: gameResponse.involved_companies?.find((company) => company.developer)?.company?.name 72 | }, 73 | { 74 | key: 'publisher', 75 | value: gameResponse.involved_companies?.find((company) => company.publisher)?.company?.name 76 | }, 77 | { key: 'description', value: gameResponse.summary }, 78 | { key: 'genre', vaolue: gameResponse.genres?.[0]?.name } 79 | ] 80 | .filter((m) => m.value) 81 | .reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}) 82 | 83 | return { 84 | ...gameWithMedia, 85 | ...metadata 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | import { electronAPI } from '@electron-toolkit/preload' 3 | import configStorage, { writeDefaultConfig } from './util/configStorage' 4 | import path from 'path' 5 | import launchGame from './util/launchGame' 6 | import scanRoms from './util/scanRoms' 7 | import loadSystemStore from './util/loadSystemStore' 8 | import downloadGame from './util/downloadGame' 9 | import getRomFileInfo from './util/getRomFileInfo' 10 | import downloadGameMedia from './util/downloadGameMedia' 11 | import loadMedia, { loadMediaAsync } from './util/loadMedia' 12 | import removeGameFiles from './util/removeGameFiles' 13 | import os from 'os' 14 | import initRomDir from './util/initRomDir' 15 | import { hasFlatpak } from './util/systemHasFlatpak'; 16 | 17 | import { accessSync, constants as fsConstants } from 'fs' 18 | import { installEmulator } from './util/installEmulator' 19 | 20 | // Use `contextBridge` APIs to expose Electron APIs to 21 | // renderer only if context isolation is enabled, otherwise 22 | // just add to the DOM global. 23 | if (process.contextIsolated) { 24 | try { 25 | contextBridge.exposeInMainWorld('configStorage', configStorage) 26 | contextBridge.exposeInMainWorld('launchGame', launchGame) 27 | contextBridge.exposeInMainWorld('path', path), 28 | contextBridge.exposeInMainWorld('scanRoms', scanRoms) 29 | contextBridge.exposeInMainWorld('loadSystemStore', loadSystemStore) 30 | contextBridge.exposeInMainWorld('downloadGame', downloadGame) 31 | contextBridge.exposeInMainWorld('downloadGameMedia', downloadGameMedia) 32 | contextBridge.exposeInMainWorld('getRomFileInfo', getRomFileInfo) 33 | contextBridge.exposeInMainWorld('loadMedia', loadMedia) 34 | contextBridge.exposeInMainWorld('loadMediaAsync', loadMediaAsync) 35 | contextBridge.exposeInMainWorld('removeGameFiles', removeGameFiles) 36 | contextBridge.exposeInMainWorld('platform', os.platform()) 37 | contextBridge.exposeInMainWorld('homedir', os.homedir()) 38 | contextBridge.exposeInMainWorld('initRomDir', initRomDir) 39 | contextBridge.exposeInMainWorld('restart', () => { 40 | ipcRenderer.invoke('restart') 41 | }) 42 | contextBridge.exposeInMainWorld('quit', () => { 43 | ipcRenderer.invoke('quit') 44 | }) 45 | contextBridge.exposeInMainWorld('focusApp', () => { 46 | ipcRenderer.invoke('focusApp') 47 | }) 48 | contextBridge.exposeInMainWorld('checkDir', (dir: string) => { 49 | try { 50 | accessSync(dir, fsConstants.R_OK | fsConstants.W_OK) 51 | return true 52 | } catch { 53 | return false 54 | } 55 | }) 56 | contextBridge.exposeInMainWorld('hasFlatpak', hasFlatpak) 57 | contextBridge.exposeInMainWorld('installEmulator', installEmulator) 58 | contextBridge.exposeInMainWorld('writeDefaultConfig', writeDefaultConfig) 59 | } catch (error) { 60 | console.error(error) 61 | } 62 | } else { 63 | // @ts-ignore (define in dts) 64 | window.electron = electronAPI 65 | // @ts-ignore (define in dts) 66 | window.api = api 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/src/components/TabSelector/TabSelector.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useMemo, useState } from 'react' 3 | import css from './TabSelector.module.scss' 4 | import { ControllerHint, useOnInput } from '@renderer/hooks' 5 | import { Input } from '@renderer/enums' 6 | import { Game } from '@common/types/Game' 7 | 8 | export interface TabContentProps { 9 | className?: string 10 | onExitUp?: () => void 11 | isDisabled?: boolean 12 | game?: Game 13 | } 14 | 15 | interface Tab { 16 | id: string 17 | label: string 18 | Content?: (props: TabContentProps) => JSX.Element | null 19 | canSelect?: boolean 20 | game?: Game 21 | className?: string 22 | isInvalid?: boolean 23 | } 24 | 25 | interface Props { 26 | tabsClassName?: string 27 | contentInactiveClassName?: string 28 | tabs: Tab[] 29 | disabled?: boolean 30 | onExitUp?: () => void 31 | onExitDown?: () => void 32 | } 33 | 34 | const TabSelector = ({ tabsClassName, tabs: unfilteredTabs, disabled, onExitUp }: Props) => { 35 | const [activeTab, setActiveTab] = useState(0) 36 | const [activeSection, setActiveSection] = useState<'tabs' | 'content'>('tabs') 37 | 38 | const tabs = useMemo(() => { 39 | return unfilteredTabs.filter((tab) => !tab.isInvalid) 40 | }, [unfilteredTabs]) 41 | 42 | useOnInput( 43 | (input) => { 44 | switch (input) { 45 | case Input.LEFT: 46 | return setActiveTab((tab) => Math.max(tab - 1, 0)) 47 | case Input.RIGHT: 48 | return setActiveTab((tab) => Math.min(tab + 1, tabs.length - 1)) 49 | case Input.UP: 50 | return onExitUp?.() 51 | case Input.DOWN: 52 | case Input.A: 53 | if (tabs[activeTab].canSelect) setActiveSection('content') 54 | } 55 | }, 56 | { 57 | disabled: disabled || activeSection !== 'tabs', 58 | hints: [ 59 | tabs[activeTab].canSelect && { input: Input.DOWN, text: tabs[activeTab].label }, 60 | tabs.length > 1 && { input: "DPADLR", text: "Select Section" } 61 | ].filter(Boolean) as ControllerHint[] 62 | } 63 | ) 64 | 65 | const Content = tabs[activeTab].Content 66 | 67 | return ( 68 |
69 |
72 | {tabs.map((tab, i) => ( 73 |
74 | {tab.label} 75 |
76 | ))} 77 |
78 | {Content && ( 79 | { 83 | setActiveSection('tabs') 84 | }} 85 | className={classNames( 86 | tabs[activeTab].className, 87 | css.opacityTransition, 88 | tabs[activeTab].canSelect && activeSection !== 'content' && css.inactive 89 | )} 90 | /> 91 | )} 92 |
93 | ) 94 | } 95 | 96 | export default TabSelector 97 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Stores/AlphabetSelector/AlphabetSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, useMemo, useRef } from 'react' 2 | import css from './AlphabetSelector.module.scss' 3 | import classNames from 'classnames' 4 | import { SetStateAction } from 'jotai' 5 | import { useOnInput } from '@renderer/hooks' 6 | import { Input } from '@renderer/enums' 7 | import { useShrinkToFit } from '@renderer/hooks/useShrinkToFit' 8 | 9 | const baseAlphabet = [ 10 | { 11 | text: '0 - 9', 12 | matcher: (name: string) => /^[^a-zA-Z]/.test(name) 13 | }, 14 | ...[...'abcdefghijklmnopqrstuvwxyz'].map((letter) => ({ 15 | text: letter.toUpperCase(), 16 | matcher: (name: string) => name.toLowerCase().startsWith(letter) 17 | })) 18 | ] 19 | 20 | interface Props { 21 | entries: { 22 | label: string 23 | }[] 24 | activeIndex: number 25 | setActiveIndex: Dispatch> 26 | isActive: boolean 27 | inputPriority?: number 28 | } 29 | 30 | const AlphabetSelector = ({ 31 | entries, 32 | activeIndex, 33 | setActiveIndex, 34 | isActive, 35 | inputPriority 36 | }: Props) => { 37 | const alphaRef = useRef(null) 38 | 39 | const lookupMap = useMemo(() => { 40 | return baseAlphabet 41 | .map((alpha) => ({ 42 | index: entries.findIndex((entry) => alpha.matcher(entry.label)), 43 | alphabetEntry: alpha 44 | })) 45 | .filter((lookupEntry) => lookupEntry.index !== -1) 46 | }, [entries]) 47 | 48 | const activeLookupIndex = useMemo(() => { 49 | const index = lookupMap.findLastIndex((entry) => activeIndex >= entry.index) 50 | return index === -1 ? 0 : index 51 | }, [lookupMap, activeIndex]) 52 | 53 | useOnInput( 54 | (input) => { 55 | switch (input) { 56 | case Input.UP: { 57 | if (activeLookupIndex === 0) return 58 | const lookup = lookupMap[activeLookupIndex - 1] 59 | const newEntry = entries.findIndex((entry) => lookup.alphabetEntry.matcher(entry.label)) 60 | setActiveIndex(newEntry) 61 | break 62 | } 63 | case Input.DOWN: { 64 | if (activeLookupIndex === lookupMap.length - 1) return 65 | const lookup = lookupMap[activeLookupIndex + 1] 66 | const newEntry = entries.findIndex((entry) => lookup.alphabetEntry.matcher(entry.label)) 67 | setActiveIndex(newEntry) 68 | break 69 | } 70 | } 71 | }, 72 | { 73 | disabled: !isActive, 74 | priority: inputPriority 75 | } 76 | ) 77 | 78 | useShrinkToFit(alphaRef, 0.95) 79 | 80 | return ( 81 |
82 |
83 |
84 | {lookupMap.map((lookup, i) => ( 85 |
89 | {lookup.alphabetEntry.text} 90 |
91 | ))} 92 |
93 |
94 |
95 | ) 96 | } 97 | 98 | export default AlphabetSelector 99 | -------------------------------------------------------------------------------- /src/preload/util/downloadGame.ts: -------------------------------------------------------------------------------- 1 | import Downloader from 'nodejs-file-downloader' 2 | 3 | import { Game, System } from '@common/types' 4 | 5 | import path from 'path' 6 | import ShortUniqueId from 'short-unique-id' 7 | import { createReadStream } from 'fs' 8 | import { readdir, rm, rmdir, rename } from 'fs/promises' 9 | import unzipper from 'unzipper' 10 | import { AppConfig } from '@common/types/AppConfig' 11 | 12 | const uid = new ShortUniqueId() 13 | 14 | const downloadGame = async (system: System, url: string, paths: AppConfig['paths'], name?: string) => { 15 | const systemPath = system.romdir || path.join(paths.roms, system.id) 16 | 17 | const romname = decodeURIComponent(path.basename(url)) 18 | const defaultName = path 19 | .basename(romname, path.extname(romname)) 20 | .replace(/\([^\)]*\)/g, '') 21 | .trim() 22 | 23 | const id = uid.rnd() 24 | 25 | const downloader = new Downloader({ 26 | url, 27 | directory: systemPath, 28 | fileName: romname 29 | }) 30 | 31 | await downloader.download() 32 | 33 | // if we don't need to unzip, return early 34 | if (path.extname(romname) !== '.zip' || system.fileExtensions.includes('.zip')) { 35 | return { 36 | romname, 37 | name: name ?? defaultName, 38 | id, 39 | system: system.id, 40 | added: new Date().toUTCString() 41 | } as Game 42 | } 43 | 44 | // zip files don't scrape particularly well; unzip them 45 | const zipFilePath = path.join(systemPath, romname) 46 | const outputPath = path.join(systemPath, defaultName) 47 | 48 | await new Promise((resolve, reject) => { 49 | createReadStream(zipFilePath) 50 | .pipe( 51 | unzipper.Extract({ 52 | path: outputPath 53 | }) 54 | ) 55 | .on('close', () => { 56 | resolve() 57 | }) 58 | .on('error', (e) => { 59 | console.error(e) 60 | reject(e) 61 | }) 62 | }) 63 | 64 | const unzippedDir = await readdir(outputPath) 65 | const gameFile = unzippedDir.find((file) => { 66 | const extname = path.extname(file) 67 | return system.fileExtensions.includes(extname) && !file.match(/\((Track|Disc) [^1]\)/) // handle multi-part games by filtering out other tracks/discs 68 | }) 69 | 70 | if (!gameFile) throw 'No valid ROM found in downloaded zip file!' 71 | 72 | await rm(zipFilePath) 73 | 74 | let rompath: string[] | undefined = [defaultName] // path for romfile relative to system dir 75 | 76 | // if we only have one file, let's take it out of the subdir 77 | if (unzippedDir.length === 1) { 78 | const inSubDir = path.join(outputPath, gameFile) 79 | const inSystemDir = path.join(systemPath, gameFile) 80 | 81 | await rename(inSubDir, inSystemDir) 82 | await rmdir(outputPath) 83 | 84 | rompath = undefined // we no longer have a nested path to deal with 85 | } 86 | 87 | return { 88 | romname: gameFile, 89 | rompath, 90 | name: name ?? path 91 | .basename(gameFile, path.extname(gameFile)) 92 | .replace(/\([^\)]*\)/g, '') 93 | .trim(), 94 | id, 95 | system: system.id, 96 | added: new Date().toUTCString() 97 | } as Game 98 | } 99 | 100 | export default downloadGame 101 | -------------------------------------------------------------------------------- /src/renderer/src/components/ControllerHints/ControllerHints.tsx: -------------------------------------------------------------------------------- 1 | import { ControllerHint, inputSubscribersAtom } from "@renderer/hooks" 2 | import { useAtom } from "jotai" 3 | import css from "./ControllerHints.module.scss" 4 | import { motion } from "framer-motion"; 5 | import MediaImage from "../MediaImage/MediaImage"; 6 | import { Input } from "@renderer/enums"; 7 | import { hintsHeight } from "@renderer/const/const"; 8 | 9 | const sortOrder = [ 10 | Input.UP, 11 | Input.DOWN, 12 | Input.LEFT, 13 | Input.RIGHT, 14 | "DPAD", 15 | "DPADLR", 16 | "DPADUD", 17 | Input.B, 18 | Input.A, 19 | Input.X, 20 | Input.Y, 21 | Input.LB, 22 | Input.RB, 23 | Input.LT, 24 | Input.RT, 25 | Input.SELECT, 26 | Input.START, 27 | ] as const 28 | 29 | const ControllerHints = () => { 30 | const [subscribers] = useAtom(inputSubscribersAtom); 31 | const maxPriority = subscribers.reduce((acc, sub) => { 32 | return Math.max(sub.enforcePriority ? sub.priority : 0, acc) 33 | }, 0) 34 | 35 | let hints = (subscribers.flatMap(sub => 36 | (sub.priority >= maxPriority || sub.bypass) 37 | ? sub.hints 38 | : [] 39 | ) 40 | .filter(Boolean) as ControllerHint[]) 41 | .reduceRight((acc, hint) => { 42 | if (acc.some(accHint => accHint.input === hint.input)) return acc; 43 | return [...acc, hint] 44 | }, [] as ControllerHint[]) 45 | .toSorted((a, b) => sortOrder.indexOf(a.input) - sortOrder.indexOf(b.input)) as ControllerHint[] 46 | 47 | if (hints.length === 1 && hints[0].text === 'Settings Menu') { 48 | hints = [] 49 | } // workaround for flash between closing settings and being able to control content; don't like it but it'll do 50 | 51 | return ( 52 |
71 | 83 | {hints.map((hint, i) => ( 84 |
93 | 101 |
{hint.text}
102 |
103 | ))} 104 |
105 |
106 | ) 107 | } 108 | 109 | export default ControllerHints 110 | -------------------------------------------------------------------------------- /src/renderer/src/atoms/systems.ts: -------------------------------------------------------------------------------- 1 | import { Atom, atom } from 'jotai' 2 | import { atomFamily, loadable } from 'jotai/utils' 3 | import { arrayConfigAtoms } from './util/arrayConfigAtom' 4 | import deepEqual from 'fast-deep-equal' 5 | import games from './games' 6 | import { System, SystemWithGames, SystemStore } from '@common/types' 7 | import { sort } from 'fast-sort' 8 | import defaultSystems from './defaults/systems' 9 | 10 | export const mainAtoms = arrayConfigAtoms({ 11 | storageKey: 'systems', 12 | default: defaultSystems, 13 | splitUserEntries: { 14 | arrOverrides: { 15 | fileExtensions: "combine", 16 | emulators: "combine", 17 | stores: "mergeById", 18 | } 19 | } 20 | }) 21 | 22 | interface GetStoreParms { 23 | systemId: string 24 | storeId: string 25 | } 26 | 27 | const getStoreAtom = atomFamily( 28 | (params: GetStoreParms) => 29 | atom((get) => { 30 | const system = get(mainAtoms.single(params.systemId)) 31 | if (!system) throw `Tried to get store for undefined system ID: ${params.systemId}` 32 | 33 | const store = system.stores?.find((store) => store.id === params.storeId) 34 | if (!store) 35 | throw `Tried to get store for undefined store ID: ${params.storeId} (system: ${params.systemId})` 36 | 37 | return store 38 | }), 39 | deepEqual 40 | ) 41 | 42 | interface LoadStoreProps { 43 | storeData: SystemStore 44 | systemId: string 45 | } 46 | 47 | const loadStoreAtom = atomFamily( 48 | (props: LoadStoreProps) => 49 | loadable( 50 | atom(async (_) => { 51 | const contents = await window.loadSystemStore(props.storeData, props.systemId) 52 | return contents 53 | }) 54 | ), 55 | deepEqual 56 | ) 57 | 58 | const systemsWithStoresAtom = atom((get) => { 59 | const systems = get(mainAtoms.lists.all) 60 | return systems.filter((system) => system.stores?.length) 61 | }) 62 | 63 | const systemsWithGamesAtom = atom((get) => { 64 | const systems = get(mainAtoms.lists.all) 65 | return sort( 66 | systems.map((system) => { 67 | const gamesList = get(games.lists.system(system.id)) 68 | 69 | const randomGame = gamesList 70 | .filter((game) => game.screenshot || game.hero) 71 | .toSorted(() => Math.random() - 0.5)[0] 72 | 73 | const randomImg = randomGame?.showcaseDisplayType === "fanart" 74 | ? (randomGame?.hero ?? randomGame?.screenshot) 75 | : (randomGame?.screenshot ?? randomGame?.hero) 76 | 77 | return { 78 | ...system, 79 | screenshot: randomImg, 80 | games: gamesList 81 | } 82 | }) 83 | ).asc([(sys) => sys.company, (sys) => (sys.handheld ? 1 : 0), (sys) => sys.releaseYear]) 84 | }) as Atom 85 | 86 | const onlySystemsWithGamesAtom = atom((get) => { 87 | const systemsWithGames = get(systemsWithGamesAtom) 88 | return systemsWithGames.filter((system) => system.games.length) 89 | }) 90 | 91 | const systemWithGamesAtom = atomFamily((id: string) => 92 | atom((get) => { 93 | return get(systemsWithGamesAtom).find((system) => system.id === id) 94 | }) 95 | ) 96 | 97 | export default { 98 | ...mainAtoms, 99 | lists: { 100 | ...mainAtoms.lists, 101 | withStores: systemsWithStoresAtom, 102 | withGames: systemsWithGamesAtom, 103 | onlyWithGames: onlySystemsWithGamesAtom 104 | }, 105 | store: { 106 | get: getStoreAtom, 107 | load: loadStoreAtom 108 | }, 109 | withGames: systemWithGamesAtom 110 | } 111 | -------------------------------------------------------------------------------- /src/renderer/src/components/EmuNotFoundModal/EmuNotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Emulator } from "@common/types"; 2 | import SelectModal, { SelectModalOption } from "../SelectModal/SelectModal"; 3 | import { useEffect, useState } from "react"; 4 | import css from "./EmuNotFound.module.scss"; 5 | import { useAtom } from "jotai"; 6 | import notifications from "@renderer/atoms/notifications"; 7 | 8 | interface Props { 9 | open: boolean 10 | setOpen: (o: boolean) => void, 11 | emulator: Emulator 12 | } 13 | 14 | const EmuNotFound = ({ emulator, open, setOpen }: Props) => { 15 | const [selected, setSelected] = useState(0); 16 | const [, addNotification] = useAtom(notifications.add); 17 | const [, removeNotification] = useAtom(notifications.remove); 18 | 19 | useEffect(() => { 20 | if(open) return; 21 | setSelected(0); 22 | }, [open]); 23 | 24 | const isRA = "core" in emulator.location; 25 | 26 | const emuNameString = isRA 27 | ? `RetroArch core (${emulator.name})` 28 | : `emulator (${emulator.name})`; 29 | 30 | const canInstallFlatpak = window.platform === "linux" 31 | && "linux" in emulator.location 32 | && emulator.location.linux 33 | && "flatpak" in emulator.location.linux 34 | && window.hasFlatpak 35 | 36 | return ( 37 | 42 |

{emulator.name} not found!

43 |

Could not find the selected {emuNameString} on this system.

44 |
45 | } 46 | selected={selected} 47 | setSelected={setSelected} 48 | options={[ 49 | { 50 | id: 'close', 51 | label: 'Close', 52 | colorScheme: 'default' 53 | }, 54 | canInstallFlatpak && { 55 | id: 'install-flatpak', 56 | label: `Install ${emulator.name} Flatpak`, 57 | colorScheme: "confirm", 58 | } 59 | ].filter(Boolean) as SelectModalOption[]} 60 | onResponse={async (response) => { 61 | switch(response) { 62 | case "install-flatpak": { 63 | if(!canInstallFlatpak) break; 64 | if(!("linux" in emulator.location) 65 | || !emulator.location.linux 66 | || !("flatpak" in emulator.location.linux) 67 | || !emulator.location.linux.flatpak 68 | ) { 69 | break 70 | }; 71 | 72 | const notifId = `install-emu-${emulator.id}` 73 | addNotification({ 74 | id: notifId, 75 | text: `Installing ${emulator.name}: ${emulator.location.linux.flatpak}`, 76 | type: "download", 77 | timeout: 0 78 | }); 79 | setOpen(false); 80 | 81 | try { 82 | await window.installEmulator(emulator, "flatpak"); 83 | addNotification({ 84 | text: `Done installing ${emulator.name}!`, 85 | type: "success" 86 | }) 87 | } catch(e) { 88 | console.error(e) 89 | addNotification({ 90 | text: `Failed to install ${emulator.name}`, 91 | type: "error" 92 | }) 93 | } finally { 94 | removeNotification(notifId) 95 | } 96 | } 97 | default: { 98 | setOpen(false) 99 | } 100 | } 101 | }} 102 | /> 103 | ) 104 | } 105 | 106 | export default EmuNotFound 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EmuHub", 3 | "version": "1.0.0-beta.2", 4 | "description": "A games-first hub for emulation.", 5 | "main": "./out/main/index.js", 6 | "author": "ryandavidmercado.github.com", 7 | "homepage": "", 8 | "scripts": { 9 | "format": "prettier --write .", 10 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 11 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 12 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 13 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 14 | "start": "electron-vite preview", 15 | "dev": "electron-vite dev --watch", 16 | "build": "npm run typecheck && electron-vite build", 17 | "postinstall": "electron-builder install-app-deps", 18 | "build:win": "npm run build && electron-builder --win --config", 19 | "build:mac": "electron-vite build && electron-builder --mac --config", 20 | "build:linux": "electron-vite build && electron-builder --linux --config" 21 | }, 22 | "dependencies": { 23 | "@electron-toolkit/preload": "^2.0.0", 24 | "@electron-toolkit/utils": "^2.0.0", 25 | "@fontsource-variable/figtree": "^5.0.19", 26 | "@leeoniya/ufuzzy": "^1.0.14", 27 | "@types/react-window": "^1.8.8", 28 | "axios": "^1.6.5", 29 | "buffer-to-data-url": "^1.0.0", 30 | "change-case": "^5.4.2", 31 | "classnames": "^2.5.1", 32 | "crc": "^4.3.2", 33 | "date-fns": "^3.2.0", 34 | "deepmerge": "^4.3.1", 35 | "electron-log": "^5.1.1", 36 | "electron-updater": "^6.1.1", 37 | "fast-deep-equal": "^3.1.3", 38 | "fast-sort": "^3.4.0", 39 | "fetch-retry": "^5.0.6", 40 | "framer-motion": "^9.0.2", 41 | "fuse.js": "^7.0.0", 42 | "immer": "^10.0.3", 43 | "jotai": "^2.6.1", 44 | "jotai-immer": "^0.2.0", 45 | "jotai-location": "^0.5.2", 46 | "lodash": "^4.17.21", 47 | "mime": "^4.0.1", 48 | "nanoevents": "^9.0.0", 49 | "node-html-parser": "^6.1.12", 50 | "nodejs-file-downloader": "^4.12.1", 51 | "react-flip-toolkit": "^7.1.0", 52 | "react-icons": "^4.12.0", 53 | "react-loading": "^2.0.3", 54 | "react-router-dom": "^6.21.2", 55 | "react-simple-keyboard": "^3.7.70", 56 | "react-spinners": "^0.13.8", 57 | "react-transition-group": "^4.4.5", 58 | "react-use-ref-effect": "^1.2.0", 59 | "react-virtualized-auto-sizer": "^1.0.20", 60 | "react-virtuoso": "^4.6.3", 61 | "react-wavify": "^1.10.0", 62 | "react-window": "^1.8.10", 63 | "sharp": "^0.33.2", 64 | "short-unique-id": "^5.0.3", 65 | "simple-keyboard-key-navigation": "^2.4.246", 66 | "unzipper": "^0.10.14", 67 | "use-query-params": "^2.2.1", 68 | "yaml": "^2.3.4" 69 | }, 70 | "devDependencies": { 71 | "@electron-toolkit/eslint-config-prettier": "^1.0.1", 72 | "@electron-toolkit/eslint-config-ts": "^1.0.0", 73 | "@electron-toolkit/tsconfig": "^1.0.1", 74 | "@types/download": "^8.0.5", 75 | "@types/lodash": "^4.14.202", 76 | "@types/node": "^18.17.5", 77 | "@types/react": "^18.2.20", 78 | "@types/react-dom": "^18.2.7", 79 | "@types/unzipper": "^0.10.9", 80 | "@vitejs/plugin-react": "^4.0.4", 81 | "electron": "^25.6.0", 82 | "electron-builder": "^24.6.3", 83 | "electron-devtools-installer": "^3.2.0", 84 | "electron-vite": "^1.0.27", 85 | "eslint": "^8.47.0", 86 | "eslint-plugin-react": "^7.33.2", 87 | "patch-package": "^8.0.0", 88 | "prettier": "^3.0.2", 89 | "react": "^18.2.0", 90 | "react-dom": "^18.2.0", 91 | "sass": "^1.69.6", 92 | "typescript": "^5.1.6", 93 | "vite": "^4.5.2" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/preload/util/launchGame.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Game, Emulator, System } from '@common/types' 3 | import { spawn } from 'child_process' 4 | import { loadConfig } from './configStorage' 5 | import { readFileSync } from 'fs' 6 | import findEmulator from './findEmulator' 7 | import { AppConfig } from '@common/types/AppConfig' 8 | import { raEmulatorEntry } from '@common/features/RetroArch' 9 | import log from 'electron-log/renderer' 10 | 11 | const parseLaunchCommand = ( 12 | command: string, 13 | emulatorLocation: string, 14 | romLocation: string 15 | ) => { 16 | const templateMap = { 17 | /* NOTE: Emulator path for RetroArch cores pre-includes the core launch command 18 | ex: "/path/to/RetroArch.AppImage" -L core_to_launch */ 19 | '%EMUPATH%': () => emulatorLocation, // ex: "/home/Applications/CoolEmu.AppImage" 20 | '%ROMPATH%': () => romLocation, // ex: /path/to/roms/someSystem/someParentDir/myRom.rom 21 | '%ROMDIR%': () => path.dirname(romLocation), // ex: /path/to/roms/someSystem/someParentDir 22 | '%ROMDIRNAME%': () => path.basename(path.dirname(romLocation)), // ex: someParentDir 23 | '%ROMNAME%': () => path.parse(romLocation).base, // ex: myRom.rom 24 | '%ROMNAMENOEXT%': () => path.parse(romLocation).name, // ex: myRom 25 | '%ROMEXT%': () => path.parse(romLocation).ext, // .rom 26 | '%ROMTEXTCONTENT%': () => readFileSync(romLocation, { encoding: 'utf8' }).trim() // ex: rom is text file with contents "Hello" -> this returns "Hello" 27 | } as const 28 | 29 | const injectTemplateValues = (command: string) => Object.keys(templateMap).reduce((command, templateKey) => { 30 | if (!command.includes(templateKey)) return command 31 | return command.replaceAll(templateKey, templateMap[templateKey]()) 32 | }, command) 33 | 34 | return injectTemplateValues(command) 35 | } 36 | 37 | const launchGame = async (game: Game, emulator: Emulator, system: System) => { 38 | const emulatorLocation = await findEmulator(emulator); 39 | 40 | const { paths: { roms: romPath } } = loadConfig( 41 | 'config', 42 | {} /* we don't need to supply a default; jotai initializes this config on boot */ 43 | ) as AppConfig 44 | 45 | const systemDir = system.romdir ?? path.join(romPath, system.id) 46 | const romLocation = path.join(systemDir, ...(game.rompath ?? []), game.romname) 47 | 48 | const args = [ 49 | ...('core' in emulator.location ? (raEmulatorEntry.args ?? []) : []), 50 | ...(emulator.args ?? []) 51 | ].join(" ") 52 | 53 | const defaultLaunchCommand = `%EMUPATH% ${args} "%ROMPATH%"` 54 | const launchCommand = emulator.launchCommands?.[path.extname(game.romname)] 55 | ?? emulator.launchCommand 56 | ?? defaultLaunchCommand 57 | 58 | const parsedCommand = parseLaunchCommand(launchCommand, emulatorLocation, romLocation) 59 | 60 | log.info(`Launching ${game.name} with command: ${parsedCommand}`) 61 | 62 | const spawnedProcess = spawn(parsedCommand, [], { detached: true, shell: true, windowsHide: true }) 63 | 64 | const execInstance = new Promise((resolve, reject) => { 65 | spawnedProcess.on('close', (status) => { 66 | if(status !== 1) resolve(); 67 | 68 | if ('core' in emulator.location) { 69 | reject({ type: 'emu-not-found', data: emulator }) 70 | } 71 | }) 72 | 73 | spawnedProcess.on('error', (e) => { 74 | reject(e) 75 | }) 76 | }) 77 | 78 | const abort = () => { 79 | if(!spawnedProcess.pid) return; 80 | process.kill(-spawnedProcess.pid, emulator.killSignal || 'SIGTERM') 81 | } 82 | 83 | return { 84 | execInstance, 85 | abort, 86 | game 87 | } 88 | } 89 | 90 | export default launchGame 91 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useOnInput.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useId } from 'react' 2 | import { Input } from '../enums' 3 | import gamepadReader from './util/gamepadReader' 4 | import { atom } from 'jotai' 5 | import { jotaiStore } from '@renderer/atoms/store/store' 6 | import { InputLabel } from '@common/types/Input' 7 | 8 | type Callback = (input: Input) => void 9 | 10 | const kbKeyMap: Partial> = { 11 | ArrowLeft: Input.LEFT, 12 | ArrowRight: Input.RIGHT, 13 | ArrowUp: Input.UP, 14 | ArrowDown: Input.DOWN, 15 | a: Input.LEFT, 16 | d: Input.RIGHT, 17 | w: Input.UP, 18 | s: Input.DOWN, 19 | Enter: Input.A, 20 | Escape: Input.B, 21 | Backspace: Input.START, 22 | Tab: Input.SELECT 23 | } 24 | 25 | export interface ControllerHint { 26 | input: InputLabel 27 | text: string 28 | } 29 | 30 | interface Subscriber { 31 | id: string 32 | priority: number 33 | bypass: boolean 34 | cb: Callback 35 | enforcePriority: boolean 36 | disableForDevice?: 'keyboard' | 'gamepad' 37 | hints?: ControllerHint[] 38 | } 39 | 40 | export const controllerHintsAtom = atom([]) 41 | export const inputSubscribersAtom = atom([]); 42 | 43 | const passInputToSubscribers = (input: Input, source: 'keyboard' | 'gamepad') => { 44 | const subscribers = jotaiStore.get(inputSubscribersAtom); 45 | 46 | const maxPriority = subscribers.reduce((acc, sub) => { 47 | return Math.max(sub.enforcePriority ? sub.priority : 0, acc) 48 | }, 0) 49 | 50 | for (const subscriber of subscribers) { 51 | if (subscriber.priority < maxPriority && !subscriber.bypass) continue 52 | if (source === subscriber.disableForDevice) continue 53 | subscriber.cb(input) 54 | } 55 | } 56 | 57 | gamepadReader(passInputToSubscribers) 58 | 59 | document.addEventListener('keydown', (e) => { 60 | const input = kbKeyMap[e.key] 61 | if (!input) return 62 | 63 | passInputToSubscribers(input, 'keyboard') 64 | }) 65 | 66 | interface PrioritySettings { 67 | priority?: number 68 | bypass?: boolean 69 | disabled?: boolean 70 | enforcePriority?: boolean 71 | disableForDevice?: 'keyboard' | 'gamepad' 72 | hints?: ControllerHint[] 73 | } 74 | 75 | export const useOnInput = (cb: Callback, prioritySettings?: PrioritySettings) => { 76 | const { 77 | priority = 0, 78 | disabled = false, 79 | bypass = false, 80 | enforcePriority = true, 81 | disableForDevice 82 | } = prioritySettings ?? {} 83 | 84 | const id = useId() 85 | 86 | useEffect(() => { 87 | // we create a dependency loop if we get our setters from the useAtom hook 88 | // we bypass this by talking to the store directly 89 | jotaiStore.set(inputSubscribersAtom, (subscribers) => { 90 | if(disabled) return subscribers.filter((sub) => sub.id !== id) 91 | 92 | const currentSubscriber = subscribers.find((sub) => sub.id === id) 93 | if(!currentSubscriber) return [ 94 | ...subscribers, 95 | { 96 | priority, 97 | cb, 98 | id, 99 | bypass, 100 | enforcePriority, 101 | disableForDevice, 102 | hints: prioritySettings?.hints 103 | } 104 | ]; 105 | 106 | return subscribers.map(subscriber => { 107 | if(subscriber.id !== id) return subscriber; 108 | return { ...subscriber, priority, cb, hints: prioritySettings?.hints } 109 | }) 110 | }) 111 | 112 | return () => { 113 | jotaiStore.set(inputSubscribersAtom, (subscribers => subscribers.filter((sub) => sub.id !== id))) 114 | } 115 | }, [cb, priority, disabled, prioritySettings?.hints]) 116 | } 117 | -------------------------------------------------------------------------------- /src/preload/util/scanRoms.ts: -------------------------------------------------------------------------------- 1 | import { Game, System } from '@common/types' 2 | import path from 'path' 3 | import ShortUniqueId from 'short-unique-id' 4 | import { readdir, stat } from 'fs/promises' 5 | import { nameMappers } from '@common/features/nameMapping' 6 | import { AppConfig } from '@common/types' 7 | 8 | const uid = new ShortUniqueId() 9 | 10 | const scanRoms = async (paths: AppConfig['paths'], currentSystems: System[], currentGames: Game[]) => { 11 | const getGameLookupKey = (romname: string, systemId: string, rompath: string[] = []) => 12 | `${romname}-${systemId}-${rompath.join('_')}` 13 | 14 | const gameLookupMap: Record = currentGames.reduce((acc, game) => ({ 15 | ...acc, 16 | [getGameLookupKey(game.romname, game.system, game.rompath)]: game 17 | }), {}) 18 | 19 | const { roms: romPath } = paths 20 | 21 | const addedDate = new Date().toUTCString() 22 | const newGames: Game[] = [] 23 | const romsDir = await readdir(romPath) 24 | 25 | const scanFolder = async (systemConfig: System, pathTokens: string[] = []) => { 26 | const systemRomDir = systemConfig.romdir || path.join(romPath, systemConfig.id) 27 | 28 | const dir = path.join(systemRomDir, ...pathTokens) 29 | let contents: string[] 30 | 31 | try { 32 | contents = await readdir(dir) 33 | } catch (e) { 34 | console.error(`Failed to read ${systemConfig.name} directory at "${dir}"`) 35 | return 36 | } 37 | 38 | // handle multi-part games by filtering out other tracks/discs 39 | contents = contents.filter((entry) => !entry.match(/\((Track|Disc) [^1]\)/)) 40 | if (!contents.length) return 41 | if (contents.includes('.eh-ignore')) return 42 | 43 | const extnames = systemConfig.fileExtensions 44 | 45 | for (const entry of contents) { 46 | const entryPath = path.join(dir, entry) 47 | const entryExt = path.extname(entry) 48 | const entryStat = await stat(entryPath) 49 | 50 | const isValidExt = extnames.includes(entryExt.toLowerCase()) 51 | 52 | if (entryStat.isDirectory() && !isValidExt) { 53 | await scanFolder(systemConfig, [...pathTokens, entry]) 54 | continue 55 | } 56 | 57 | if (!isValidExt) continue 58 | 59 | const lookupKey = getGameLookupKey(entry, systemConfig.id, pathTokens) 60 | const gameConfigEntry = gameLookupMap[lookupKey] 61 | 62 | if (gameConfigEntry) { 63 | newGames.push(gameConfigEntry) 64 | continue 65 | } 66 | 67 | const name = (() => { 68 | const defaultName = path.basename(entry, entryExt) 69 | 70 | const nameConfig = systemConfig.defaultNames?.[entryExt.toLowerCase()] 71 | if (!nameConfig) return defaultName 72 | 73 | let name: string 74 | switch (nameConfig.type) { 75 | case 'pathToken': 76 | name = pathTokens.at(nameConfig.token) ?? defaultName 77 | } 78 | 79 | if (nameConfig.map) name = nameMappers[nameConfig.map](name) 80 | return name 81 | })() 82 | 83 | newGames.push({ 84 | id: uid.rnd(), 85 | rompath: pathTokens.length ? pathTokens : undefined, 86 | romname: entry, 87 | system: systemConfig.id, 88 | name, 89 | added: addedDate 90 | }) 91 | } 92 | } 93 | 94 | const scanQueue: Promise[] = [] 95 | for (const system of romsDir) { 96 | const systemConfig = currentSystems.find((config) => config.id === system) 97 | if (!systemConfig) continue 98 | 99 | scanQueue.push(scanFolder(systemConfig)) 100 | } 101 | 102 | await Promise.allSettled(scanQueue) 103 | return newGames 104 | } 105 | 106 | export default scanRoms 107 | -------------------------------------------------------------------------------- /src/renderer/src/components/Settings/Collections/Collections.tsx: -------------------------------------------------------------------------------- 1 | import collections from '@renderer/atoms/collections' 2 | import MultiPageControllerForm, { 3 | MultiFormPage 4 | } from '@renderer/components/ControllerForm/MultiPage' 5 | import { useAtom, useSetAtom } from 'jotai' 6 | import { SectionProps } from '..' 7 | import { useMemo } from 'react' 8 | import { FaMinus } from 'react-icons/fa' 9 | import { IoTrash } from 'react-icons/io5' 10 | import { HiPlus } from 'react-icons/hi' 11 | 12 | const Collections = ({ isActive, onExit, inputPriority }: SectionProps) => { 13 | const [collectionsList] = useAtom(collections.lists.all) 14 | const [getCollection] = useAtom(collections.single.curriedWithGames) 15 | 16 | const addCollection = useSetAtom(collections.add) 17 | 18 | const updateCollection = useSetAtom(collections.curriedSingle) 19 | const removeGameFromCollection = useSetAtom(collections.removeGame) 20 | const deleteCollection = useSetAtom(collections.remove) 21 | 22 | const pages = useMemo( 23 | () => [ 24 | { 25 | id: 'collection', 26 | entries: [ 27 | { 28 | id: 'new-collection', 29 | label: 'New Collection', 30 | type: 'input', 31 | colorScheme: 'confirm', 32 | Icon: HiPlus, 33 | onInput: (input) => { 34 | addCollection({ 35 | name: input, 36 | games: [] 37 | }) 38 | } 39 | }, 40 | ...collectionsList.map( 41 | (collection) => 42 | ({ 43 | id: collection.id, 44 | label: collection.name, 45 | type: 'navigate' 46 | }) as const 47 | ) 48 | ] 49 | }, 50 | { 51 | id: 'game', 52 | entries: ({ collection }: { collection: string }) => { 53 | const collectionData = getCollection(collection) 54 | if (!collectionData) return [] 55 | 56 | return [ 57 | { 58 | id: `rename-${collectionData.id}`, 59 | label: `Rename "${collectionData.name}"`, 60 | type: 'input', 61 | defaultValue: collectionData.name, 62 | onInput: (newName: string) => { updateCollection({ id: collection, name: newName }) } 63 | }, 64 | { 65 | id: `remove-${collectionData.id}`, 66 | label: "Delete Collection", 67 | type: 'navigate', 68 | navigateTo: -1, 69 | Icon: IoTrash, 70 | colorScheme: 'caution', 71 | onSelect: () => { 72 | deleteCollection(collection) 73 | } 74 | } as const, 75 | ...collectionData.games.map( 76 | (game) => 77 | ({ 78 | id: game.id, 79 | label: game.name ?? game.romname, 80 | sublabel: `Remove ${game.name ?? game.romname} from "${collectionData.name}"`, 81 | type: 'action', 82 | Icon: FaMinus, 83 | onSelect: (gameId: string) => { 84 | removeGameFromCollection(collection, gameId) 85 | } 86 | }) as const 87 | ) 88 | ] 89 | } 90 | } 91 | ], 92 | [collectionsList] 93 | ) 94 | 95 | return ( 96 | 103 | ) 104 | } 105 | 106 | export default Collections 107 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, shell, BrowserWindow, ipcMain } from 'electron' 2 | import { join } from 'path' 3 | import { electronApp, optimizer, is } from '@electron-toolkit/utils' 4 | import icon from '../../resources/icon.png?asset' 5 | 6 | import log from 'electron-log/main' 7 | log.initialize() 8 | 9 | function createWindow(): void { 10 | // Create the browser window. 11 | const mainWindow = new BrowserWindow({ 12 | show: false, 13 | autoHideMenuBar: true, 14 | ...(process.platform === 'linux' ? { icon } : {}), 15 | webPreferences: { 16 | preload: join(__dirname, '../preload/index.js'), 17 | sandbox: false, 18 | webSecurity: false 19 | }, 20 | fullscreen: app.isPackaged, 21 | backgroundColor: 'hsl(200, 15%, 20%)' 22 | }) 23 | 24 | ipcMain.handle('restart', () => { 25 | app.relaunch() 26 | app.quit() 27 | }) 28 | 29 | ipcMain.handle('quit', () => { 30 | app.quit() 31 | }) 32 | 33 | ipcMain.handle('focusApp', () => { 34 | app.focus({ steal: true }) 35 | }) 36 | 37 | mainWindow.on('ready-to-show', () => { 38 | mainWindow.maximize() 39 | mainWindow.show() 40 | }) 41 | 42 | mainWindow.webContents.setWindowOpenHandler((details) => { 43 | shell.openExternal(details.url) 44 | return { action: 'deny' } 45 | }) 46 | 47 | mainWindow.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { 48 | callback({ requestHeaders: { Origin: '*', ...details.requestHeaders } }) 49 | }) 50 | 51 | mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { 52 | if (!details.responseHeaders) return callback({}) 53 | 54 | for (const header of Object.keys(details.responseHeaders)) { 55 | if (header.toLowerCase() === 'access-control-allow-origin') 56 | delete details.responseHeaders[header] 57 | } 58 | 59 | callback({ 60 | responseHeaders: { 61 | ...details.responseHeaders, 62 | 'Access-Control-Allow-Origin': ['*'] 63 | } 64 | }) 65 | }) 66 | 67 | // HMR for renderer base on electron-vite cli. 68 | // Load the remote URL for development or the local html file for production. 69 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 70 | console.log(process.env['ELECTRON_RENDERER_URL']) 71 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 72 | } else { 73 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 74 | } 75 | } 76 | 77 | // This method will be called when Electron has finished 78 | // initialization and is ready to create browser windows. 79 | // Some APIs can only be used after this event occurs. 80 | app.whenReady().then(() => { 81 | // Set app user model id for windows 82 | electronApp.setAppUserModelId('com.electron') 83 | 84 | // Default open or close DevTools by F12 in development 85 | // and ignore CommandOrControl + R in production. 86 | // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils 87 | app.on('browser-window-created', (_, window) => { 88 | optimizer.watchWindowShortcuts(window) 89 | }) 90 | 91 | createWindow() 92 | 93 | app.on('activate', function () { 94 | // On macOS it's common to re-create a window in the app when the 95 | // dock icon is clicked and there are no other windows open. 96 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 97 | }) 98 | }) 99 | 100 | // Quit when all windows are closed, except on macOS. There, it's common 101 | // for applications and their menu bar to stay active until the user quits 102 | // explicitly with Cmd + Q. 103 | app.on('window-all-closed', () => { 104 | // if (process.platform !== 'darwin') { 105 | app.quit() 106 | // } 107 | }) 108 | 109 | // In this file you can include the rest of your app"s specific main process 110 | // code. You can also put them in separate files and require them here. 111 | -------------------------------------------------------------------------------- /src/renderer/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/src/components/IconButtons/index.tsx: -------------------------------------------------------------------------------- 1 | import { useOnInput } from '@renderer/hooks' 2 | import { IconType } from 'react-icons' 3 | import css from './IconButtons.module.scss' 4 | import { useMemo, useState } from 'react' 5 | import { Input } from '@renderer/enums' 6 | import classNames from 'classnames' 7 | import Label from '../Label/Label' 8 | import { AnimatePresence, motion } from 'framer-motion' 9 | import { ColorScheme } from '../ControllerForm/ControllerForm' 10 | 11 | export interface IconButtonConfig { 12 | id: string 13 | Icon?: IconType 14 | IconActive?: IconType 15 | label?: string 16 | onHighlight?: () => void 17 | onSelect?: () => void 18 | className?: string 19 | iconClassName?: string 20 | disabled?: boolean 21 | colorScheme?: ColorScheme 22 | } 23 | 24 | interface Props { 25 | className?: string 26 | buttons: IconButtonConfig[] 27 | isActive?: boolean 28 | onExitDown?: () => void 29 | } 30 | 31 | const IconButtons = ({ className, buttons, isActive, onExitDown }: Props) => { 32 | const [index, setIndex] = useState(0) 33 | useOnInput( 34 | (input) => { 35 | switch (input) { 36 | case Input.LEFT: 37 | return setIndex((i) => Math.max(0, i - 1)) 38 | case Input.RIGHT: 39 | return setIndex((i) => Math.min(buttons.length - 1, i + 1)) 40 | case Input.DOWN: 41 | onExitDown?.() 42 | } 43 | }, 44 | { 45 | disabled: !isActive, 46 | hints: [ 47 | { input: Input.A, text: 'Select' } 48 | ] 49 | } 50 | ) 51 | 52 | const buttonElements = buttons.map((button, i) => ( 53 |