6 Commits

Author SHA1 Message Date
edef5cc6dc fix: also replace download URLs 2025-04-30 07:35:54 +00:00
2dad0de4f1 fix: update rainycolor's domain 2025-04-30 06:59:38 +00:00
14a65eb5bb fix: keyboard unbinding and IR fixes 2025-04-29 19:59:21 +00:00
0add9200a6 feat: new grouping options 2025-04-28 22:00:33 +00:00
ee49da3665 fix: category sort 2025-04-28 16:47:45 +00:00
f478ad9216 feat: add package creator 2025-04-28 16:44:04 +00:00
19 changed files with 539 additions and 66 deletions

View File

@ -1,3 +1,25 @@
## 0.18.3
- Updated Rainycolor's domain・真
## 0.18.2
- Updated Rainycolor's domain
## 0.18.1
- Keys can now be unbinded with Esc
- Fixed CHUNITHM IR behavior on actual keyboards
## 0.18.0
- Added new grouping options to the package list
## 0.17.0
- Added a package creation prompt
- Added a default package icon
## 0.16.0 ## 0.16.0
- Fixed the clear cache button not working - Fixed the clear cache button not working

View File

@ -9,7 +9,7 @@ This is a program that seeks to streamline game data configuration, currently su
STARTLINER is four things: STARTLINER is four things:
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip), - a mod installer and updater, powered by [Rainycolor Watercolor](https://rainycolor.org),
- a configuration GUI for segatools, - a configuration GUI for segatools,
- a glorified `start.bat` clicker, with automatic monitor setup and rollback, - a glorified `start.bat` clicker, with automatic monitor setup and rollback,
- [an abstraction allowing data configuration without touching the game directory](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details). - [an abstraction allowing data configuration without touching the game directory](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details).

BIN
public/no-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -1,11 +1,12 @@
use ini::Ini; use ini::Ini;
use log; use log;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::fs; use tokio::fs;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
use crate::model::config::GlobalConfigField; use crate::model::config::GlobalConfigField;
use crate::model::local::PackageManifest;
use crate::model::misc::Game; use crate::model::misc::Game;
use crate::model::patch::Patch; use crate::model::patch::Patch;
use crate::modules::package::prepare_dlls; use crate::modules::package::prepare_dlls;
@ -166,6 +167,65 @@ pub async fn toggle_package(state: State<'_, tokio::sync::Mutex<AppData>>, key:
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn create_package(
name: String,
description: String,
website: String,
r#type: String,
games: Vec<Game>
) -> Result<(), String> {
log::debug!("invoke: create_package");
let dir = util::pkg_dir_of("local", &name);
if dir.exists() {
return Err("Package already exists".to_owned());
}
let mut installers = Vec::new();
if r#type == "segatools" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("segatools".to_owned())
);
installers.push(map);
} else if r#type == "native" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("native_mod".to_owned())
);
map.insert(
"dll-game".to_owned(),
serde_json::Value::String("some.dll".to_owned())
);
map.insert(
"dll-amdaemon".to_owned(),
serde_json::Value::String("another.dll".to_owned())
);
installers.push(map);
}
let manifest = PackageManifest {
name,
version_number: "1.0.0".to_owned(),
description,
website_url: website,
dependencies: BTreeSet::new(),
installers,
games: Some(games)
};
std::fs::create_dir(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
std::fs::write(dir.join("manifest.json"), json).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> { pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: reload_all_packages"); log::debug!("invoke: reload_all_packages");

View File

@ -193,6 +193,7 @@ pub async fn run(_args: Vec<String>) {
cmd::install_package, cmd::install_package,
cmd::delete_package, cmd::delete_package,
cmd::toggle_package, cmd::toggle_package,
cmd::create_package,
cmd::list_profiles, cmd::list_profiles,
cmd::init_profile, cmd::init_profile,

View File

@ -6,10 +6,11 @@ use super::misc::Game;
// manifest.json // manifest.json
#[derive(Deserialize)] #[derive(Serialize, Deserialize)]
pub struct PackageManifest { pub struct PackageManifest {
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub website_url: String,
pub description: String, pub description: String,
pub dependencies: BTreeSet<PkgKeyVersion>, pub dependencies: BTreeSet<PkgKeyVersion>,

View File

@ -117,12 +117,16 @@ impl Keyboard {
} }
} }
Keyboard::Chunithm(kb) => { Keyboard::Chunithm(kb) => {
let mut enabled_ir = false;
if kb.enabled { if kb.enabled {
for (i, cell) in kb.cell.iter().enumerate() { for (i, cell) in kb.cell.iter().enumerate() {
ini.with_section(Some("slider")).set(format!("cell{}", i + 1), cell.to_string()); ini.with_section(Some("slider")).set(format!("cell{}", i + 1), cell.to_string());
} }
for (i, ir) in kb.ir.iter().enumerate() { for (i, ir) in kb.ir.iter().enumerate() {
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), ir.to_string()); ini.with_section(Some("ir")).set(format!("ir{}", i + 1), (*ir).to_string());
if i > 0 && *ir != 0 {
enabled_ir = true;
}
} }
ini.with_section(Some("io3")) ini.with_section(Some("io3"))
.set("test", kb.test.to_string()) .set("test", kb.test.to_string())
@ -140,8 +144,13 @@ impl Keyboard {
.set("service", "0") .set("service", "0")
.set("coin", "0"); .set("coin", "0");
} }
ini.with_section(Some("io3")) if enabled_ir {
.set("ir", "0"); ini.with_section(Some("io3"))
.set("ir", "0");
} else {
ini.with_section(Some("io3"))
.set("ir", kb.ir[0].to_string());
}
} }
} }

