diff --git a/cores/cores.json b/cores/cores.json index 32273d2..bad393b 100644 --- a/cores/cores.json +++ b/cores/cores.json @@ -1,83 +1,34 @@ { - "2048": { - "name": "2048", - "repository": "libretro-2048", - "makefile": "Makefile.libretro", - "systems": [ - { - "name": "Get to the 2048 tile!", - "standalone": true - } - ] - }, "gambatte": { "name": "Gambatte", "repository": "gambatte-libretro", - "systems": [ - { - "name": "Game Boy", - "extension": "gb" - }, - { - "name": "Game Boy Color", - "extension": "gbc" - } - ] + "systems": ["Game Boy", "Game Boy Color"] }, "vbam": { "name": "VBA-M", "repository": "vbam-libretro", "directory": "src/libretro", - "systems": [ - { - "name": "Game Boy Advance", - "extension": "gba" - } - ] + "systems": ["Game Boy Advance"] }, "desmume": { "name": "DeSmuME", "directory": "desmume/src/frontend/libretro", - "systems": [ - { - "name": "Nintendo DS", - "extension": "nds" - } - ] + "systems": ["Nintendo DS"] }, "nestopia": { "name": "Nestopia", "directory": "libretro", - "systems": [ - { - "name": "NES", - "extension": "nes" - } - ] + "systems": ["NES"] }, "snes9x": { "name": "Snes9x", "directory": "libretro", - "systems": [ - { - "name": "SNES", - "extension": "smc" - } - ] + "systems": ["SNES"] }, "genesis": { "name": "Genesis Plus GX", "repository": "Genesis-Plus-GX", "makefile": "Makefile.libretro", - "systems": [ - { - "name": "Master System", - "extension": "sms" - }, - { - "name": "Mega Drive", - "extension": "bin" - } - ] + "systems": ["Master System", "Mega Drive"] } } diff --git a/cores/lib2048.a b/cores/lib2048.a deleted file mode 100644 index c718b81..0000000 Binary files a/cores/lib2048.a and /dev/null differ diff --git a/ui/sources/entities/game.js b/ui/sources/entities/game.js index acd085f..0d8788e 100644 --- a/ui/sources/entities/game.js +++ b/ui/sources/entities/game.js @@ -1,3 +1,4 @@ +import Path from "../services/path"; import { System } from "./system"; export class Game { @@ -16,12 +17,12 @@ export class Game { /** * @param {System} system * @param {string} rom + * @param {boolean} installed */ - constructor(system, rom) { + constructor(system, rom, installed) { this.system = system.name; this.rom = rom; - this.name = rom.lastIndexOf('.') != -1 - ? rom.substring(0, rom.lastIndexOf('.')).replaceAll(/ \(.*\).*/g, '') - : rom; + this.installed = installed; + this.name = Path.name(rom); } } diff --git a/ui/sources/entities/save.js b/ui/sources/entities/save.js index bc26d90..b6b35c3 100644 --- a/ui/sources/entities/save.js +++ b/ui/sources/entities/save.js @@ -32,10 +32,6 @@ export class Save { if (!system || !system.games) return false; - const game = system.games.find(game => game.rom == `${this.game}.${system.extension}`); - if (!game) - return system.standalone; - - return true; + return system.games.find(game => game.name == this.game) != null; } } diff --git a/ui/sources/entities/system.js b/ui/sources/entities/system.js index 85c063f..5267c39 100644 --- a/ui/sources/entities/system.js +++ b/ui/sources/entities/system.js @@ -10,12 +10,6 @@ export class System { /** @type {string} */ core_name; - /** @type {string} */ - extension; - - /** @type {boolean} */ - standalone; - /** @type {Game[]} */ games; } diff --git a/ui/sources/index.jsx b/ui/sources/index.jsx index 6e714f3..f5ee6cb 100644 --- a/ui/sources/index.jsx +++ b/ui/sources/index.jsx @@ -4,9 +4,8 @@ import { createRoot } from 'react-dom/client'; import { Redirect, Route } from 'react-router'; import { IonReactMemoryRouter } from '@ionic/react-router'; import { IonApp, IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs, setupIonicReact, useIonLoading } from '@ionic/react'; -import { cloudDownload, gameController, keyOutline, save } from 'ionicons/icons'; +import { gameController, keyOutline, save } from 'ionicons/icons'; import { HomePage } from './pages/home-page'; -import { InstallPage } from './pages/install-page'; import { SavesPage } from './pages/saves-page'; import { CheatsPage } from './pages/cheats-page'; import Database from './services/database'; @@ -67,7 +66,6 @@ function Junie() { - } /> @@ -78,10 +76,6 @@ function Junie() { Games - - - Install - Saves diff --git a/ui/sources/modals/games-modal.jsx b/ui/sources/modals/games-modal.jsx index 6a37b7f..0b7627d 100644 --- a/ui/sources/modals/games-modal.jsx +++ b/ui/sources/modals/games-modal.jsx @@ -1,31 +1,86 @@ -import { IonButton, IonButtons, IonCard, IonContent, IonHeader, IonIcon, IonItem, IonLabel, IonPage, IonProgressBar, IonTitle, IonToolbar, useIonAlert } from '@ionic/react'; -import { cloudDownloadOutline, trashOutline } from 'ionicons/icons'; -import { useState } from 'react'; +import { IonButton, IonButtons, IonCard, IonContent, IonHeader, IonIcon, IonItem, IonItemDivider, IonItemGroup, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonPage, IonProgressBar, IonTitle, IonToolbar, useIonAlert, useIonModal } from '@ionic/react'; +import { add, closeOutline, cloudDownloadOutline, playOutline } from 'ionicons/icons'; +import { useRef, useState } from 'react'; import { useToast } from '../hooks/toast'; +import { CoreModal } from './core-modal'; +import { System } from '../entities/system'; import { Game } from '../entities/game'; import Requests from '../services/requests'; import Files from '../services/files'; +import Path from '../services/path'; + +const GameCard = ({ game, status, download, play }) => { + return ( + + +

{Path.clean(game.name)}

+

{game.system}

+
+ {status.game == game.name && + + } + {status.game != game.name && !game.installed && + download(game)} disabled={!!status.game} fill="clear"> + + + } + {status.game != game.name && game.installed && + play(game)} disabled={!!status.game} fill="clear"> + + + } +
+ ); +} /** * @param {Object} parameters - * @param {string} parameters.system + * @param {System} parameters.system * @param {() => void} parameters.close * @returns {JSX.Element} */ export const GamesModal = ({ system, close }) => { - const [download, setDownload] = useState({ game: null, progress: 0 }); + const list = useRef(/** @type {HTMLIonListElement} */ (null)); + const input = useRef(/** @type {HTMLInputElement} */ (null)); + + const sort = () => [...system.games.sort((g1, g2) => g1.rom < g2.rom ? -1 : 1)]; + + const [game, setGame] = useState(null); + const [games, setGames] = useState(sort(system.games)); + const [status, setStatus] = useState({ game: null, progress: 0 }); + + const [start, stop] = useIonModal(CoreModal, { system, game, close: () => stop() }); const [present, dismiss] = useToast('Game successfully installed!'); const [alert] = useIonAlert(); + /** + * @param {FileList} input + * @returns {Promise} + */ + const install = async (input) => { + if (!input?.length) + return; + + const file = input[0]; + + const buffer = new Uint8Array(await file.arrayBuffer()) + await Files.Games.add(system.name, file.name, buffer); + + const games = await Files.Games.get(); + system.games.push(games.find(game => game.rom == file.name)); + + setGames(sort(system.games)); + } + /** * @param {Game} game * @returns {Promise} */ - const install = async (game) => { - setDownload({ game: game.name, progress: 0 }); + const download = async (game) => { + setStatus({ game: game.name, progress: 0 }); const data = await Requests.fetchGame(system, game, progress => { - setDownload({ game: game.name, progress }); + setStatus({ game: game.name, progress }); }); if (!data) { @@ -34,7 +89,7 @@ export const GamesModal = ({ system, close }) => { message: `${game.name} (${system.name})`, buttons: [ 'OK' ], }); - setDownload({ game: null, progress: 0 }); + setStatus({ game: null, progress: 0 }); return; } @@ -44,7 +99,8 @@ export const GamesModal = ({ system, close }) => { dismiss(); present(`${game.name} (${system.name})`); - setDownload({ game: null, progress: 0 }); + setStatus({ game: null, progress: 0 }); + setGames(sort(system.games)); } /** @@ -55,7 +111,17 @@ export const GamesModal = ({ system, close }) => { await Files.Games.remove(game.system, game.rom); game.installed = false; - setDownload({ game: null, progress: 0 }); + await list.current.closeSlidingItems(); + setGames(sort(system.games)); + } + + /** + * @param {Game} game + * @returns {void} + */ + const play = (game) => { + setGame(game); + start({ cssClass: 'fullscreen' }); } return ( @@ -65,35 +131,62 @@ export const GamesModal = ({ system, close }) => { Games - Close + input.current.click()}> + install(e.target.files)} hidden /> + + + + + - {system.games.map(game => - - - -

{game.name}

-

{game.system}

+ + + + Installed + + + {games.filter(game => game.installed).map(game => ( + + + + + remove(game)}>Delete + + + + ))} + + {!games.filter(game => game.installed).length && + + You haven't installed any game yet. - {download.game == game.name && - - } - {download.game != game.name && !game.installed && - install(game)} disabled={!!download.game} fill="clear"> - - - } - {download.game != game.name && game.installed && - remove(game)} disabled={!!download.game} fill="clear"> - - - } -
-
- )} + } + + + + + Available + + + {games.filter(game => !game.installed).map(game => ( + + + + ))} + + {!games.filter(game => !game.installed).length && + + No game is available to download.
+ Try refreshing the library from the home page. +
+ } + +
+
diff --git a/ui/sources/pages/cheats-page.jsx b/ui/sources/pages/cheats-page.jsx index 93ac514..2a9e761 100644 --- a/ui/sources/pages/cheats-page.jsx +++ b/ui/sources/pages/cheats-page.jsx @@ -7,6 +7,7 @@ import { System } from '../entities/system'; import { Game } from '../entities/game'; import Files from '../services/files'; import Requests from '../services/requests'; +import Path from '../services/path'; /** * @returns {JSX.Element} @@ -105,7 +106,7 @@ export const CheatsPage = () => { }

{cheat.name}

-

{list.game}

+

{Path.clean(list.game)}

{list.system}

showModal(list, cheat)} fill="clear"> diff --git a/ui/sources/pages/home-page.jsx b/ui/sources/pages/home-page.jsx index 70a0483..e36c053 100644 --- a/ui/sources/pages/home-page.jsx +++ b/ui/sources/pages/home-page.jsx @@ -1,24 +1,18 @@ -import { IonButton, IonButtons, IonCard, IonContent, IonHeader, IonIcon, IonItem, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonPage, IonTitle, IonToolbar, useIonModal, useIonViewWillEnter } from '@ionic/react'; -import { add, playOutline, informationCircleOutline } from 'ionicons/icons'; -import { useRef, useState } from 'react'; +import { IonButton, IonButtons, IonCard, IonCardHeader, IonCardSubtitle, IonCardTitle, IonContent, IonHeader, IonIcon, IonLoading, IonPage, IonTitle, IonToolbar, useIonModal, useIonViewWillEnter } from '@ionic/react'; +import { useState } from 'react'; +import { informationCircleOutline, refreshOutline } from 'ionicons/icons'; import { useToast } from '../hooks/toast'; -import { CoreModal } from '../modals/core-modal'; +import { GamesModal } from '../modals/games-modal'; import { System } from '../entities/system'; -import { Game } from '../entities/game'; -import Audio from '../services/audio'; import Requests from '../services/requests'; -import Files from '../services/files'; /** * @returns {JSX.Element} */ export const HomePage = () => { - const fileInput = useRef(/** @type {HTMLInputElement} */ (null)); - - const [systems, setSystems] = useState(/** @type {System[]} */ ([])); - const [system, setSystem] = useState(/** @type {System} */ (null)); - const [games, setGames] = useState(/** @type {Game[]} */ ([])); - const [game, setGame] = useState(/** @type {Game} */ (null)); + const [systems, setSystems] = useState(/** @type {System[]} */ ([]) ); + const [system, setSystem] = useState(/** @type {System} */ (null) ); + const [loading, setLoading] = useState(/** @type {boolean} */ (false)); const version = window.junie_build.split('-')[0]; const build = window.junie_build.split('-')[1]; @@ -26,53 +20,31 @@ export const HomePage = () => { const [present] = useToast(`Junie - ${version} (${build})`); /** - * @param {FileList} input * @returns {Promise} */ - const addGame = async (input) => { - if (!input?.length) - return; - - const file = input[0]; - - const system = systems.find(x => x.extension == file.name.split('.').pop()); - if (!system) - return; + const refreshLibrary = async () => { + setLoading(true); - const buffer = new Uint8Array(await file.arrayBuffer()) - Files.Games.add(system.name, file.name, buffer); - - setGames(await Files.Games.get()); - } + await Requests.refreshLibrary() + setSystems(await Requests.getSystems()); - /** - * @param {Game} game - * @returns {Promise} - */ - const deleteGame = async (game) => { - await Files.Games.remove(game.system, game.rom); - setGames(await Files.Games.get()); + setLoading(false); } /** - * @param {Game} system + * @param {System} system * @returns {void} */ - const showModal = (game) => { - const system = systems.find(system => system.name == game.system); - + const showModal = (system) => { setSystem(system); - setGame(game); open({ cssClass: 'fullscreen' }); } - const [open, close] = useIonModal(CoreModal, { system, game, close: () => close() }); + const [open, close] = useIonModal(GamesModal, { system, close: () => close() }); useIonViewWillEnter(async () => { - Audio.unlock(); setSystems(await Requests.getSystems()); - setGames(await Files.Games.get()); }); return ( @@ -80,42 +52,30 @@ export const HomePage = () => { + Junie present(date)}> - Junie - fileInput.current.click()}> - addGame(e.target.files)} hidden /> - + + - - - {games.map(game => - - - - -

{game.name}

-

{game.system}

-
- showModal(game)} fill="clear"> - - -
- - deleteGame(game)}>Delete - -
-
- )} -
+ + + {systems.map(system => + showModal(system)}> + + {system.name} + {system.core_name} - {system.games.length} games + + + )} diff --git a/ui/sources/pages/install-page.jsx b/ui/sources/pages/install-page.jsx deleted file mode 100644 index 6e5684c..0000000 --- a/ui/sources/pages/install-page.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import { IonButton, IonButtons, IonCard, IonCardHeader, IonCardSubtitle, IonCardTitle, IonContent, IonHeader, IonIcon, IonLoading, IonPage, IonTitle, IonToolbar, useIonModal, useIonViewWillEnter } from '@ionic/react'; -import { useState } from 'react'; -import { refreshOutline } from 'ionicons/icons'; -import { GamesModal } from '../modals/games-modal'; -import { System } from '../entities/system'; -import Requests from '../services/requests'; - -/** - * @returns {JSX.Element} - */ -export const InstallPage = () => { - const [systems, setSystems] = useState(/** @type {System[]} */ ([]) ); - const [system, setSystem] = useState(/** @type {System} */ (null) ); - const [loading, setLoading] = useState(/** @type {boolean} */ (false)); - - /** - * @returns {Promise} - */ - const refreshLibrary = async () => { - setLoading(true); - - await Requests.refreshLibrary() - setSystems(await Requests.getSystems()); - - setLoading(false); - } - - /** - * @param {System} system - * @returns {void} - */ - const showModal = (system) => { - setSystem(system); - - open({ cssClass: 'fullscreen' }); - } - - const [open, close] = useIonModal(GamesModal, { system, close: () => close() }); - - useIonViewWillEnter(async () => { - setSystems(await Requests.getSystems()); - }); - - return ( - - - - - Systems - - - - - - - - - - - {systems.filter(system => !system.standalone).map(system => - showModal(system)}> - - {system.name} - {system.core_name} - {system.games.length} games - - - )} - - - - ); -}; diff --git a/ui/sources/pages/saves-page.jsx b/ui/sources/pages/saves-page.jsx index 1af8706..0fd57e1 100644 --- a/ui/sources/pages/saves-page.jsx +++ b/ui/sources/pages/saves-page.jsx @@ -8,6 +8,7 @@ import { Save } from '../entities/save'; import Requests from '../services/requests'; import Files from '../services/files'; import Zip from '../services/zip'; +import Path from '../services/path'; /** * @returns {JSX.Element} @@ -135,7 +136,7 @@ export const SavesPage = () => { } -

{save.game?.replaceAll(/ \(.*\)/g, '')}

+

{Path.clean(save.game)}

{save.system}

showModal(save)} fill="clear"> diff --git a/ui/sources/services/files.js b/ui/sources/services/files.js index a99f9ba..e4800e7 100644 --- a/ui/sources/services/files.js +++ b/ui/sources/services/files.js @@ -39,17 +39,11 @@ export default class Files { } /** - * @param {string[]} suffixes * @returns {Promise} */ - static async list(...suffixes) { + static async list() { const fs = await this.#fs(); - - const paths = await fs.list(); - - return suffixes - ? paths.filter(p => suffixes.some(s => p.endsWith(s))) - : paths; + return await fs.list(); } /** @@ -136,10 +130,10 @@ export default class Files { for (const core of Object.keys(Files.#cores)) { for (const system of Files.#cores[core].systems) { systems.push({ - ...system, + name: system, lib_name: core, core_name: Files.#cores[core].name, - games: stored.find(x => x.name == system.name)?.games, + games: stored.find(x => x.name == system)?.games, }); } } @@ -178,7 +172,7 @@ export default class Files { * @returns {Promise} */ static async get() { - const paths = await Files.list('.sav', '.dsv', '.srm', '.rtc', '.state', '.cht'); + const paths = (await Files.list()).filter(path => path.split('/').length == 4); return paths.map(path => new Save(path)).reduce((saves, save) => { const found = saves.find(x => x.system == save.system && x.game == save.game); @@ -196,8 +190,7 @@ export default class Files { */ static async fix(save, system, game) { for (const path of save.paths) { - const filename = game.rom.replace(`.${system.extension}`, ''); - const new_path = path.replace(save.system, system.name).replaceAll(save.game, filename); + const new_path = path.replace(save.system, system.name).replaceAll(save.game, game.name); const data = await Files.read(path); await Files.remove(path); @@ -220,7 +213,7 @@ export default class Files { * @returns {Promise} */ static async get() { - const paths = await Files.list('.cht'); + const paths = (await Files.list()).filter(path => path.endsWith('.cht')); const files = []; for (const path of paths) { @@ -254,20 +247,16 @@ export default class Files { */ static async get() { const systems = await Files.Library.get(); - const extensions = systems.map(x => x.extension); - const paths = await Files.list(...extensions); + const paths = (await Files.list()).filter(path => path.split('/').length == 3); const files = []; - for (const system of systems) - if (system.standalone) - files.push(new Game(system, system.core_name)); - for (const path of paths) { const [system_name, rom_name] = Path.parse(path); const system = systems.find(x => x.name == system_name); - files.push(new Game(system, rom_name)); + if (system) + files.push(new Game(system, rom_name, true)); } return files; diff --git a/ui/sources/services/path.js b/ui/sources/services/path.js index bb35348..5a7e4ce 100644 --- a/ui/sources/services/path.js +++ b/ui/sources/services/path.js @@ -39,4 +39,20 @@ export default class Path { const matches = path.match(/\/([^\/]*)\/([^\/]*)/); return [ matches[1], matches[2] ]; } + + /** + * @param {string} name + * @returns {string} + */ + static name(name) { + return name.substring(0, name.lastIndexOf('.')) || name; + } + + /** + * @param {string} name + * @returns {string} + */ + static clean(name) { + return name.replaceAll(/ \(.*\)/g, ''); + } } diff --git a/ui/sources/services/requests.js b/ui/sources/services/requests.js index f02367c..82735b8 100644 --- a/ui/sources/services/requests.js +++ b/ui/sources/services/requests.js @@ -1,6 +1,7 @@ import { Game } from '../entities/game'; import { System } from '../entities/system'; import Files from './files'; +import Path from './path'; export default class Requests { /** @@ -15,14 +16,11 @@ export default class Requests { html.innerHTML = await folder.text(); const elements = Array.from(html.querySelectorAll('a')); - const games = elements.map(a => { - const name = a.innerText.substring(0, a.innerText.lastIndexOf('.')); - return { name: name, rom: a.innerText }; - }); - - system.games = games.filter(game => game.rom.endsWith(`.${system.extension}`)); + system.games = elements.map(a => new Object({ name: Path.name(a.innerText), rom: a.innerText })) + .filter(game => game.rom != '.' && !game.rom.endsWith('/') && !game.rom.endsWith('.png')); } catch (e) { + console.error(e); system.games = []; } } @@ -51,11 +49,8 @@ export default class Requests { system.games = [ ...games.filter(x => !system.games.find(y => x.rom == y.rom)), - ...system.games.map(x => new Game(system, x.rom)), + ...system.games.map(x => new Game(system, x.rom, false)), ]; - - for (const game of system.games) - game.installed = !!games.find(x => x.rom == game.rom); } return systems; diff --git a/ui/sources/services/wasi.js b/ui/sources/services/wasi.js index e594882..c7d96b4 100644 --- a/ui/sources/services/wasi.js +++ b/ui/sources/services/wasi.js @@ -23,10 +23,8 @@ class FS { * @returns {Promise} */ async load(system, rom) { - const game = rom.substring(0, rom.lastIndexOf('.')) || rom; - for (const path of this.#filesystem.list()) { - if (!path.startsWith(`/${system}/${game}`)) + if (!path.startsWith(`/${system}/${Path.name(rom)}`)) continue; this.#filesystem.close(path); diff --git a/ui/sources/styles/index.css b/ui/sources/styles/index.css index 389d119..aa8f6b4 100644 --- a/ui/sources/styles/index.css +++ b/ui/sources/styles/index.css @@ -26,21 +26,51 @@ /* - * Home + * Systems + */ + #root .home ion-card { + text-align: center; + cursor: pointer; +} + + +/* + * Games */ -#root .home ion-list { +#root .games { + --background: var(--ion-background-color); +} + +#root .games .empty { + text-align: center; + font-style: italic; + padding: 18px; + color: var(--ion-color-step-400); +} + +#root .games ion-list { padding: 0; background: transparent; } -#root .home ion-item { +#root .games ion-card { + display: flex; + cursor: pointer; +} + +#root .games ion-item { + width: 100%; --padding-start: 0; } -#root .home ion-label { +#root .games ion-label { margin-left: 16px; } +#root .games ion-progress-bar { + max-width: 50px; +} + /* * Core @@ -92,41 +122,6 @@ } -/* - * Systems - */ -#root .systems ion-card { - text-align: center; - cursor: pointer; -} - - -/* - * Games - */ -#root .games { - --background: var(--ion-background-color); -} - -#root .games ion-card { - display: flex; - cursor: pointer; -} - -#root .games ion-item { - width: 100%; - --padding-start: 0; -} - -#root .games ion-label { - margin-left: 16px; -} - -#root .games ion-progress-bar { - max-width: 50px; -} - - /* * Saves */