explorer: add ps3 games support & localized resource support

This commit is contained in:
DH
2025-08-26 08:14:08 +03:00
parent f314a6657f
commit 7be697b13f
6 changed files with 408 additions and 68 deletions

View File

@@ -5,6 +5,209 @@
using namespace rpcsx::ui;
enum class LanguageCode {
ja,
en,
fr,
es,
de,
it,
nl,
pt,
ru,
ko,
ch,
zh,
fi,
sv,
da,
no,
pl,
br,
gb,
tr,
la,
ar,
ca,
cs,
hu,
el,
ro,
th,
vi,
in,
uk,
_count
};
static std::string languageCodeToString(LanguageCode code) {
switch (code) {
case LanguageCode::ja:
return "ja";
case LanguageCode::en:
return "en";
case LanguageCode::fr:
return "fr";
case LanguageCode::es:
return "es";
case LanguageCode::de:
return "de";
case LanguageCode::it:
return "it";
case LanguageCode::nl:
return "nl";
case LanguageCode::pt:
return "pt";
case LanguageCode::ru:
return "ru";
case LanguageCode::ko:
return "ko";
case LanguageCode::ch:
return "ch";
case LanguageCode::zh:
return "zh";
case LanguageCode::fi:
return "fi";
case LanguageCode::sv:
return "sv";
case LanguageCode::da:
return "da";
case LanguageCode::no:
return "no";
case LanguageCode::pl:
return "pl";
case LanguageCode::br:
return "br";
case LanguageCode::gb:
return "gb";
case LanguageCode::tr:
return "tr";
case LanguageCode::la:
return "la";
case LanguageCode::ar:
return "ar";
case LanguageCode::ca:
return "ca";
case LanguageCode::cs:
return "cs";
case LanguageCode::hu:
return "hu";
case LanguageCode::el:
return "el";
case LanguageCode::ro:
return "ro";
case LanguageCode::th:
return "th";
case LanguageCode::vi:
return "vi";
case LanguageCode::in:
return "in";
case LanguageCode::uk:
return "uk";
default:
return "en";
}
}
static std::vector<LocalizedString>
fetchLocalizedString(const sfo::registry &registry, const std::string &key) {
if (!registry.contains(key)) {
return {};
}
std::vector<LocalizedString> result;
result.push_back({.text = registry.at(key).as_string()});
for (std::size_t i = 0; i < static_cast<int>(LanguageCode::_count); ++i) {
std::string keyWithSuffix = key + (i < 10 ? "_0" : "_");
keyWithSuffix += std::to_string(i);
if (!registry.contains(keyWithSuffix)) {
continue;
}
result.push_back(
{.text = registry.at(keyWithSuffix).as_string(),
.lang = languageCodeToString(static_cast<LanguageCode>(i))});
}
return result;
}
static std::vector<LocalizedResource>
fetchLocalizedResourceFile(const std::filesystem::path &path,
const std::string &name, const std::string &ext) {
std::vector<LocalizedResource> result;
if (std::filesystem::is_regular_file(path / (name + ext))) {
result.push_back(LocalizedResource{
.uri = "file://" + (path / (name + ext)).string(),
});
} else {
return {};
}
for (std::size_t i = 0; i < static_cast<int>(LanguageCode::_count); ++i) {
std::string suffix = (i < 10 ? "_0" : "_");
suffix += std::to_string(i);
if (auto testPath = path / (name + suffix + ext);
std::filesystem::is_regular_file(testPath)) {
result.push_back(LocalizedResource{
.uri = "file://" + testPath.string(),
.lang = languageCodeToString(static_cast<LanguageCode>(i)),
});
}
}
return result;
}
static std::vector<LocalizedImage>
fetchLocalizedImageFile(const std::filesystem::path &path,
const std::string &name, const std::string &ext) {
std::vector<LocalizedImage> result;
if (std::filesystem::is_regular_file(path / (name + ext))) {
result.push_back(LocalizedImage{
.uri = "file://" + (path / (name + ext)).string(),
.resolution = ImageResolution::Normal,
});
}
if (std::filesystem::is_regular_file(path / (name + "_4k" + ext))) {
result.push_back(LocalizedImage{
.uri = "file://" + (path / (name + "_4k" + ext)).string(),
.resolution = ImageResolution::High,
});
}
for (std::size_t i = 0; i < static_cast<int>(LanguageCode::_count); ++i) {
std::string suffix = (i < 10 ? "_0" : "_");
suffix += std::to_string(i);
if (auto testPath = path / (name + suffix + ext);
std::filesystem::is_regular_file(testPath)) {
result.push_back(LocalizedImage{
.uri = "file://" + testPath.string(),
.lang = languageCodeToString(static_cast<LanguageCode>(i)),
.resolution = ImageResolution::Normal,
});
}
if (auto testPath = path / (name + "_4k" + suffix + ext);
std::filesystem::is_regular_file(testPath)) {
result.push_back(LocalizedImage{
.uri = "file://" + testPath.string(),
.lang = languageCodeToString(static_cast<LanguageCode>(i)),
.resolution = ImageResolution::High,
});
}
}
return result;
}
static std::size_t calcDirectorySize(const std::filesystem::path &path) {
std::uint64_t result = 0;
@@ -104,18 +307,12 @@ tryFetchGame(const std::filesystem::directory_entry &entry) {
ExplorerItem info;
info.type = "game";
info.name = fetchLocalizedString(data.sfo, "TITLE");
auto name = sfo::get_string(data.sfo, "TITLE");
if (name.empty()) {
name = sfo::get_string(data.sfo, "TITLE_ID");
if (name.empty()) {
return {};
}
if (info.name.empty()) {
return {};
}
info.name = {LocalizedString{.text = std::string(name)}};
info.titleId = sfo::get_string(data.sfo, "TITLE_ID");
info.version = sfo::get_string(data.sfo, "APP_VER");
@@ -123,21 +320,80 @@ tryFetchGame(const std::filesystem::directory_entry &entry) {
info.version = sfo::get_string(data.sfo, "VERSION", "1.0");
}
if (std::filesystem::is_regular_file(sysPath / "icon0.png")) {
info.icon = {
LocalizedIcon{.uri = "file://" + (sysPath / "icon0.png").string()}};
}
info.icon = fetchLocalizedImageFile(sysPath, "icon0", ".png");
info.iconSound = fetchLocalizedResourceFile(sysPath, "snd0", ".at9");
info.background = fetchLocalizedImageFile(sysPath, "pic1", ".png");
info.overlayImage = fetchLocalizedImageFile(sysPath, "pic2", ".png");
info.size = calcDirectorySize(entry.path());
info.type = "game";
info.launcher = LauncherInfo{
.type = "fself-ps4-orbis" // FIXME: self/elf? ps3? ps5?
.type = "fself-ps4-orbis" // FIXME: self/elf? ps5?
// "fself-ps5-prospero"
};
info.location = "file://" + entry.path().string();
return std::move(info);
}
static std::optional<ExplorerItem>
tryFetchPs3Game(const std::filesystem::directory_entry &entry) {
auto usrdirPath = entry.path() / "USRDIR";
auto paramSfoPath = entry.path() / "PARAM.SFO";
auto ebootPath = usrdirPath / "EBOOT.BIN";
if (!std::filesystem::is_regular_file(ebootPath)) {
return {};
}
if (!std::filesystem::is_regular_file(paramSfoPath)) {
return {};
}
auto data = sfo::load(paramSfoPath.string());
if (data.errc != sfo::error::ok) {
elog("%s: error %d", entry.path().c_str(), static_cast<int>(data.errc));
return {};
}
auto titleId = sfo::get_string(data.sfo, "TITLE_ID");
auto bootable = sfo::get_integer(data.sfo, "BOOTABLE", 0);
auto category = sfo::get_string(data.sfo, "CATEGORY");
if (!bootable || titleId.empty()) {
return {};
}
ExplorerItem info;
info.type = "game";
info.name = fetchLocalizedString(data.sfo, "TITLE");
if (info.name.empty()) {
return {};
}
info.version = sfo::get_string(data.sfo, "APP_VER");
if (info.version->empty()) {
info.version = sfo::get_string(data.sfo, "VERSION", "1.0");
}
info.icon = fetchLocalizedImageFile(entry.path(), "ICON0", ".PNG");
info.iconSound = fetchLocalizedResourceFile(entry.path(), "SND0", ".AT3");
info.iconVideo = fetchLocalizedResourceFile(entry.path(), "ICON1", ".PAM");
info.overlayImageWide = fetchLocalizedImageFile(entry.path(), "PIC0", ".PNG");
info.background = fetchLocalizedImageFile(entry.path(), "PIC1", ".PNG");
info.overlayImage = fetchLocalizedImageFile(entry.path(), "PIC2", ".PNG");
info.size = calcDirectorySize(entry.path());
info.type = "game";
info.launcher = LauncherInfo{
.type = "self-ps3-cellos" // FIXME: fself/elf?
};
info.location = "file://" + entry.path().string();
return info;
}
struct ExplorerExtension : rpcsx::ui::Extension<rpcsx::ui::Explorer> {
std::thread explorerThread;
std::vector<std::string> locations;
@@ -205,6 +461,11 @@ struct ExplorerExtension : rpcsx::ui::Extension<rpcsx::ui::Explorer> {
submit(std::move(*fw));
continue;
}
if (auto game = tryFetchPs3Game(entry)) {
submit(std::move(*game));
continue;
}
}
}

View File

@@ -37,14 +37,26 @@
}
}
},
"icon-resolution": {
"image-resolution": {
"type": "enum",
"enumerators": {
"normal": 0,
"high": 1
}
},
"localized-icon": {
"localized-resource": {
"type": "object",
"params": {
"uri": {
"type": "string"
},
"lang": {
"type": "string",
"optional": true
}
}
},
"localized-image": {
"type": "object",
"params": {
"uri": {
@@ -55,7 +67,7 @@
"optional": true
},
"resolution": {
"type": "icon-resolution",
"type": "image-resolution",
"optional": true
}
}
@@ -177,7 +189,7 @@
},
"icon": {
"type": "array",
"item-type": "localized-icon",
"item-type": "localized-image",
"optional": true
},
"description": {
@@ -218,7 +230,7 @@
},
"icon": {
"type": "array",
"item-type": "localized-icon",
"item-type": "localized-image",
"optional": true
},
"description": {

View File

@@ -13,28 +13,21 @@ export function getLocalizedString(string: LocalizedString[], langs: string[] =
return string[0].text;
}
export function getLocalizedIcon(icon: LocalizedIcon[], resolution: IconResolution = IconResolution.Normal, langs: string[] = []) {
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
const lang = langs[langIndex];
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
const localizedIcon = icon[iconIndex];
if (localizedIcon.lang === lang && localizedIcon.resolution === resolution) {
return localizedIcon.uri;
}
}
export function getLocalizedResource(resources: LocalizedResource[], langs: string[] = []) {
if (resources.length == 0) {
return undefined;
}
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
const lang = langs[langIndex];
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
const localizedIcon = icon[iconIndex];
if (localizedIcon.lang === lang) {
return localizedIcon.uri;
for (let resourceIndex = 0; resourceIndex < resources.length; ++resourceIndex) {
const localizedResource = resources[resourceIndex];
if (localizedResource.lang === lang) {
return localizedResource.uri;
}
}
}
return icon[0].uri;
return resources[0].uri;
}

View File

@@ -35,7 +35,32 @@
},
"icon": {
"type": "array",
"item-type": "$core/localized-icon",
"item-type": "$core/localized-image",
"optional": true
},
"icon-video": {
"type": "array",
"item-type": "$core/localized-resource",
"optional": true
},
"icon-sound": {
"type": "array",
"item-type": "$core/localized-resource",
"optional": true
},
"background": {
"type": "array",
"item-type": "$core/localized-image",
"optional": true
},
"overlay-image": {
"type": "array",
"item-type": "$core/localized-image",
"optional": true
},
"overlay-image-wide": {
"type": "array",
"item-type": "$core/localized-image",
"optional": true
},
"publisher": {

View File

@@ -1,6 +1,6 @@
import { Region } from '$/Region';
import { IconResolution } from '$core/enums';
import { getLocalizedString, getLocalizedIcon } from '$core/Localized';
import { ImageResolution } from '$core/enums';
import { getLocalizedString } from '$core/Localized';
export function getRegion(contentId?: string) {
if (contentId === undefined || contentId.length != 36) {
@@ -23,11 +23,41 @@ export function getName(item: ExplorerItem, langs: string[] = []) {
return getLocalizedString(item.name, langs);
}
export function getIcon(item: ExplorerItem, resolution: IconResolution = IconResolution.Normal, langs: string[] = []) {
export function getIcon(item: ExplorerItem, resolution: ImageResolution = ImageResolution.Normal, langs: string[] = []) {
if (!item.icon) {
return undefined;
}
return getLocalizedIcon(item.icon, resolution, langs);
return getLocalizedImage(item.icon, resolution, langs);
}
export function getLocalizedImage(icon: LocalizedImage[], resolution: ImageResolution = ImageResolution.Normal, langs: string[] = []) {
if (icon.length == 0) {
return undefined;
}
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
const lang = langs[langIndex];
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
const localizedIcon = icon[iconIndex];
if (localizedIcon.lang === lang && localizedIcon.resolution === resolution) {
return localizedIcon.uri;
}
}
}
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
const lang = langs[langIndex];
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
const localizedIcon = icon[iconIndex];
if (localizedIcon.lang === lang) {
return localizedIcon.uri;
}
}
}
return icon[0].uri;
}

View File

@@ -1,9 +1,10 @@
import { ComponentProps, memo, ReactElement, useEffect, useRef, useState } from 'react';
import { Image, Pressable, ScrollView, StyleSheet, View, FlatList, Modal, useWindowDimensions } from 'react-native';
import { ComponentProps, memo, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { Image, Pressable, ScrollView, StyleSheet, View, FlatList, Modal, useWindowDimensions, ImageBackground } from 'react-native';
import { useThemeColor } from '$core/useThemeColor'
import ThemedIcon from '$core/ThemedIcon';
import { ThemedText } from '$core/ThemedText';
import { getIcon, getName } from "$/ExplorerItemUtils"
import { getIcon, getName, getLocalizedImage } from "$/ExplorerItemUtils"
import { HapticPressable } from '$core/HapticPressable';
import { DownShowViewSelector, LeftRightViewSelector } from '$core/ViewSelector';
import { getLocalizedString } from '$core/Localized';
@@ -242,7 +243,8 @@ const ExplorerItemHeader = memo(function ({ item, active, ...rest }: { item: Exp
justifyContent: 'center',
flexWrap: 'nowrap',
height: "100%",
minWidth: 100,
minWidth: 250,
minHeight: 100
},
});
@@ -432,7 +434,7 @@ const ExplorerItemBody = memo(function ({ item }: { item: ExplorerItem }) {
)
});
const ExplorerView = function ({ items }: { items: ExplorerItem[] }) {
const ExplorerView = function ({ items, setBackground }: { items: ExplorerItem[], setBackground: (uri?: string) => void }) {
const styles = StyleSheet.create({
topContainer: {
width: "100%",
@@ -444,7 +446,13 @@ const ExplorerView = function ({ items }: { items: ExplorerItem[] }) {
}
});
const [selectedItem, selectItem] = useState(0);
const [selectedItem, setSelectedItem] = useState(0);
const selectItem = (index: number) => {
setSelectedItem(index);
setBackground(items[index].background ? getLocalizedImage(items[index].background) : undefined);
};
return (
<View style={styles.topContainer}>
@@ -523,7 +531,6 @@ const ExplorerStyles = StyleSheet.create({
},
});
export function Explorer(props?: Props) {
const insets = useSafeAreaInsets();
const [background, setBackground] = useState<string | undefined>(undefined);
@@ -548,12 +555,22 @@ export function Explorer(props?: Props) {
self.onExplorerItems(event => {
games.push(...event.items.filter(item => item.type == 'game'));
setGames(games);
setUpdateId(updateId + 1);
if (updateId == 0) {
setUpdateId(updateId + 1);
}
console.log("received items", event.items.length, games.length);
});
self.explorerGet({});
if (games.length == 0) {
self.explorerGet({});
}
}, []);
const updateActiveTab = (tab: number) => {
setActiveTab(tab);
setBackground(undefined);
};
const screens = [
{
title: "Games",
@@ -566,29 +583,31 @@ export function Explorer(props?: Props) {
];
return (
<View style={[ExplorerStyles.rootContainer, { backgroundImage: background }]}>
<View style={[ExplorerStyles.menuContainer, safeArea.header]}>
<View style={ExplorerStyles.containerTabs}>
<View style={ExplorerStyles.containerTabItems}>
{
screens.map((screen, index) =>
<ScreenTab key={screen.title} title={screen.title} active={activeTab == index} onPress={() => setActiveTab(index)} />
)
}
<ImageBackground source={{ uri: background }} style={{ width: "100%", height: "100%" }} resizeMode="cover">
<View style={[ExplorerStyles.rootContainer]}>
<View style={[ExplorerStyles.menuContainer, safeArea.header]}>
<View style={ExplorerStyles.containerTabs}>
<View style={ExplorerStyles.containerTabItems}>
{
screens.map((screen, index) =>
<ScreenTab key={screen.title} title={screen.title} active={activeTab == index} onPress={() => updateActiveTab(index)} />
)
}
</View>
</View>
<View style={[ExplorerStyles.containerButtons]}>
<View style={ExplorerStyles.containerButtonItems}>
<HapticPressable><ThemedIcon iconSet="Ionicons" name="search" size={40} /></HapticPressable>
<HapticPressable onPress={() => settings.pushSettingsView({})}><ThemedIcon iconSet="Ionicons" name="settings-outline" size={40} /></HapticPressable>
<HapticPressable><ThemedIcon iconSet="FontAwesome6" name="user" size={40} /></HapticPressable>
</View>
</View>
</View>
<View style={[ExplorerStyles.containerButtons]}>
<View style={ExplorerStyles.containerButtonItems}>
<HapticPressable><ThemedIcon iconSet="Ionicons" name="search" size={40} /></HapticPressable>
<HapticPressable onPress={() => settings.pushSettingsView({})}><ThemedIcon iconSet="Ionicons" name="settings-outline" size={40} /></HapticPressable>
<HapticPressable><ThemedIcon iconSet="FontAwesome6" name="user" size={40} /></HapticPressable>
</View>
</View>
</View>
<LeftRightViewSelector key={updateId} list={screens} style={[ExplorerStyles.contentContainer, safeArea.content]} renderItem={item =>
<ExplorerView items={item.view} />} selectedItem={activeTab} />
</View>
<LeftRightViewSelector key={updateId} list={screens} style={[ExplorerStyles.contentContainer, safeArea.content]} renderItem={item =>
<ExplorerView items={item.view} setBackground={setBackground} />} selectedItem={activeTab} />
</View>
</ImageBackground>
)
};