View File

@ -109,7 +109,7 @@ impl Package {
loc: None, loc: None,
rmt: Some(Remote { rmt: Some(Remote {
package_url: p.package_url, package_url: p.package_url,
download_url: v.download_url, download_url: v.download_url.replace("https://rainy.patafour.zip/", "https://www.rainycolor.org/"),
icon: v.icon, icon: v.icon,
deprecated: p.is_deprecated, deprecated: p.is_deprecated,
nsfw: p.has_nsfw_content, nsfw: p.has_nsfw_content,

View File

@ -132,7 +132,7 @@ impl PackageStore {
prelude::*, prelude::*,
}; };
let response = reqwest::get(format!("https://rainy.patafour.zip/c/{game}/api/v1/package/")).await?; let response = reqwest::get(format!("https://www.rainycolor.org/c/{game}/api/v1/package/")).await?;
let reader = response let reader = response
.bytes_stream() .bytes_stream()

View File

@ -63,9 +63,9 @@
}, },
{ {
// Ongeki // Ongeki
filename: "amdaemon.exe", filename: "amdaemon.exe",
version: "46d47eab", version: "46d47eab",
sha256: '962C76331306D0839AFD40808EA99D83E651D39C4708C448ADE0C77E8BC0A1B0', sha256: '962C76331306D0839AFD40808EA99D83E651D39C4708C448ADE0C77E8BC0A1B0',
patches: [ patches: [
{ {
id: 'standard-localhost', id: 'standard-localhost',

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "STARTLINER", "productName": "STARTLINER",
"version": "0.16.0", "version": "0.18.3",
"identifier": "zip.patafour.startliner", "identifier": "zip.patafour.startliner",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",

View File

@ -4,6 +4,9 @@ import InputText from 'primevue/inputtext';
import { fromKeycode, toKeycode } from '../keyboard'; import { fromKeycode, toKeycode } from '../keyboard';
import { usePrfStore } from '../stores'; import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types'; import { OngekiButtons } from '../types';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const prf = usePrfStore(); const prf = usePrfStore();
@ -61,6 +64,10 @@ const handleKey = (
} }
} }
if (event.code === 'Escape') {
keycode = 0;
}
if (index !== undefined) { if (index !== undefined) {
data[button][index] = keycode; data[button][index] = keycode;
} else { } else {
@ -160,13 +167,24 @@ const fontSize = computed(() => {
<InputText <InputText
:style="{ :style="{
width: small ? '2.8rem' : '5rem', width: small ? '2.8rem' : '5rem',
height: small ? '2.8rem' : tall ? '10rem' : '5rem', height:
small && tall
? '5rem'
: small
? '2.8rem'
: tall
? '10rem'
: '5rem',
fontSize, fontSize,
backgroundColor: color, backgroundColor: color,
}" }"
unstyled unstyled
class="text-center buttoninputtext" class="text-center buttoninputtext"
v-tooltip="tooltip ? `${tooltip}: ${modelValue}` : undefined" v-tooltip="
tooltip
? `${tooltip}: ${modelValue} ${tooltip.startsWith('ir') ? `\n${t('cfg.keyboard.irTooltip')}` : ''}`
: undefined
"
@contextmenu.prevent="() => {}" @contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)" @keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown=" @mousedown="

View File

@ -1,12 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Fieldset from 'primevue/fieldset'; import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import { emit } from '@tauri-apps/api/event';
import ModListEntry from './ModListEntry.vue'; import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores'; import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types'; import { Feature, Game, Package } from '../types';
import { pkgKey } from '../util'; import { pkgKey } from '../util';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -17,38 +23,120 @@ const props = defineProps({
}); });
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const client = useClientStore();
const prf = usePrfStore(); const prf = usePrfStore();
const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]); const gameSublist: Ref<string[]> = ref([]);
invoke('get_game_packages', { const loadPackages = () => {
game: prf.current?.meta.game ?? null, invoke('get_game_packages', {
}).then((list) => { game: prf.current?.meta.game ?? null,
gameSublist.value = list as string[]; }).then((list) => {
gameSublist.value = list as string[];
});
};
loadPackages();
const allCategories = computed(() => {
const res = new Set<string>();
for (const pkg of pkgs.allLocal) {
for (const cat of pkg.rmt?.categories ?? []) {
res.add(cat);
}
}
return [...res.values()].sort((a, b) => a.localeCompare(b));
}); });
const group = computed(() => { const local = computed(() => {
const res = Object.assign( return pkgs.allLocal
{}, .filter((p) => gameSublist.value.includes(pkgKey(p)))
Object.groupBy( .filter((p) => p.namespace === 'local');
pkgs.allLocal });
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter( const groupedList = computed(() => {
(p) => const searchedPkgs = pkgs.allLocal
props.search === undefined || .filter((p) => gameSublist.value.includes(pkgKey(p)))
p.name .filter((p) => p.namespace !== 'local')
.toLowerCase() .filter(
.includes(props.search.toLowerCase()) || (p) =>
p.namespace props.search === undefined ||
.toLowerCase() p.name.toLowerCase().includes(props.search.toLowerCase()) ||
.includes(props.search.toLowerCase()) p.namespace.toLowerCase().includes(props.search.toLowerCase())
) );
.sort((p1, p2) => p1.namespace.localeCompare(p2.namespace))
.sort((p1, p2) => p1.name.localeCompare(p2.name)), let grouped;
({ namespace }) => namespace if (client.pkgListMode === 'namespace') {
) grouped = Object.groupBy(searchedPkgs, ({ namespace }) => namespace);
); } else if (client.pkgListMode === 'type') {
empty.value = Object.keys(res).length === 0; grouped = {
standard: [] as Package[],
native: [] as Package[],
segatools: [] as Package[],
unsupported: [] as Package[] | undefined,
};
grouped.unsupported = [];
for (const pkg of searchedPkgs) {
const loc = pkg.loc;
if (!loc || !loc.status || typeof loc.status === 'string') {
grouped.unsupported.push(pkg);
} else {
if (
loc.status.OK[0] &
(Feature.GameDLL | Feature.Mempatcher | Feature.AmdDLL)
) {
grouped.native.push(pkg);
} else if (loc.status.OK[0] & Feature.Mod) {
grouped.standard.push(pkg);
}
if (
loc.status.OK[0] &
(Feature.AMNet |
Feature.Aime |
Feature.ChuniIO |
Feature.ChusanHook |
Feature.Mu3IO |
Feature.Mu3Hook)
) {
grouped.segatools.push(pkg);
}
}
}
if (grouped.unsupported.length === 0) {
delete grouped.unsupported;
}
} else {
grouped = {} as { [key: string]: Package[] };
for (const pkg of searchedPkgs) {
for (const cat of pkg.rmt?.categories ?? []) {
if (client.hiddenCategories.includes(cat)) {
continue;
}
if (!(cat in grouped)) {
grouped[cat] = [] as Package[];
}
grouped[cat].push(pkg);
}
}
}
let res: [string, Package[]][] = [];
for (const [k, v] of Object.entries(grouped)) {
if (v !== undefined) {
res.push([k, v]);
}
}
if (
client.pkgListMode === 'namespace' ||
client.pkgListMode === 'category'
) {
res.sort((a, b) => `${a[0]}`.localeCompare(`${b[0]}`));
} else if (client.pkgListMode === 'type') {
for (const entry of res) {
entry[0] = t(`pkglist.${entry[0]}`);
}
}
return res; return res;
}); });
@ -56,12 +144,169 @@ const missing = computed(() => {
return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? []; return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
}); });
const emptyVisible = ref(false); const dialogVisible = ref(false);
setTimeout(() => (emptyVisible.value = true), 500);
const defaultModel = {
name: '',
description: '',
website: '',
type: 'rainy',
games: [] as string[],
};
const creatorModel = ref({ ...defaultModel });
const gameModel = (game: Game) =>
computed({
get() {
return (creatorModel.value.games as string[]).includes(game);
},
set(v: boolean) {
creatorModel.value.games = creatorModel.value.games.filter(
(g) => g !== game
);
if (v) {
creatorModel.value.games.push(game);
}
},
});
const gameModelOngeki = gameModel('ongeki');
const gameModelChunithm = gameModel('chunithm');
</script> </script>
<template> <template>
<Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0"> <Dialog
modal
:visible="dialogVisible"
:closable="false"
:header="t('creator.header')"
:style="{ width: '500px', scale: client.scaleValue }"
class="creation-dialog"
>
<div style="position: absolute; left: 250px; top: 25px">
<a
href="https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format"
target="_blank"
style="text-decoration: underline"
class="self-center"
>{{ t('creator.packageFormat') }}</a
>
</div>
<h2>{{ t('creator.basic') }}</h2>
<div class="flex flex-col gap-3">
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.name')"
v-model="creatorModel.name"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.description')"
v-model="creatorModel.description"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.website')"
v-model="creatorModel.website"
/>
</div>
<h2>{{ t('creator.type') }}</h2>
<div class="flex flex-col items-center">
<SelectButton
:options="[
{ title: t('creator.rainy'), value: 'rainy' },
{ title: t('creator.native'), value: 'native' },
{ title: t('creator.segatools'), value: 'segatools' },
]"
v-model="creatorModel.type"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</div>
<h2>{{ t('creator.games') }}</h2>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">{{ t('game.ongeki') }}</div>
<ToggleSwitch v-model="gameModelOngeki" />
</div>
<div class="flex flex-row">
<div class="grow">{{ t('game.chunithm') }}</div>
<ToggleSwitch v-model="gameModelChunithm" />
</div>
</div>
<div class="flex flex-row mt-5">
<Button
class="ml-auto mr-1"
style="width: 80px"
:label="t('ok')"
:disabled="creatorModel.games.length === 0"
@click="
async () => {
await invoke('create_package', creatorModel);
await pkgs.reloadAll();
loadPackages();
dialogVisible = false;
creatorModel = { ...defaultModel };
}
"
/>
<Button
class="mr-auto ml-1"
style="width: 80px"
:label="t('cancel')"
@click="
() => (
(dialogVisible = false),
(creatorModel = { ...defaultModel })
)
"
/>
</div>
</Dialog>
<div class="flex flex-row">
<SelectButton
:options="[
{ title: t('pkglist.namespace'), value: 'namespace' },
{ title: t('pkglist.type'), value: 'type' },
{ title: t('pkglist.category'), value: 'category' },
]"
v-model="client.pkgListMode"
v-on:update:model-value="
client.save();
emit('reload-icons');
"
:allow-empty="false"
option-label="title"
option-value="value"
/>
<div
class="grow text-right mr-2 self-center"
v-if="client.pkgListMode === 'category'"
>
{{ t('pkglist.exclusions') }}
</div>
<MultiSelect
v-if="client.pkgListMode === 'category'"
style="width: 30%"
:showToggleAll="false"
v-model="client.hiddenCategories"
v-on:value-change="
client.save();
emit('reload-icons');
"
:options="allCategories"
class="w-full grow"
/>
</div>
<Fieldset :legend="t('pkglist.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing"> <div class="flex items-center" v-for="p in missing">
<ModTitlecard <ModTitlecard
show-namespace show-namespace
@ -84,10 +329,28 @@ setTimeout(() => (emptyVisible.value = true), 500);
/> />
</div> </div>
</Fieldset> </Fieldset>
<Fieldset v-for="(namespace, key) in group" :legend="key.toString()"> <Fieldset :legend="t('pkglist.local')">
<ModListEntry v-for="p in namespace" :pkg="p" /> <ModListEntry v-for="p in local" :pkg="p" />
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</Fieldset>
<Fieldset v-for="[namespace, pkgs] in groupedList" :legend="namespace">
<ModListEntry v-for="p in pkgs" :pkg="p" />
</Fieldset> </Fieldset>
<div v-if="empty === true && emptyVisible === true" class="text-3xl fadein">
</div>
</template> </template>
<style lang="css" scoped>
.creation-dialog h2 {
margin-top: 0.6em;
margin-bottom: 0.4em;
font-size: 110%;
}
</style>

View File

@ -7,7 +7,7 @@ import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue'; import UpdateButton from './UpdateButton.vue';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores'; import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Feature, Package } from '../types'; import { Feature, Package } from '../types';
import { hasFeature } from '../util'; import { hasFeature } from '../util';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -16,6 +16,7 @@ const { t } = useI18n();
const prf = usePrfStore(); const prf = usePrfStore();
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const client = useClientStore();
const props = defineProps({ const props = defineProps({
pkg: Object as () => Package, pkg: Object as () => Package,
@ -39,7 +40,13 @@ if (unsupported.value === true && model.value === true) {
<template> <template>
<div class="flex items-center"> <div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" /> <ModTitlecard
show-version
show-icon
show-description
:show-namespace="client.pkgListMode !== 'namespace'"
:pkg="pkg"
/>
<UpdateButton :pkg="pkg" /> <UpdateButton :pkg="pkg" />
<span v-tooltip="unsupported && t('store.incompatible')"> <span v-tooltip="unsupported && t('store.incompatible')">
<ToggleSwitch <ToggleSwitch

View File

@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { ref } from 'vue';
import Chip from 'primevue/chip'; import Chip from 'primevue/chip';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '../invoke';
import { Feature, Package } from '../types'; import { Feature, Package } from '../types';
import { hasFeature, needsUpdate } from '../util'; import { hasFeature, needsUpdate } from '../util';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({ const props = defineProps({
pkg: Object as () => Package, pkg: Object as () => Package,
@ -14,23 +19,34 @@ const props = defineProps({
showIcon: Boolean, showIcon: Boolean,
}); });
const iconSrc = computed(() => { const icon = ref('/no-icon.png');
const icon = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
if (icon === undefined) { const reloadIcons = async () => {
return ''; const src = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
} else if (icon.startsWith('https://')) {
return icon; if (src === undefined) {
icon.value = '/no-icon.png';
} else if (src.startsWith('https://')) {
icon.value = src;
} else { } else {
return convertFileSrc(icon); const convt = convertFileSrc(src);
if (await invoke('file_exists', { path: src })) {
icon.value = convt;
} else {
icon.value = '/no-icon.png';
}
} }
}); };
reloadIcons();
listen('reload-icons', reloadIcons);
</script> </script>
<template> <template>
<img <img
v-if="showIcon" v-if="showIcon"
:src="iconSrc" :src="icon"
class="self-center rounded-sm" class="self-center rounded-sm"
width="32px" width="32px"
height="32px" height="32px"
@ -89,7 +105,12 @@ const iconSrc = computed(() => {
v-if="showNamespace && pkg?.namespace" v-if="showNamespace && pkg?.namespace"
class="text-sm opacity-75" class="text-sm opacity-75"
> >
by&nbsp;{{ pkg.namespace }} &nbsp;{{
t('by', { namespace: pkg.namespace }).replaceAll(
' ',
'&nbsp;'
)
}}
</span> </span>
<span class="m-2"> <span class="m-2">
<span <span

View File

@ -95,7 +95,7 @@ const prf = usePrfStore();
</div> </div>
</div> </div>
<div v-if="prf.current?.meta.game === 'chunithm'"> <div v-if="prf.current?.meta.game === 'chunithm'">
<div class="absolute left-1/2 top-1/5"> <div class="absolute left-9/17 top-1/12">
<div <div
class="flex flex-row flex-nowrap gap-2 self-center w-full" class="flex flex-row flex-nowrap gap-2 self-center w-full"
> >
@ -108,6 +108,7 @@ const prf = usePrfStore();
button="ir" button="ir"
:index="idx - 1" :index="idx - 1"
:tooltip="`ir${idx}`" :tooltip="`ir${idx}`"
tall
small small
color="rgba(0, 255, 0, 0.2)" color="rgba(0, 255, 0, 0.2)"
/> />

View File

@ -8,6 +8,7 @@ export default {
next: 'Next', next: 'Next',
skip: 'Skip', skip: 'Skip',
close: 'Close', close: 'Close',
by: 'by {namespace}',
start: { start: {
failed: 'Start check failed', failed: 'Start check failed',
accept: 'Run anyway', accept: 'Run anyway',
@ -46,16 +47,41 @@ export default {
exportTemplate: 'Export template', exportTemplate: 'Export template',
export: 'Export', export: 'Export',
}, },
creator: {
header: 'Package creator',
basic: 'Basic information',
name: 'Name',
description: 'Description',
website: 'Website',
type: 'Package type',
rainy: 'Standard',
segatools: 'Segatools',
native: 'Native',
games: 'Games',
packageFormat: 'Package format spec',
},
store: { store: {
installRecommended: 'Install recommended packages', installRecommended: 'Install recommended packages',
installed: 'Show installed', installed: 'Show installed',
deprecated: 'Show deprecated', deprecated: 'Show deprecated',
nsfw: 'Show NSFW', nsfw: 'Show NSFW',
incompatible: 'This package is currently incompatible with STARTLINER.', incompatible: 'This package is currently incompatible with STARTLINER.',
missing: 'Missing',
includeCategories: 'Include categories', includeCategories: 'Include categories',
excludeCategories: 'Exclude categories', excludeCategories: 'Exclude categories',
}, },
pkglist: {
missing: 'Missing',
local: 'Local packages',
namespace: 'By namespace',
type: 'By type',
category: 'By category',
standard: 'Standard mods',
native: 'Native mods',
segatools: 'segatools',
unsupported: 'Unsupported',
exclusions: 'Exclusions:',
},
patch: { patch: {
loading: 'Loading...', loading: 'Loading...',
noneFound: noneFound:
@ -163,6 +189,8 @@ export default {
'Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)', 'Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)',
leverMode: 'Lever mode', leverMode: 'Lever mode',
mouse: 'Mouse', mouse: 'Mouse',
irTooltip:
'When playing on an actual keyboard, only bind ir1; leave the rest unbound',
}, },
wine: { wine: {
prefix: 'Wine prefix', prefix: 'Wine prefix',

View File

@ -8,6 +8,7 @@ export default {
next: 'Dalej', next: 'Dalej',
skip: 'Pomiń', skip: 'Pomiń',
close: 'Zamknij', close: 'Zamknij',
by: 'od {namespace}',
start: { start: {
failed: 'Uruchomienie nie powiodło się', failed: 'Uruchomienie nie powiodło się',
accept: 'Uruchom mimo to', accept: 'Uruchom mimo to',
@ -46,6 +47,19 @@ export default {
exportTemplate: 'Eksportuj szablon', exportTemplate: 'Eksportuj szablon',
export: 'Eksportuj', export: 'Eksportuj',
}, },
creator: {
header: 'Kreator pakietów',
basic: 'Podstawowe informacje',
name: 'Nazwa',
description: 'Opis',
website: 'Strona internetowa',
type: 'Typ',
rainy: 'Standardowy',
segatools: 'Segatools',
native: 'Natywny',
games: 'Gry',
packageFormat: 'Specyfikacja formatu',
},
store: { store: {
installRecommended: 'Dodaj zalecane pakiety', installRecommended: 'Dodaj zalecane pakiety',
installed: 'Pokaż zainstalowane', installed: 'Pokaż zainstalowane',
@ -53,10 +67,21 @@ export default {
nsfw: 'Pokaż mityczny O.N.G.E.K.I. Sex Mod dlaczego ta opcja w ogóle tu jest', nsfw: 'Pokaż mityczny O.N.G.E.K.I. Sex Mod dlaczego ta opcja w ogóle tu jest',
incompatible: incompatible:
'Ten pakiet jest obecnie niekompatybilny ze STARTLINEREM.', 'Ten pakiet jest obecnie niekompatybilny ze STARTLINEREM.',
missing: 'Niedostępne',
includeCategories: 'Włącz kategorie', includeCategories: 'Włącz kategorie',
excludeCategories: 'Wyłącz kategorie', excludeCategories: 'Wyłącz kategorie',
}, },
pkglist: {
missing: 'Niedostępne',
local: 'Lokalne pakiety',
namespace: 'Po przestrzeni nazw',
type: 'Po typie',
category: 'Po kategorii',
standard: 'Standardowe mody',
native: 'Natywne mody',
segatools: 'segatools',
unsupported: 'Niewspierane',
exclusions: 'Czarna lista:',
},
patch: { patch: {
loading: 'Wczytuję...', loading: 'Wczytuję...',
noneFound: noneFound:
@ -204,6 +229,8 @@ export default {
'Dotyczy tylko wtedy, gdy moduł IO jest ustawiony na wbudowaną emulację lub zgodny moduł (np. mu3io.NET)', 'Dotyczy tylko wtedy, gdy moduł IO jest ustawiony na wbudowaną emulację lub zgodny moduł (np. mu3io.NET)',
leverMode: 'Tryb wajchy', leverMode: 'Tryb wajchy',
mouse: 'Mysz', mouse: 'Mysz',
irTooltip:
'Jeśli grasz na klawiaturze, ustaw tylko ir1; pozostałe zostaw wyłączone',
}, },
wine: { wine: {
prefix: 'Wine prefix', prefix: 'Wine prefix',

View File

@ -375,6 +375,9 @@ export const useClientStore = defineStore('client', () => {
const onboarded: Ref<Game[]> = ref([]); const onboarded: Ref<Game[]> = ref([]);
const locale: Ref<Locale> = ref('en'); const locale: Ref<Locale> = ref('en');
const currentTab: Ref<string> = ref('users'); const currentTab: Ref<string> = ref('users');
const pkgListMode: Ref<'namespace' | 'type' | 'category'> =
ref('namespace');
const hiddenCategories: Ref<string[]> = ref([]);
const _scaleValue = (value: ScaleType) => const _scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2; value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
@ -445,6 +448,14 @@ export const useClientStore = defineStore('client', () => {
if (input.currentTab) { if (input.currentTab) {
currentTab.value = input.currentTab; currentTab.value = input.currentTab;
} }
if (input.pkgListMode) {
pkgListMode.value = input.pkgListMode;
}
if (input.hiddenCategories) {
hiddenCategories.value = input.hiddenCategories;
}
await setLocale(locale.value); await setLocale(locale.value);
await setTheme(theme.value); await setTheme(theme.value);
} catch (e) { } catch (e) {
@ -484,6 +495,8 @@ export const useClientStore = defineStore('client', () => {
onboarded: onboarded.value, onboarded: onboarded.value,
locale: locale.value, locale: locale.value,
currentTab: currentTab.value, currentTab: currentTab.value,
pkgListMode: pkgListMode.value,
hiddenCategories: hiddenCategories.value,
}) })
); );
}; };
@ -560,6 +573,8 @@ export const useClientStore = defineStore('client', () => {
timeout, timeout,
scaleModel, scaleModel,
currentTab, currentTab,
pkgListMode,
hiddenCategories,
_scaleValue, _scaleValue,
scaleValue, scaleValue,
load, load,