Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e0ba69dc | |||
| 9ec65fbcc8 | |||
| 5a0b7f1a26 | |||
| 887f0d5bd0 | |||
| 3b91e96074 | |||
| 76bca26143 | |||
| a4ce0686ad | |||
| 96eec9d979 | |||
| 1600b2e942 | |||
| 29916d5c8a | |||
| 1b5acadf99 | |||
| 8817b1e611 | |||
| 9d078eaf2f | |||
| 4b577b5034 | |||
| 15d40c6961 | |||
| c10e4d20ab | |||
| 026d284644 | |||
| 34332e99ee | |||
| c5ff9c6ed7 | |||
| eb0e90f92b | |||
| 37b3c93604 | |||
| b2b279be38 | |||
| 803c1b8762 | |||
| 912a604971 | |||
| a16e3acc91 | |||
| 6c591729ff | |||
| f4841c3be0 | |||
| 17353f6984 | |||
| a2021529b2 | |||
| 7ec96e6a14 | |||
| 6ad97219c9 | |||
| 14e92937e8 | |||
| c9ef1db546 | |||
| 7d530fe258 | |||
| 265a2b3992 | |||
| 5f8f084064 | |||
| a24330ae2b | |||
| b04d1f06eb | |||
| 1411f78388 | |||
| c5a05ca3e2 | |||
| f9487ce632 | |||
| e17fbd2354 | |||
| c087dbcd69 | |||
| 5fb311859a | |||
| b01b1273b1 | |||
| 1b02b443d7 | |||
| d874b194a2 | |||
| f648cbbcc4 | |||
| 212413ff1a | |||
| 7414efce47 | |||
| eddba38829 | |||
| 365a8143ff | |||
| 1ecc47c32f | |||
| b589e43d3e | |||
| 74d2b71084 | |||
| 972a5958c6 |
@@ -91,7 +91,10 @@ jobs:
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
uploadPlainBinary: true
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'Emerald v__VERSION__'
|
||||
releaseBody: |
|
||||
@@ -142,4 +145,4 @@ jobs:
|
||||
tag_name: v${{ needs.get-version.outputs.version }}
|
||||
files: Emerald.Legacy.Launcher.flatpak
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -15,6 +15,8 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "~2.3.1",
|
||||
"@tauri-apps/plugin-updater": "~2.10.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -14,6 +14,12 @@ importers:
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2
|
||||
version: 2.5.3
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ~2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ~2.10.1
|
||||
version: 2.10.1
|
||||
framer-motion:
|
||||
specifier: ^12.36.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -647,6 +653,12 @@ packages:
|
||||
'@tauri-apps/plugin-opener@2.5.3':
|
||||
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.10.1':
|
||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
|
||||
@@ -1748,6 +1760,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-updater@2.10.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
|
||||
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 715 B |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -47,6 +47,21 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
@@ -363,6 +378,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -761,6 +782,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -771,6 +798,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -980,20 +1018,26 @@ version = "1.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"image 0.24.9",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.11.27",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"steam_shortcuts_util",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-drpc",
|
||||
"tauri-plugin-gamepad",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1140,6 +1184,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1886,6 +1940,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.9.0",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
@@ -2505,6 +2574,18 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2628,6 +2709,27 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom_locate"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2735,6 +2837,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2761,6 +2864,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2877,6 +2992,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3635,15 +3764,20 @@ dependencies = [
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3679,6 +3813,20 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpcdiscord"
|
||||
version = "0.2.6"
|
||||
@@ -3720,6 +3868,32 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -3729,6 +3903,53 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -4071,6 +4292,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4202,6 +4434,18 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "steam_shortcuts_util"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"crc32fast",
|
||||
"nom",
|
||||
"nom_locate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -4257,6 +4501,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -4399,6 +4649,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4591,6 +4852,49 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
@@ -4855,6 +5159,30 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -5078,6 +5406,26 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5160,6 +5508,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -5558,6 +5912,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6357,6 +6720,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -6482,6 +6855,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
@@ -6515,6 +6894,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -24,7 +24,15 @@ tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
base64 = "0.21"
|
||||
tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "native-tls"] }
|
||||
url = "2"
|
||||
rand = "0.8"
|
||||
http = "1"
|
||||
rfd = "0.15"
|
||||
libc = "0.2"
|
||||
image = "0.24"
|
||||
percent-encoding = "2.3"
|
||||
steam_shortcuts_util = "1.1.8"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -18,13 +18,23 @@
|
||||
"opener:default",
|
||||
"gamepad:default",
|
||||
"drpc:default",
|
||||
"updater:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "**" }]
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-reveal-item-in-dir",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]}]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default"],"platforms":["macOS","windows","linux"]}}
|
||||
@@ -2443,6 +2443,90 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2443,6 +2443,90 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ fn main() {
|
||||
let stage = env::var("EMERALD_LAUNCH_STAGE").unwrap_or_else(|_| "0".to_string());
|
||||
if stage == "0" {
|
||||
let mut cmd = Command::new(env::current_exe().unwrap());
|
||||
cmd.args(env::args().skip(1));
|
||||
cmd.env("EMERALD_LAUNCH_STAGE", "1");
|
||||
|
||||
let wayland_libs = ["/usr/lib64/libwayland-client.so.0", "/usr/lib/libwayland-client.so.0"];
|
||||
@@ -76,6 +77,7 @@ fn main() {
|
||||
println!("Emerald: Automatic recovery triggered for graphics crash/invisible launch.");
|
||||
}
|
||||
let mut retry_cmd = Command::new(env::current_exe().unwrap());
|
||||
retry_cmd.args(env::args().skip(1));
|
||||
retry_cmd.env("EMERALD_LAUNCH_STAGE", "2")
|
||||
.env("GDK_BACKEND", "x11")
|
||||
.env("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
|
||||
|
||||
@@ -1,57 +1,65 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LCE Emerald Launcher",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.emerald.legacy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "LCE Emerald Launcher",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
"resources/DLC"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"signingIdentity": null,
|
||||
"providerShortName": "Emerald"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LCE Emerald Launcher",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.emerald.legacy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "LCE Emerald Launcher",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 720,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1NTk5MjQ4QjMxQjFCQgpSV1M3c1RHTEpKbFZBMEtxMnNUZ3F5V2FmQmQrREY5Z0pkbnJuUlBYL2d1cUtzRkJIeDFkUkR4RQo=",
|
||||
"endpoints": ["https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest/download/latest.json"],
|
||||
"windows": {"installMode": "passive"}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
"resources/DLC"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"signingIdentity": null,
|
||||
"providerShortName": "Emerald"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface AchievementToastProps {
|
||||
onClose: () => void;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
variant?: "error" | "update";
|
||||
variant?: "error" | "update" | "steam";
|
||||
}
|
||||
|
||||
export function AchievementToast({
|
||||
@@ -43,6 +43,11 @@ export function AchievementToast({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (variant === "steam") {
|
||||
return (
|
||||
<img src="/images/steam.png" alt="Steam" className="w-8 h-8 object-contain" style={{ imageRendering: "pixelated", filter: "brightness(0) invert(1)" }} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -72,9 +77,9 @@ export function AchievementToast({
|
||||
onClick={
|
||||
onClick
|
||||
? () => {
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
onClick();
|
||||
onClose();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
|
||||
|
||||
@@ -1,70 +1,72 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useUI } from '../../context/LauncherContext';
|
||||
|
||||
interface PanoramaProps {
|
||||
profile: string;
|
||||
isDay: boolean;
|
||||
}
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const PANORAMA_PROFILES = ['legacy_evolved', '360revived'];
|
||||
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
|
||||
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
|
||||
|
||||
const [bgWidth, setBgWidth] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const updateWidth = () => {
|
||||
if (!containerRef.current) return;
|
||||
const img = new Image();
|
||||
img.src = currentPanorama;
|
||||
img.onload = () => {
|
||||
if (!active || !containerRef.current) return;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
setBgWidth(Math.ceil(height * aspectRatio));
|
||||
};
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener('resize', updateWidth);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
};
|
||||
}, [currentPanorama]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bgWidth && (
|
||||
<style>{`
|
||||
@keyframes panoramaLoop {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500">
|
||||
{isWindowVisible && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full will-change-transform"
|
||||
style={{
|
||||
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : '200vw',
|
||||
backgroundImage: `url("${currentPanorama}")`,
|
||||
backgroundSize: bgWidth ? `${bgWidth}px 100%` : 'auto 100%',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
animation: bgWidth ? 'panoramaLoop 140s linear infinite' : 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PanoramaBackground;
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useUI } from "../../context/LauncherContext";
|
||||
|
||||
interface PanoramaProps {
|
||||
profile: string;
|
||||
isDay: boolean;
|
||||
}
|
||||
|
||||
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
|
||||
const { isWindowVisible } = useUI();
|
||||
const baseId = profile;
|
||||
const profileId = baseId ? baseId : "vanilla_tu19";
|
||||
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
|
||||
const [bgWidth, setBgWidth] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const updateWidth = () => {
|
||||
if (!containerRef.current) return;
|
||||
const img = new Image();
|
||||
img.src = currentPanorama;
|
||||
img.onload = () => {
|
||||
if (!active || !containerRef.current) return;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
setBgWidth(Math.ceil(height * aspectRatio));
|
||||
};
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, [currentPanorama]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bgWidth && (
|
||||
<style>{`
|
||||
@keyframes panoramaLoop {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500"
|
||||
>
|
||||
{isWindowVisible && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full will-change-transform"
|
||||
style={{
|
||||
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : "200vw",
|
||||
backgroundImage: `url("${currentPanorama}")`,
|
||||
backgroundSize: bgWidth ? `${bgWidth}px 100%` : "auto 100%",
|
||||
backgroundRepeat: "repeat-x",
|
||||
animation: bgWidth ? "panoramaLoop 140s linear infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PanoramaBackground;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
interface ScreenshotImageProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
style?: React.CSSProperties;
|
||||
fallbackSrc?: string;
|
||||
}
|
||||
|
||||
export function ScreenshotImage({
|
||||
path,
|
||||
className,
|
||||
alt,
|
||||
loading,
|
||||
style,
|
||||
fallbackSrc,
|
||||
}: ScreenshotImageProps) {
|
||||
const [src, setSrc] = useState<string | undefined>(fallbackSrc);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSrc(fallbackSrc);
|
||||
TauriService.readScreenshotAsDataUrl(path)
|
||||
.then((url) => {
|
||||
if (!cancelled) setSrc(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && fallbackSrc) setSrc(fallbackSrc);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, fallbackSrc]);
|
||||
|
||||
const handleError = () => {
|
||||
if (fallbackSrc) setSrc(fallbackSrc);
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
className={className}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
style={style}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -256,6 +256,9 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
} else if (focusIndex === 4) {
|
||||
playPressSound();
|
||||
setActiveView('screenshots');
|
||||
} else if (focusIndex === 5) {
|
||||
playPressSound();
|
||||
setActiveView('lcelive');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -346,6 +349,16 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
|
||||
>
|
||||
<img src="/images/Screenshots_Icon.png" alt="Screenshots" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
</button>
|
||||
<button
|
||||
data-focus="5" tabIndex={0}
|
||||
onMouseEnter={() => isFocusedSection && setFocusIndex(5)}
|
||||
onClick={() => { playPressSound(); setActiveView('lcelive'); }}
|
||||
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 5 ? 'scale-110' : ''}`}
|
||||
style={isFocusedSection && focusIndex === 5 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
|
||||
title="LCELive"
|
||||
>
|
||||
<img src="/images/friends.png" alt="LCELive" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
|
||||
|
||||
export default function ChooseInstanceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
editions,
|
||||
installs,
|
||||
invite,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playPressSound: (s?: string) => void;
|
||||
playBackSound: (s?: string) => void;
|
||||
editions: any[];
|
||||
installs: string[];
|
||||
invite: GameInvite | null;
|
||||
}) {
|
||||
const [selectedInstance, setSelectedInstance] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [connectionMethod, setConnectionMethod] = useState<"direct" | "relay">("direct");
|
||||
|
||||
const validInstances = editions.filter((e: any) =>
|
||||
installs.includes(e.instanceId)
|
||||
);
|
||||
|
||||
const hasRelay = !!(invite?.signalingSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedInstance("");
|
||||
setStatus("");
|
||||
setError("");
|
||||
setIsJoining(false);
|
||||
setFocusIndex(0);
|
||||
setConnectionMethod("direct");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && validInstances.length > 0 && !selectedInstance) {
|
||||
setSelectedInstance(validInstances[0].instanceId);
|
||||
}
|
||||
}, [isOpen, validInstances, selectedInstance]);
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!invite || !selectedInstance) return;
|
||||
playPressSound();
|
||||
setIsJoining(true);
|
||||
setError("");
|
||||
setStatus("Accepting invite...");
|
||||
try {
|
||||
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId);
|
||||
const hostIp = inviteData.hostIp || (typeof invite.from !== 'string' && (invite as any).from?.hostIp);
|
||||
const hostPort = inviteData.hostPort || invite.hostPort;
|
||||
const sessionId = inviteData.signalingSessionId || invite.signalingSessionId || "";
|
||||
|
||||
if (connectionMethod === "relay") {
|
||||
if (!sessionId) {
|
||||
throw new Error("Relay connection is not available for this invite.");
|
||||
}
|
||||
setStatus("Connecting via relay...");
|
||||
const baseUrl = lceLiveService.apiBaseUrl;
|
||||
const accessToken = lceLiveService.accessToken ?? "";
|
||||
const port = await TauriService.startRelayProxy(baseUrl, accessToken, sessionId);
|
||||
setStatus("Launching game...");
|
||||
await TauriService.launchGame(selectedInstance, [
|
||||
{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port }
|
||||
]);
|
||||
} else {
|
||||
setStatus("Launching game...");
|
||||
await TauriService.stopProxy();
|
||||
await TauriService.launchGame(selectedInstance, [
|
||||
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
|
||||
]);
|
||||
}
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
setStatus("");
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
playBackSound();
|
||||
if (isJoining) return;
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
|
||||
setFocusIndex((prev) => (prev + 1) % max);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
|
||||
setFocusIndex((prev) => (prev - 1 + max) % max);
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0 && validInstances.length > 0) {
|
||||
const currentIdx = validInstances.findIndex((i: any) => i.instanceId === selectedInstance);
|
||||
const next = (currentIdx + 1) % validInstances.length;
|
||||
setSelectedInstance(validInstances[next].instanceId);
|
||||
playPressSound();
|
||||
} else if (focusIndex === 1 && !isJoining) {
|
||||
handleJoin();
|
||||
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[450px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{!isJoining ? (
|
||||
<>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Join Game
|
||||
</h2>
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
Joining {hostName}'s game. Choose an instance:
|
||||
</p>
|
||||
|
||||
{validInstances.length > 0 ? (
|
||||
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
|
||||
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
|
||||
{validInstances.map((inst: any) => {
|
||||
const isSelected = selectedInstance === inst.instanceId;
|
||||
return (
|
||||
<div
|
||||
key={inst.instanceId}
|
||||
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
|
||||
{inst.selectedBranch && (
|
||||
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-400 text-sm mc-text-shadow mb-4 text-center">
|
||||
No installed instances available. Install a version first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="w-full mb-3 flex flex-col items-center gap-1">
|
||||
<span className="text-gray-400 text-xs mc-text-shadow uppercase tracking-wider">
|
||||
Connection Method
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConnectionMethod("direct")}
|
||||
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
|
||||
connectionMethod === "direct"
|
||||
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
|
||||
: "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
Direct
|
||||
</button>
|
||||
<button
|
||||
onClick={() => hasRelay && setConnectionMethod("relay")}
|
||||
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
|
||||
connectionMethod === "relay"
|
||||
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
|
||||
: hasRelay
|
||||
? "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
|
||||
: "text-gray-600 bg-black/10 border-l-2 border-transparent cursor-not-allowed"
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
title={!hasRelay ? "Relay not available for this invite" : "Connect via LCELive relay server"}
|
||||
>
|
||||
Relay
|
||||
</button>
|
||||
</div>
|
||||
{!hasRelay && (
|
||||
<span className="text-gray-600 text-[10px] mc-text-shadow">Relay not available for this invite</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
setFocusIndex(cancelIdx);
|
||||
}}
|
||||
onClick={() => { playBackSound(); onClose(); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
|
||||
})()}`}
|
||||
style={{
|
||||
backgroundImage: (() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
return focusIndex === cancelIdx
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')";
|
||||
})(),
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{validInstances.length > 0 && (
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
onClick={handleJoin}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Joining Game
|
||||
</h2>
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export default function SetUidModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
instances,
|
||||
installedVersions,
|
||||
targetInstanceId,
|
||||
}: any) {
|
||||
const [mode, setMode] = useState<"manual" | "copy">("manual");
|
||||
const [uid, setUid] = useState("0xFF02F0C87E8AC1F2");
|
||||
const [selectedInstance, setSelectedInstance] = useState("");
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode("manual");
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
setSelectedInstance("");
|
||||
setError("");
|
||||
setFocusIndex(0);
|
||||
setIsDropdownOpen(false);
|
||||
} else if (targetInstanceId) {
|
||||
(async () => {
|
||||
try {
|
||||
const targetPath = await TauriService.getInstancePath(targetInstanceId);
|
||||
const data = await TauriService.readBinaryFile(`${targetPath}/uid.dat`);
|
||||
const currentUid = new TextDecoder().decode(data);
|
||||
if (currentUid.trim()) {
|
||||
setUid(currentUid);
|
||||
} else {
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
}
|
||||
} catch (e) {
|
||||
setUid("0xFF02F0C87E8AC1F2");
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isOpen, targetInstanceId]);
|
||||
|
||||
const validInstances = instances.filter((i: any) => installedVersions.includes(i.instanceId) && i.instanceId !== targetInstanceId);
|
||||
const handleSave = async () => {
|
||||
playPressSound("save_click.wav");
|
||||
try {
|
||||
let finalUid = uid;
|
||||
if (mode === "copy") {
|
||||
if (!selectedInstance) {
|
||||
setError("Select an instance to copy from.");
|
||||
return;
|
||||
}
|
||||
const sourcePath = await TauriService.getInstancePath(selectedInstance);
|
||||
try {
|
||||
const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`);
|
||||
finalUid = new TextDecoder().decode(sourceData);
|
||||
} catch (e) {
|
||||
setError("Source instance has no uid.dat or it could not be read.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalUid) {
|
||||
setError("UID cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedUid = new TextEncoder().encode(finalUid);
|
||||
const targetPath = await TauriService.getInstancePath(targetInstanceId);
|
||||
await TauriService.writeBinaryFile(`${targetPath}/uid.dat`, encodedUid);
|
||||
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev + 1) % 5);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + 5) % 5);
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0) {
|
||||
playPressSound();
|
||||
setMode("manual");
|
||||
} else if (focusIndex === 1) {
|
||||
playPressSound();
|
||||
setMode("copy");
|
||||
} else if (focusIndex === 2 && mode === "copy") {
|
||||
playPressSound();
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
} else if (focusIndex === 3) {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
} else if (focusIndex === 4) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, focusIndex, mode, uid, selectedInstance, isDropdownOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
|
||||
Set UID
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-4 mb-5 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={() => { playPressSound(); setMode("manual"); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 0 ? "text-[#FFFF55]" : mode === "manual" ? "text-white" : "text-gray-400"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 0 || mode === "manual"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
onClick={() => { playPressSound(); setMode("copy"); }}
|
||||
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 1 ? "text-[#FFFF55]" : mode === "copy" ? "text-white" : "text-gray-400"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 1 || mode === "copy"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full min-h-[80px]">
|
||||
{mode === "manual" ? (
|
||||
<div className="flex flex-col gap-1 items-center w-full">
|
||||
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
|
||||
Enter UID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
onFocus={() => setFocusIndex(2)}
|
||||
placeholder="0xFF02F0C87E8AC1F2"
|
||||
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} text-white text-base outline-none font-['Mojangles'] text-center`}
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 items-center w-full relative">
|
||||
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
|
||||
Select Installed Instance
|
||||
</label>
|
||||
|
||||
<div
|
||||
onClick={() => { playPressSound(); setIsDropdownOpen(!isDropdownOpen); }}
|
||||
onFocus={() => { setFocusIndex(2); setIsDropdownOpen(false); }}
|
||||
tabIndex={0}
|
||||
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-['Mojangles'] cursor-pointer`}
|
||||
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedInstance
|
||||
? (() => {
|
||||
const i = validInstances.find((inst: any) => inst.instanceId === selectedInstance);
|
||||
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --";
|
||||
})()
|
||||
: "-- Select an Instance --"}
|
||||
</span>
|
||||
<span className="text-xs">▼</span>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && validInstances.length > 0 && (
|
||||
<div className="absolute top-[60px] left-0 w-full max-h-40 overflow-y-auto bg-black/90 border-2 border-[#373737] z-50 flex flex-col custom-scrollbar shadow-xl" style={{ imageRendering: "pixelated" }}>
|
||||
{validInstances.map((i: any) => (
|
||||
<div
|
||||
key={i.instanceId}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setSelectedInstance(i.instanceId);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="px-3 py-2 text-white text-sm cursor-pointer hover:bg-white/20 transition-colors truncate font-['Mojangles']"
|
||||
>
|
||||
{i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validInstances.length === 0 && (
|
||||
<p className="text-red-400 text-xs text-center mt-1">No other installed instances available.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6 w-full justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(3)}
|
||||
onClick={() => {
|
||||
playBackSound("close_click.wav");
|
||||
onClose();
|
||||
}}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 3 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 3
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onMouseEnter={() => setFocusIndex(4)}
|
||||
onClick={handleSave}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 4 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 4
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { GrfService } from "../../services/GrfService";
|
||||
import { GrfFile, GrfNode } from "../../types/grf";
|
||||
|
||||
import { GrfFile, GrfNode, GrfFileEntry } from "../../types/grf";
|
||||
export default function GrfEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
@@ -12,9 +11,8 @@ export default function GrfEditorView() {
|
||||
const [filename, setFilename] = useState("game_rules.grf");
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"rules" | "files">("rules");
|
||||
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
@@ -37,6 +35,60 @@ export default function GrfEditorView() {
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleNewGrf = () => {
|
||||
playPressSound();
|
||||
setGrf(GrfService.createDefaultGRF());
|
||||
setFilename("new_rules.grf");
|
||||
showNotification("New GRF Created");
|
||||
};
|
||||
|
||||
const handleAddFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !grf) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const newFile: GrfFileEntry = {
|
||||
filename: file.name,
|
||||
data: new Uint8Array(buffer)
|
||||
};
|
||||
setGrf({
|
||||
...grf,
|
||||
files: [...grf.files, newFile]
|
||||
});
|
||||
showNotification(`Added ${file.name}`);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number) => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
const newFiles = [...grf.files];
|
||||
const removed = newFiles.splice(index, 1)[0];
|
||||
setGrf({ ...grf, files: newFiles });
|
||||
showNotification(`Removed ${removed.filename}`);
|
||||
};
|
||||
|
||||
const handleUpdateParameter = (nodePath: string[], paramIndex: number, value: string) => {
|
||||
if (!grf) return;
|
||||
const updateNode = (node: GrfNode, path: string[]): GrfNode => {
|
||||
if (path.length === 0) {
|
||||
if (!node.parameters[paramIndex]) return node;
|
||||
const newParams = [...node.parameters];
|
||||
newParams[paramIndex] = { ...newParams[paramIndex], value };
|
||||
return { ...node, parameters: newParams };
|
||||
}
|
||||
const [next, ...rest] = path;
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(child => child.name === next ? updateNode(child, rest) : child)
|
||||
};
|
||||
};
|
||||
setGrf({
|
||||
...grf,
|
||||
root: updateNode(grf.root, nodePath)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveGrf = () => {
|
||||
if (!grf) return;
|
||||
playPressSound();
|
||||
@@ -65,12 +117,20 @@ export default function GrfEditorView() {
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" />
|
||||
<input type="file" ref={addFileInputRef} onChange={handleAddFile} className="hidden" />
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">GRF Editor</h2>
|
||||
{grf && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{filename}</span></span>}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleNewGrf}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
New GRF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
@@ -115,31 +175,49 @@ export default function GrfEditorView() {
|
||||
{activeTab === "rules" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grf.root.children.map((node, i) => (
|
||||
<GrfNodeView key={i} node={node} level={0} />
|
||||
<GrfNodeView key={i} node={node} level={0} path={[]} onUpdate={handleUpdateParameter} />
|
||||
))}
|
||||
{grf.root.children.length === 0 && <span className="text-white/40 italic">No rules found</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#252525]">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={2} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors">
|
||||
<td className="p-3 text-white font-medium">{f.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(f.data.length / 1024).toFixed(2)} KB</td>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={() => addFileInputRef.current?.click()}
|
||||
className="self-start px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add File
|
||||
</button>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#252525]">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-center">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grf.files.length === 0 && (
|
||||
<tr><td colSpan={3} className="p-4 text-center text-white/40">No files in GRF</td></tr>
|
||||
)}
|
||||
{grf.files.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors">
|
||||
<td className="p-3 text-white font-medium">{f.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(f.data.length / 1024).toFixed(2)} KB</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteFile(i)}
|
||||
className="text-[#FF5555] hover:text-[#FF8888] transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,11 +252,13 @@ export default function GrfEditorView() {
|
||||
);
|
||||
}
|
||||
|
||||
function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
function GrfNodeView({ node, level, path, onUpdate }: { node: GrfNode, level: number, path: string[], onUpdate: (path: string[], paramIdx: number, val: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(level < 1);
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-1 select-none">
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-white/10 cursor-pointer transition-colors"
|
||||
style={{ paddingLeft: `${level * 24 + 8}px` }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
@@ -206,15 +286,20 @@ function GrfNodeView({ node, level }: { node: GrfNode, level: number }) {
|
||||
{node.parameters.length > 0 && (
|
||||
<div className="flex flex-col bg-black/20 p-2 mb-2 ml-4">
|
||||
{node.parameters.map((p, i) => (
|
||||
<div key={i} className="flex gap-4 border-b border-[#373737]/30 py-1 text-sm">
|
||||
<div key={i} className="flex gap-4 border-b border-[#373737]/30 py-2 text-sm items-center">
|
||||
<span className="text-[#AAAAAA] w-1/3 truncate">{p.name}</span>
|
||||
<span className="text-white flex-1 whitespace-pre-wrap font-mono">{p.value}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={p.value}
|
||||
onChange={(e) => onUpdate(currentPath, i, e.target.value)}
|
||||
className="flex-1 bg-white/5 border border-white/10 px-2 py-1 text-white outline-none focus:border-[#FFFF55]/50 font-mono transition-colors"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{node.children.map((child, i) => (
|
||||
<GrfNodeView key={i} node={child} level={level + 1} />
|
||||
<GrfNodeView key={i} node={child} level={level + 1} path={currentPath} onUpdate={onUpdate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ const HomeView = memo(function HomeView() {
|
||||
downloadingId,
|
||||
isGameRunning,
|
||||
stopGame,
|
||||
updatesAvailable,
|
||||
} = useGame();
|
||||
|
||||
const isFocusedSection = focusSection === "menu";
|
||||
@@ -47,17 +48,37 @@ const HomeView = memo(function HomeView() {
|
||||
: isGameRunning
|
||||
? stopGame
|
||||
: isDownloading
|
||||
? () => { }
|
||||
? () => {}
|
||||
: isInstalled
|
||||
? handleLaunch
|
||||
: () => toggleInstall(profile),
|
||||
isDanger: isGameRunning,
|
||||
disabled: isDownloading,
|
||||
},
|
||||
{ label: "Help & Options", action: () => setActiveView("settings"), disabled: false },
|
||||
{ label: "Versions", action: () => setActiveView("versions"), disabled: false },
|
||||
{ label: "Workshop", action: () => setActiveView("workshop"), disabled: false },
|
||||
{ label: "Developer Tools", action: () => setActiveView("devtools"), disabled: false },
|
||||
{
|
||||
label: "Help & Options",
|
||||
action: () => setActiveView("settings"),
|
||||
disabled: false,
|
||||
id: "settings",
|
||||
},
|
||||
{
|
||||
label: "Versions",
|
||||
action: () => setActiveView("versions"),
|
||||
disabled: false,
|
||||
id: "versions",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
action: () => setActiveView("workshop"),
|
||||
disabled: false,
|
||||
id: "workshop",
|
||||
},
|
||||
{
|
||||
label: "Developer Tools",
|
||||
action: () => setActiveView("devtools"),
|
||||
disabled: false,
|
||||
id: "devtools",
|
||||
},
|
||||
],
|
||||
[
|
||||
isDownloading,
|
||||
@@ -110,7 +131,9 @@ const HomeView = memo(function HomeView() {
|
||||
{buttons.map((btn: any, i: number) => (
|
||||
<div key={i} className="relative w-full group">
|
||||
<button
|
||||
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
|
||||
onMouseEnter={() =>
|
||||
isFocusedSection && !btn.disabled && setMenuFocus(i)
|
||||
}
|
||||
onMouseLeave={() => setMenuFocus(null)}
|
||||
onClick={() => {
|
||||
if (isFocusedSection && !btn.disabled) {
|
||||
@@ -131,7 +154,21 @@ const HomeView = memo(function HomeView() {
|
||||
opacity: btn.disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="w-full text-center">{btn.label}</span>
|
||||
<div className="w-full h-full flex items-center justify-center relative">
|
||||
<span>{btn.label}</span>
|
||||
{btn.id === "versions" &&
|
||||
Object.values(updatesAvailable || {}).some((v) => v) && (
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
className="absolute right-4 w-6 h-6 object-contain"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
filter:
|
||||
"drop-shadow(0 0 2px rgba(255, 255, 0, 0.8)) sepia(100%) saturate(500%) hue-rotate(5deg) brightness(1.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -140,7 +177,7 @@ const HomeView = memo(function HomeView() {
|
||||
<div className="pt-4 flex flex-col items-center w-full gap-3">
|
||||
<div className="flex gap-8">
|
||||
<a
|
||||
href="https://discord.gg/NPMp9JhGYH"
|
||||
href="https://discord.gg/cQVKhQXcCx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
import {
|
||||
lceLiveService,
|
||||
LceLiveAccount,
|
||||
FriendRequest,
|
||||
GameInvite,
|
||||
} from "../../services/LceLiveService";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
|
||||
|
||||
const LceLiveView = memo(function LceLiveView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { editions, installs } = useGame();
|
||||
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
|
||||
const [currentTab, setCurrentTab] = useState<
|
||||
"friends" | "requests" | "invites" | "device_link"
|
||||
>("friends");
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(0);
|
||||
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
|
||||
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
|
||||
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
|
||||
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
|
||||
const [invites, setInvites] = useState<GameInvite[]>([]);
|
||||
const [linkData, setLinkData] = useState<any>(null);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const [isHosting, setIsHosting] = useState(false);
|
||||
const [hostStatus, setHostStatus] = useState("");
|
||||
const [hostIp, setHostIp] = useState("");
|
||||
const [hostPort, setHostPort] = useState(19132);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
|
||||
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
|
||||
const [hostSessionId, setHostSessionId] = useState<string>("");
|
||||
const [hostMethodPick, setHostMethodPick] = useState<"direct" | "relay">(
|
||||
"direct",
|
||||
);
|
||||
const [relayStarted, setRelayStarted] = useState(false);
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
const [addFriendUsername, setAddFriendUsername] = useState("");
|
||||
const addFriendInputRef = useRef<HTMLInputElement>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fetchSocialData = async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
try {
|
||||
const [f, reqs, invs] = await Promise.all([
|
||||
lceLiveService.getFriends(),
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites(),
|
||||
]);
|
||||
setFriends(f);
|
||||
setIncomingReqs(reqs.incoming);
|
||||
setOutgoingReqs(reqs.outgoing);
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
if (currentTab === "device_link") setCurrentTab("friends");
|
||||
fetchSocialData();
|
||||
const pollInvites = setInterval(async () => {
|
||||
try {
|
||||
const invs = await lceLiveService.getGameInvites();
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
} catch (e) {
|
||||
console.warn("Failed to poll invites", e);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInvites);
|
||||
} else {
|
||||
setCurrentTab("device_link");
|
||||
}
|
||||
}, [isSignedIn, currentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTab !== "device_link") return;
|
||||
let mounted = true;
|
||||
let pollInterval: any = null;
|
||||
const startLink = async () => {
|
||||
try {
|
||||
if (!linkData) {
|
||||
const data = await lceLiveService.startDeviceLink();
|
||||
if (mounted) setLinkData(data);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (mounted) setLinkError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
startLink();
|
||||
if (linkData?.deviceCode) {
|
||||
pollInterval = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const res = await lceLiveService.pollDeviceLink(
|
||||
linkData.deviceCode,
|
||||
);
|
||||
if (res.isLinked && mounted) {
|
||||
setIsSignedIn(true);
|
||||
setLinkData(null);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn("Poll failed", e);
|
||||
}
|
||||
},
|
||||
Math.max(linkData.intervalSeconds * 1000, 2000),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, [currentTab, linkData]);
|
||||
|
||||
const handleLogout = () => {
|
||||
playPressSound();
|
||||
lceLiveService.logoutLocal();
|
||||
setIsSignedIn(false);
|
||||
setLinkData(null);
|
||||
};
|
||||
|
||||
const handleAction = async (action: () => Promise<void>) => {
|
||||
playPressSound();
|
||||
try {
|
||||
await action();
|
||||
fetchSocialData();
|
||||
} catch (e: any) {
|
||||
setErrorModal(e.message || "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartHosting = () => {
|
||||
playPressSound();
|
||||
setRelayStarted(false);
|
||||
setShowHostMethodPicker(true);
|
||||
};
|
||||
|
||||
const handleHostDirect = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP...");
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
setIsHosting(true);
|
||||
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
|
||||
setInvitedFriends(new Set());
|
||||
setHostSessionId("");
|
||||
} catch (e: any) {
|
||||
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
|
||||
setErrorModal("STUN discovery failed: " + msg);
|
||||
setHostStatus("");
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostRelay = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP for invite...");
|
||||
const sessionId = crypto.randomUUID();
|
||||
setHostSessionId(sessionId);
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
} catch {
|
||||
setHostIp("127.0.0.1");
|
||||
setHostPort(25565);
|
||||
}
|
||||
setRelayStarted(false);
|
||||
setIsHosting(true);
|
||||
setHostStatus("Relay ready - invite friends to activate");
|
||||
setInvitedFriends(new Set());
|
||||
setIsDiscovering(false);
|
||||
};
|
||||
|
||||
const handleStopHosting = async () => {
|
||||
playPressSound();
|
||||
try {
|
||||
await TauriService.stopProxy();
|
||||
await lceLiveService.deactivateGameInvites();
|
||||
} catch (e: any) {
|
||||
console.warn("Stop hosting failed", e);
|
||||
}
|
||||
setIsHosting(false);
|
||||
setHostStatus("");
|
||||
setHostIp("");
|
||||
setInvitedFriends(new Set());
|
||||
setHostSessionId("");
|
||||
setRelayStarted(false);
|
||||
};
|
||||
|
||||
const handleInviteFriend = async (friend: LceLiveAccount) => {
|
||||
playPressSound();
|
||||
const name = lceLiveService.displayUsername;
|
||||
try {
|
||||
await lceLiveService.sendGameInvite(
|
||||
friend.accountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
name,
|
||||
hostSessionId || undefined,
|
||||
);
|
||||
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
|
||||
if (hostSessionId && !relayStarted) {
|
||||
setRelayStarted(true);
|
||||
setHostStatus("Connecting relay...");
|
||||
const baseUrl = lceLiveService.apiBaseUrl;
|
||||
const accessToken = lceLiveService.accessToken ?? "";
|
||||
TauriService.startHostRelay(baseUrl, accessToken, hostSessionId, 25565)
|
||||
.then(() => setHostStatus("Relay active"))
|
||||
.catch((relayErr: any) => {
|
||||
const relayMsg =
|
||||
typeof relayErr === "string"
|
||||
? relayErr
|
||||
: relayErr?.message || "Unknown error";
|
||||
console.warn("Relay failed:", relayMsg);
|
||||
setHostStatus("Relay disconnected");
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
|
||||
setErrorModal("Failed to send invite: " + msg);
|
||||
}
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
type: "button" | "friend" | "request_in" | "request_out" | "invite";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClickSecondary?: () => void;
|
||||
};
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [];
|
||||
if (currentTab === "device_link") {
|
||||
if (linkData) {
|
||||
items.push({
|
||||
id: "link_retry",
|
||||
type: "button",
|
||||
label: "Restart Link",
|
||||
onClick: () => {
|
||||
setLinkData(null);
|
||||
playPressSound();
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (currentTab === "friends") {
|
||||
if (!isDiscovering && !showHostMethodPicker) {
|
||||
if (!isHosting) {
|
||||
items.push({
|
||||
id: "host_game",
|
||||
type: "button",
|
||||
label: "Host Game",
|
||||
onClick: handleStartHosting,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: "stop_hosting",
|
||||
type: "button",
|
||||
label: "Stop Hosting",
|
||||
onClick: handleStopHosting,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
id: "add_friend",
|
||||
type: "button",
|
||||
label: "Add Friend",
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setIsAddingFriend(true);
|
||||
setAddFriendUsername("");
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "sign_out",
|
||||
type: "button",
|
||||
label: "Sign Out",
|
||||
onClick: handleLogout,
|
||||
});
|
||||
friends.forEach((f) => {
|
||||
items.push({
|
||||
id: `friend_${f.accountId}`,
|
||||
type: "friend",
|
||||
label: f.displayName,
|
||||
onClick: isHosting
|
||||
? () => handleInviteFriend(f)
|
||||
: () =>
|
||||
handleAction(() => lceLiveService.removeFriend(f.accountId)),
|
||||
onClickSecondary: isHosting
|
||||
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "requests") {
|
||||
incomingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_in_${r.accountId}`,
|
||||
type: "request_in",
|
||||
label: r.displayName,
|
||||
onClick: () =>
|
||||
handleAction(() => lceLiveService.acceptFriendRequest(r.accountId)),
|
||||
onClickSecondary: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
});
|
||||
});
|
||||
outgoingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_out_${r.accountId}`,
|
||||
type: "request_out",
|
||||
label: r.displayName,
|
||||
onClick: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "invites") {
|
||||
invites.forEach((inv) => {
|
||||
items.push({
|
||||
id: `inv_${inv.inviteId}`,
|
||||
type: "invite",
|
||||
label:
|
||||
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setAcceptInvite(inv);
|
||||
},
|
||||
onClickSecondary: () =>
|
||||
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
currentTab,
|
||||
friends,
|
||||
incomingReqs,
|
||||
outgoingReqs,
|
||||
invites,
|
||||
linkData,
|
||||
playPressSound,
|
||||
isHosting,
|
||||
isDiscovering,
|
||||
showHostMethodPicker,
|
||||
]);
|
||||
|
||||
const tabs = ["friends", "requests", "invites"];
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (errorModal) {
|
||||
if (e.key === "Escape" || e.key === "Enter") {
|
||||
setErrorModal(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAddingFriend) {
|
||||
if (e.key === "Escape") {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
} else if (e.key === "Enter") {
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
|
||||
);
|
||||
setIsAddingFriend(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHostMethodPicker) {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
setShowHostMethodPicker(false);
|
||||
playBackSound();
|
||||
} else if (
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "q" ||
|
||||
e.key === "Q" ||
|
||||
e.key === "e" ||
|
||||
e.key === "E"
|
||||
) {
|
||||
setHostMethodPick((prev: "direct" | "relay") =>
|
||||
prev === "direct" ? "relay" : "direct",
|
||||
);
|
||||
playPressSound();
|
||||
} else if (e.key === "Enter") {
|
||||
if (hostMethodPick === "direct") handleHostDirect();
|
||||
else handleHostRelay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTab !== "device_link") {
|
||||
const curIdx = tabs.indexOf(currentTab);
|
||||
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
|
||||
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
|
||||
setCurrentTab(next as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
return;
|
||||
}
|
||||
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
|
||||
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
|
||||
setCurrentTab(next as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const itemCount = menuItems.length;
|
||||
if (itemCount > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
setFocusIndex((prev) =>
|
||||
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
|
||||
);
|
||||
} else if (e.key === "Enter" && focusIndex !== null) {
|
||||
menuItems[focusIndex]?.onClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
focusIndex,
|
||||
menuItems,
|
||||
currentTab,
|
||||
playBackSound,
|
||||
setActiveView,
|
||||
isAddingFriend,
|
||||
addFriendUsername,
|
||||
errorModal,
|
||||
showHostMethodPicker,
|
||||
hostMethodPick,
|
||||
handleHostDirect,
|
||||
handleHostRelay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddingFriend && addFriendInputRef.current) {
|
||||
addFriendInputRef.current.focus();
|
||||
} else if (focusIndex !== null) {
|
||||
const el = containerRef.current?.querySelector(
|
||||
`[data-index="${focusIndex}"]`,
|
||||
) as HTMLElement;
|
||||
if (el) {
|
||||
el.focus();
|
||||
if (scrollRef.current) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > scrollRect.bottom || rect.top < scrollRect.top) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusIndex, isAddingFriend]);
|
||||
const renderContent = () => {
|
||||
if (currentTab === "device_link") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-6 flex-1 text-center py-20">
|
||||
{!linkData ? (
|
||||
<p className="text-xl text-[#2a2a2a] font-bold">
|
||||
{linkError || "Starting device link..."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl text-[#2a2a2a] font-bold">
|
||||
Open this link in your browser:
|
||||
</p>
|
||||
<p className="text-[#111] text-3xl font-bold tracking-widest break-words bg-black/10 px-6 py-2 rounded shadow-inner">
|
||||
{linkData.verificationUri}
|
||||
</p>
|
||||
<p className="text-2xl text-[#2a2a2a] font-bold mt-8">
|
||||
And enter the code:
|
||||
</p>
|
||||
<p className="text-[#111] text-6xl tracking-[0.2em] font-bold mt-2 bg-black/10 px-8 py-4 rounded shadow-inner">
|
||||
{linkData.userCode}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topButtons = menuItems.filter((m) => m.type === "button");
|
||||
const listItems = menuItems.filter((m) => m.type !== "button");
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-4">
|
||||
{showHostMethodPicker ? (
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={handleHostDirect}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "direct" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
hostMethodPick === "direct"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Direct (STUN)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHostRelay}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "relay" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
hostMethodPick === "relay"
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Relay
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowHostMethodPicker(false);
|
||||
playBackSound();
|
||||
}}
|
||||
className="flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all text-white mc-text-shadow hover:text-gray-200"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
topButtons.length > 0 && (
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{topButtons.map((btn) => {
|
||||
const idx = menuItems.indexOf(btn);
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
data-index={idx}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
onClick={btn.onClick}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${isFocused ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
|
||||
style={{
|
||||
backgroundImage: isFocused
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{hostStatus && (
|
||||
<div className="text-center text-sm text-[#FFFF55] mc-text-shadow py-1 tracking-wider">
|
||||
{hostStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
|
||||
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
|
||||
<span>
|
||||
{currentTab === "friends"
|
||||
? "Joinable Friends"
|
||||
: currentTab === "requests"
|
||||
? "Pending Requests"
|
||||
: "Game Invites"}
|
||||
</span>
|
||||
<span className="text-[#111]">{listItems.length}</span>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto w-full">
|
||||
{listItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[200px] text-[#555] font-bold">
|
||||
None available
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col p-2 space-y-2">
|
||||
{listItems.map((item) => {
|
||||
const idx = menuItems.indexOf(item);
|
||||
const isFocused = focusIndex === idx;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={idx}
|
||||
onMouseEnter={() => setFocusIndex(idx)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 relative outline-none border-none rounded transition-all ${isFocused ? "bg-black/15 shadow-inner" : "bg-transparent hover:bg-black/5"}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex flex-col ml-2 flex-1 min-w-0">
|
||||
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[#555] text-base font-bold truncate">
|
||||
@
|
||||
{menuItems.find((m) => m.id === item.id)?.type ===
|
||||
"friend"
|
||||
? friends.find(
|
||||
(f) => `friend_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_in"
|
||||
? incomingReqs.find(
|
||||
(f) => `req_in_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_out"
|
||||
? outgoingReqs.find(
|
||||
(f) => `req_out_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: "Invite"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 pr-2 shrink-0">
|
||||
{item.type === "friend" && !isHosting && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
)}
|
||||
{item.type === "friend" && isHosting && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{invitedFriends.has(
|
||||
item.id.replace("friend_", ""),
|
||||
)
|
||||
? "INVITED"
|
||||
: "INVITE"}
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{item.type === "request_out" && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
)}
|
||||
{(item.type === "request_in" ||
|
||||
item.type === "invite") && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
ACCEPT
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
DECLINE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
|
||||
>
|
||||
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
|
||||
{currentTab !== "device_link" && (
|
||||
<div
|
||||
className="flex z-10 space-x-2 px-12 relative w-full items-end"
|
||||
style={{ marginBottom: "-4px" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 font-bold text-xl outline-none uppercase transition-all duration-200 ease-in-out ${currentTab === t ? "text-[#2a2a2a] z-20 pb-6 pt-5 text-2xl drop-shadow-[5px_-5px_15px_rgba(0,0,0,0.3)] rounded-t border-4 border-[#222] border-b-0" : "text-[#555] mt-2 py-4 hover:bg-black/30 bg-black/10 hover:text-[#222] border-4 border-transparent border-b-0"}`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "bottom",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setCurrentTab(t as any);
|
||||
setFocusIndex(0);
|
||||
playPressSound();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{t}
|
||||
{t === "requests" && incomingReqs.length > 0 && (
|
||||
<span
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
|
||||
>
|
||||
{incomingReqs.length}
|
||||
</span>
|
||||
)}
|
||||
{t === "invites" && invites.length > 0 && (
|
||||
<span
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
|
||||
>
|
||||
{invites.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 flex flex-col p-8 z-10 relative overflow-hidden rounded-b shadow-[0_0_30px_rgba(0,0,0,0.6)] border-4 border-[#222] border-t-0"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% auto",
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundPosition: "top",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isAddingFriend && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-6 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
Add Friend
|
||||
</h2>
|
||||
<input
|
||||
ref={addFriendInputRef}
|
||||
type="text"
|
||||
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
|
||||
placeholder="Username"
|
||||
value={addFriendUsername}
|
||||
onChange={(e) => setAddFriendUsername(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-4 w-full">
|
||||
<button
|
||||
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(
|
||||
addFriendUsername.trim(),
|
||||
),
|
||||
);
|
||||
setIsAddingFriend(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsAddingFriend(false);
|
||||
playBackSound();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[400px] p-8 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
Error
|
||||
</h2>
|
||||
<p className="text-white text-lg mc-text-shadow text-center mb-6">
|
||||
{errorModal}
|
||||
</p>
|
||||
<button
|
||||
className="h-12 w-48 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => setErrorModal(null)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ChooseInstanceModal
|
||||
isOpen={acceptInvite !== null}
|
||||
onClose={() => {
|
||||
setAcceptInvite(null);
|
||||
fetchSocialData();
|
||||
}}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editions={editions}
|
||||
installs={installs}
|
||||
invite={acceptInvite}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default LceLiveView;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ScreenshotService,
|
||||
ScreenshotInfo,
|
||||
} from "../../services/ScreenshotService";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
const ScreenshotsView = memo(function ScreenshotsView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
@@ -207,15 +208,12 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full relative overflow-hidden bg-black/50">
|
||||
<img
|
||||
src={`screenshots://localhost/${ss.path.replace(/\\/g, "/")}`}
|
||||
<ScreenshotImage
|
||||
path={ss.path}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
alt={ss.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/images/Folder_Icon.png";
|
||||
}}
|
||||
fallbackSrc="/images/Folder_Icon.png"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" />
|
||||
|
||||
@@ -288,13 +286,10 @@ const ScreenshotsView = memo(function ScreenshotsView() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative w-full aspect-video bg-black/60 overflow-hidden border border-[#444] rounded-sm">
|
||||
<img
|
||||
src={`screenshots://localhost/${selectedScreenshot.path.replace(/\\/g, "/")}`}
|
||||
<ScreenshotImage
|
||||
path={selectedScreenshot.path}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/images/Pack_Icon.png";
|
||||
}}
|
||||
fallbackSrc="/images/Pack_Icon.png"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-6 right-6 flex items-end justify-between pointer-events-none">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -2,16 +2,56 @@ import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService, Runner } from "../../services/TauriService";
|
||||
import { usePlatform } from "../../hooks/usePlatform";
|
||||
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
|
||||
const SettingsView = memo(function SettingsView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode } = useConfig();
|
||||
const { currentTrack, setCurrentTrack, tracks, playPressSound, playBackSound } = useAudio();
|
||||
const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
|
||||
const {
|
||||
vfxEnabled,
|
||||
setVfxEnabled,
|
||||
animationsEnabled,
|
||||
setAnimationsEnabled,
|
||||
musicVol: musicVolume,
|
||||
setMusicVol: setMusicVolume,
|
||||
sfxVol: sfxVolume,
|
||||
setSfxVol: setSfxVolume,
|
||||
layout,
|
||||
setLayout,
|
||||
linuxRunner,
|
||||
setLinuxRunner,
|
||||
perfBoost,
|
||||
setPerfBoost,
|
||||
rpcEnabled,
|
||||
setRpcEnabled,
|
||||
legacyMode,
|
||||
setLegacyMode,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
} = useConfig();
|
||||
const {
|
||||
currentTrack,
|
||||
setCurrentTrack,
|
||||
tracks,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
} = useAudio();
|
||||
const {
|
||||
isGameRunning,
|
||||
stopGame,
|
||||
isRunnerDownloading,
|
||||
runnerDownloadProgress,
|
||||
downloadRunner,
|
||||
} = useGame();
|
||||
const { isLinux, isMac } = usePlatform();
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
|
||||
const [currentSubMenu, setCurrentSubMenu] = useState<
|
||||
"main" | "audio" | "video" | "controls" | "launcher"
|
||||
>("main");
|
||||
const [runners, setRunners] = useState<Runner[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -53,6 +93,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
setLegacyMode(!legacyMode);
|
||||
};
|
||||
|
||||
const handleMangohudToggle = () => {
|
||||
playPressSound();
|
||||
setMangohudEnabled(!mangohudEnabled);
|
||||
};
|
||||
|
||||
const handleRunnerToggle = () => {
|
||||
playPressSound();
|
||||
if (runners.length === 0) return;
|
||||
@@ -69,35 +114,37 @@ const SettingsView = memo(function SettingsView() {
|
||||
const handleResetSetup = () => {
|
||||
playPressSound();
|
||||
|
||||
// Create styled confirmation dialog
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className =
|
||||
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
|
||||
dialog.innerHTML = `
|
||||
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
|
||||
<h3 class="text-2xl font-bold text-white mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">Reset Setup</h3>
|
||||
<p class="text-white mb-6 text-center">Are you sure you want to reset launcher setup?</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<button id="reset-yes" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Yes</button>
|
||||
<button id="reset-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">No</button>
|
||||
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
|
||||
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Reset Setup</h3>
|
||||
<p class="text-[#333333] mb-8 text-left w-full px-4">Are you sure you want to reset launcher setup?</p>
|
||||
<div class="flex flex-col gap-3 w-full px-4">
|
||||
<button id="reset-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
|
||||
<button id="reset-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const handleYes = () => {
|
||||
const handleOk = () => {
|
||||
document.body.removeChild(dialog);
|
||||
showSecondConfirmation();
|
||||
};
|
||||
|
||||
const handleNo = () => {
|
||||
const handleCancel = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
|
||||
dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
|
||||
dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
|
||||
dialog.querySelector("#reset-ok")?.addEventListener("click", handleOk);
|
||||
dialog
|
||||
.querySelector("#reset-cancel")
|
||||
?.addEventListener("click", handleCancel);
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
@@ -105,43 +152,48 @@ const SettingsView = memo(function SettingsView() {
|
||||
};
|
||||
|
||||
const showSecondConfirmation = () => {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className =
|
||||
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
|
||||
dialog.innerHTML = `
|
||||
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
|
||||
<h3 class="text-2xl font-bold text-yellow-400 mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">CONFIRM RESET</h3>
|
||||
<div class="text-white mb-6 text-left">
|
||||
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
|
||||
<h3 class="text-2xl font-bold text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
|
||||
<div class="text-[#333333] mb-6 text-left w-full px-4">
|
||||
<p class="mb-2">⚠️ This will:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<ul class="list-none space-y-1 text-sm">
|
||||
<li>Clear all launcher settings</li>
|
||||
<li>Reset your username</li>
|
||||
<li>Show setup screen again</li>
|
||||
<li>Require reconfiguration</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-yellow-400 font-bold">This action cannot be undone!</p>
|
||||
<p class="mt-3 text-[#333333] font-bold">This action cannot be undone!</p>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<button id="reset-final-yes" class="mc-sq-btn px-6 py-3 text-yellow-400 hover:scale-105 active:scale-95 transition-transform">YES, RESET</button>
|
||||
<button id="reset-final-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Cancel</button>
|
||||
<div class="flex flex-col gap-3 w-full px-4">
|
||||
<button id="reset-final-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
|
||||
<button id="reset-final-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const handleFinalYes = () => {
|
||||
const handleFinalOk = () => {
|
||||
document.body.removeChild(dialog);
|
||||
performReset();
|
||||
};
|
||||
|
||||
const handleFinalNo = () => {
|
||||
const handleFinalCancel = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
|
||||
dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
|
||||
dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
|
||||
dialog
|
||||
.querySelector("#reset-final-ok")
|
||||
?.addEventListener("click", handleFinalOk);
|
||||
dialog
|
||||
.querySelector("#reset-final-cancel")
|
||||
?.addEventListener("click", handleFinalCancel);
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
@@ -149,13 +201,10 @@ const SettingsView = memo(function SettingsView() {
|
||||
};
|
||||
|
||||
const performReset = () => {
|
||||
// Clear all localStorage data
|
||||
localStorage.clear();
|
||||
|
||||
// Set setup as not completed
|
||||
localStorage.setItem('lce-setup-completed', 'false');
|
||||
localStorage.setItem("lce-setup-completed", "false");
|
||||
|
||||
// Force reload to show setup screen
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -163,11 +212,9 @@ const SettingsView = memo(function SettingsView() {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const fullPath = tracks[currentTrack];
|
||||
if (fullPath) {
|
||||
trackName = fullPath
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(".ogg", "")
|
||||
.replace(".wav", "") || "Unknown";
|
||||
trackName =
|
||||
fullPath.split("/").pop()?.replace(".ogg", "").replace(".wav", "") ||
|
||||
"Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,20 +223,20 @@ const SettingsView = memo(function SettingsView() {
|
||||
|
||||
type SettingsItem =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "slider";
|
||||
value: number;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
id: string;
|
||||
label: string;
|
||||
type: "slider";
|
||||
value: number;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "button";
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
id: string;
|
||||
label: string;
|
||||
type: "button";
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const settingsItems = useMemo<SettingsItem[]>(() => {
|
||||
const items: SettingsItem[] = [];
|
||||
@@ -199,25 +246,41 @@ const SettingsView = memo(function SettingsView() {
|
||||
id: "audio_menu",
|
||||
label: "Audio",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("audio");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "video_menu",
|
||||
label: "Video",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("video");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "controls_menu",
|
||||
label: "Controls",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("controls");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "launcher_menu",
|
||||
label: "Launcher",
|
||||
type: "button",
|
||||
onClick: () => { playPressSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setCurrentSubMenu("launcher");
|
||||
setFocusIndex(0);
|
||||
},
|
||||
});
|
||||
} else if (currentSubMenu === "audio") {
|
||||
items.push({
|
||||
@@ -288,6 +351,12 @@ const SettingsView = memo(function SettingsView() {
|
||||
type: "button",
|
||||
onClick: handleRunnerToggle,
|
||||
});
|
||||
items.push({
|
||||
id: "mangohud",
|
||||
label: `MangoHud: ${mangohudEnabled ? "ON" : "OFF"}`,
|
||||
type: "button",
|
||||
onClick: handleMangohudToggle,
|
||||
});
|
||||
items.push({
|
||||
id: "download_runner",
|
||||
label: isRunnerDownloading
|
||||
@@ -296,7 +365,10 @@ const SettingsView = memo(function SettingsView() {
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
if (!isRunnerDownloading) {
|
||||
downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
|
||||
downloadRunner(
|
||||
"GE-Proton9-25",
|
||||
"https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz",
|
||||
);
|
||||
}
|
||||
},
|
||||
small: true,
|
||||
@@ -349,6 +421,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
animationsEnabled,
|
||||
layout,
|
||||
isLinux,
|
||||
mangohudEnabled,
|
||||
selectedRunnerName,
|
||||
isRunnerDownloading,
|
||||
runnerDownloadProgress,
|
||||
@@ -363,6 +436,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
handleLayoutToggle,
|
||||
handleRunnerToggle,
|
||||
handlePerfToggle,
|
||||
handleMangohudToggle,
|
||||
handleResetSetup,
|
||||
stopGame,
|
||||
downloadRunner,
|
||||
@@ -435,11 +509,16 @@ const SettingsView = memo(function SettingsView() {
|
||||
backgroundImage: "url('/images/Button_Background2.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated" as const,
|
||||
color: focusIndex === index ? "#FFFF55" : "white",
|
||||
color: focusIndex === index ? "#ffff00" : "white",
|
||||
});
|
||||
|
||||
const isToggleOption = (label: string): boolean => {
|
||||
return label.includes("ON") || label.includes("OFF") || label.includes("Enabled") || label.includes("Disabled");
|
||||
return (
|
||||
label.includes("ON") ||
|
||||
label.includes("OFF") ||
|
||||
label.includes("Enabled") ||
|
||||
label.includes("Disabled")
|
||||
);
|
||||
};
|
||||
|
||||
const getToggleState = (label: string): boolean => {
|
||||
@@ -454,10 +533,18 @@ const SettingsView = memo(function SettingsView() {
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-2xl outline-none"
|
||||
className="flex flex-col items-center w-full max-w-3xl outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
|
||||
{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "Video" : currentSubMenu === "controls" ? "Controls" : "Launcher"}
|
||||
{currentSubMenu === "main"
|
||||
? "Settings"
|
||||
: currentSubMenu === "audio"
|
||||
? "Audio"
|
||||
: currentSubMenu === "video"
|
||||
? "Video"
|
||||
: currentSubMenu === "controls"
|
||||
? "Controls"
|
||||
: "Launcher"}
|
||||
</h2>
|
||||
|
||||
{currentSubMenu === "main" ? (
|
||||
@@ -472,11 +559,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
|
||||
style={getSliderStyle(index)}
|
||||
>
|
||||
<span
|
||||
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : "text-white"}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -505,14 +592,15 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
onClick={item.onClick}
|
||||
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
|
||||
? focusIndex === index
|
||||
? "text-red-400"
|
||||
: "text-red-200"
|
||||
: focusIndex === index
|
||||
? "text-[#FFFF55]"
|
||||
: "text-white"
|
||||
} ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
|
||||
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${
|
||||
isRed
|
||||
? focusIndex === index
|
||||
? "text-red-400"
|
||||
: "text-red-200"
|
||||
: focusIndex === index
|
||||
? "text-[#ffff00]"
|
||||
: "text-white"
|
||||
} ${isRed ? "hover:text-red-500" : "hover:text-[#ffff00]"}`}
|
||||
style={getItemStyle(index)}
|
||||
>
|
||||
<span
|
||||
@@ -525,15 +613,7 @@ const SettingsView = memo(function SettingsView() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="min-w-[420px] w-fit p-6 flex flex-col items-center"
|
||||
style={{
|
||||
backgroundImage: "url('/images/background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-[420px] w-fit p-4 flex flex-col items-center mc-options-bg">
|
||||
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2">
|
||||
{settingsItems.map((item, index) => {
|
||||
if (item.id === "back") return null;
|
||||
@@ -545,11 +625,11 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
|
||||
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
|
||||
style={getSliderStyle(index)}
|
||||
>
|
||||
<span
|
||||
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
|
||||
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -560,7 +640,9 @@ const SettingsView = memo(function SettingsView() {
|
||||
max="100"
|
||||
step="1"
|
||||
value={item.value}
|
||||
onChange={(e) => item.onChange(parseInt(e.target.value))}
|
||||
onChange={(e) =>
|
||||
item.onChange(parseInt(e.target.value))
|
||||
}
|
||||
onMouseUp={playPressSound}
|
||||
className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
|
||||
/>
|
||||
@@ -580,21 +662,35 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={index}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
onClick={item.onClick}
|
||||
className={`w-[360px] h-10 flex items-center justify-between px-4 relative z-30 transition-all outline-none border-none shrink-0 rounded ${focusIndex === index ? "bg-black/10" : "bg-transparent"} ${isRed ? "text-red-600" : "text-[#2a2a2a]"} hover:bg-black/15`}
|
||||
className={`w-[360px] h-10 flex items-center pl-1.5 pr-4 relative z-30 outline-none border-none shrink-0 rounded ${focusIndex === index ? "text-[#ffff00]" : isRed ? "text-red-600" : "text-[#333333]"}`}
|
||||
>
|
||||
{isToggle && (
|
||||
<div className="relative w-6 h-6 mr-3 shrink-0">
|
||||
<img
|
||||
src={
|
||||
focusIndex === index
|
||||
? "/images/checkbox_highlighted.png"
|
||||
: "/images/checkbox.png"
|
||||
}
|
||||
alt="checkbox"
|
||||
className="w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{toggleState && (
|
||||
<img
|
||||
src="/images/check.png"
|
||||
alt="checked"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`tracking-widest uppercase ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left flex-1`}
|
||||
className={`tracking-widest text-xl mc-text-shadow ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left`}
|
||||
>
|
||||
{isToggle ? item.label.split(":")[0] : item.label}
|
||||
</span>
|
||||
{isToggle && (
|
||||
<img
|
||||
src={toggleState ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
|
||||
alt={toggleState ? "ON" : "OFF"}
|
||||
className="w-12 h-6 object-contain shrink-0 ml-2"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -612,11 +708,12 @@ const SettingsView = memo(function SettingsView() {
|
||||
data-index={backIndex}
|
||||
onMouseEnter={() => setFocusIndex(backIndex)}
|
||||
onClick={backItem.onClick}
|
||||
className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] mt-4 ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"}`}
|
||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === backIndex
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundImage:
|
||||
focusIndex === backIndex
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
|
||||
@@ -46,7 +46,14 @@ const DEFAULT_SKINS: SavedSkin[] = [
|
||||
},
|
||||
{ id: "peter", name: "Peter", url: "/Skins/Peter.png", isSlim: false },
|
||||
{ id: "piebot", name: "piebot", url: "/Skins/piebot.png", isSlim: false },
|
||||
{ id: "andipog", name: "Andi_Pog", url: "/Skins/andi.png", isSlim: false },
|
||||
{ id: "sevenhundred", name: "700", url: "/Skins/700.png", isSlim: false },
|
||||
{
|
||||
id: "prismachunk0",
|
||||
name: "PrismaChunk0",
|
||||
url: "/Skins/PrismaChunk0.png",
|
||||
isSlim: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SkinsView = memo(function SkinsView() {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useEffect, useRef, memo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import CustomTUModal from "../modals/CustomTUModal";
|
||||
import SetUidModal from "../modals/SetUidModal";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
import { ScreenshotImage } from "../common/ScreenshotImage";
|
||||
interface DeleteConfirmButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -61,16 +63,23 @@ const VersionsView = memo(function VersionsView() {
|
||||
updateCustomEdition: onUpdateEdition,
|
||||
downloadingId,
|
||||
downloadProgress,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
} = useGame();
|
||||
const { isDayTime } = useConfig();
|
||||
const [focusIndex, setFocusIndex] = useState<number>(0);
|
||||
const [focusBtn, setFocusBtn] = useState<number>(0);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false);
|
||||
const [setUidTargetId, setSetUidTargetId] = useState("");
|
||||
const [editingEdition, setEditingEdition] = useState<any>(null);
|
||||
const [initialPath, setInitialPath] = useState<string>("");
|
||||
const [hoveredBtn, setHoveredBtn] = useState<{
|
||||
row: number;
|
||||
btn: string;
|
||||
} | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,7 +108,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1));
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
@@ -108,53 +117,30 @@ const VersionsView = memo(function VersionsView() {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1;
|
||||
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
|
||||
setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1));
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex < editions.length) {
|
||||
const edition = editions[focusIndex];
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingId === edition.id;
|
||||
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
const isDownloading = downloadingId === edition.instanceId;
|
||||
if (focusBtn === 0) {
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.id);
|
||||
}
|
||||
} else if (isInstalled) {
|
||||
if (focusBtn === 1) {
|
||||
if (!downloadingId) {
|
||||
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
|
||||
} else {
|
||||
if (!isDownloading && !downloadingId) {
|
||||
playPressSound();
|
||||
toggleInstall(edition.id);
|
||||
}
|
||||
} else if (focusBtn === 2) {
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.id);
|
||||
} else if (focusBtn === 3) {
|
||||
if (isCustom) {
|
||||
playBackSound();
|
||||
onDeleteEdition(edition.id);
|
||||
} else {
|
||||
playPressSound();
|
||||
setDeleteConfirmEdition(edition);
|
||||
}
|
||||
} else if (focusBtn === 4 && isCustom) {
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
if (focusBtn === 1) {
|
||||
if (isDownloading) {
|
||||
toggleInstall(edition.instanceId);
|
||||
} else if (isDownloading) {
|
||||
handleCancelDownload();
|
||||
} else if (!downloadingId) {
|
||||
playPressSound();
|
||||
toggleInstall(edition.id);
|
||||
}
|
||||
}
|
||||
} else if (focusBtn === 2) {
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
}
|
||||
} else if (focusIndex === editions.length) {
|
||||
playPressSound();
|
||||
@@ -184,6 +170,8 @@ const VersionsView = memo(function VersionsView() {
|
||||
setActiveView,
|
||||
toggleInstall,
|
||||
handleCancelDownload,
|
||||
addToSteam,
|
||||
isDayTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -198,11 +186,10 @@ const VersionsView = memo(function VersionsView() {
|
||||
}, [focusIndex]);
|
||||
|
||||
const handleEditionClick = (edition: any, index: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
if (isInstalled) {
|
||||
playPressSound();
|
||||
setSelectedProfile(edition.id);
|
||||
setSelectedProfile(edition.instanceId);
|
||||
}
|
||||
setFocusIndex(index);
|
||||
};
|
||||
@@ -226,7 +213,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: animationsEnabled ? 0.25 : 0 }}
|
||||
className="flex flex-col items-center w-full max-w-2xl outline-none"
|
||||
className="flex flex-col items-center w-full max-w-5xl outline-none"
|
||||
>
|
||||
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase font-bold">
|
||||
Versions
|
||||
@@ -247,13 +234,15 @@ const VersionsView = memo(function VersionsView() {
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{editions.map((edition: any, i: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.id);
|
||||
const isInstalled = installedVersions.includes(
|
||||
edition.instanceId,
|
||||
);
|
||||
const hasAnyInstall = installedVersions.length > 0;
|
||||
const isSelected =
|
||||
hasAnyInstall && selectedProfile === edition.id;
|
||||
hasAnyInstall && selectedProfile === edition.instanceId;
|
||||
const isFocused = focusIndex === i;
|
||||
const isCustom = edition.id.startsWith("custom_");
|
||||
const isDownloading = downloadingId === edition.id;
|
||||
const isDownloading = downloadingId === edition.instanceId;
|
||||
const isComingSoon = edition.comingSoon;
|
||||
|
||||
return (
|
||||
@@ -264,7 +253,7 @@ const VersionsView = memo(function VersionsView() {
|
||||
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
|
||||
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
|
||||
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
|
||||
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
|
||||
>
|
||||
<div className="w-6 flex items-center justify-center flex-shrink-0">
|
||||
@@ -296,31 +285,34 @@ const VersionsView = memo(function VersionsView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<div
|
||||
onClick={() =>
|
||||
!isComingSoon && handleEditionClick(edition, i)
|
||||
}
|
||||
disabled={isComingSoon}
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded ${
|
||||
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${
|
||||
focusIndex === i && focusBtn === 0 && !isComingSoon
|
||||
? "ring-2 ring-white"
|
||||
: ""
|
||||
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{edition.logo && (
|
||||
<img
|
||||
src={
|
||||
edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images")
|
||||
? edition.logo
|
||||
: `screenshots://localhost/${edition.logo.replace(/\\/g, "/")}`
|
||||
}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
{edition.logo &&
|
||||
(edition.logo.startsWith("http") ||
|
||||
edition.logo.startsWith("/images") ? (
|
||||
<img
|
||||
src={edition.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenshotImage
|
||||
path={edition.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
className={`text-xl tracking-wide truncate ${
|
||||
isSelected ? "text-white" : "text-black"
|
||||
@@ -351,31 +343,201 @@ const VersionsView = memo(function VersionsView() {
|
||||
>
|
||||
{edition.desc}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 relative">
|
||||
{!isInstalled ? (
|
||||
isDownloading ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDownloading && !downloadingId) {
|
||||
toggleInstall(edition.instanceId);
|
||||
} else if (isDownloading) {
|
||||
handleCancelDownload();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "main" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-9 h-9 flex items-center justify-center ${
|
||||
isDownloading || (!!downloadingId && !isInstalled)
|
||||
? "opacity-50"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "main") ||
|
||||
(focusIndex === i && focusBtn === 0)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
isDownloading
|
||||
? "/images/Trash_Bin_Icon.png"
|
||||
: "/images/Download_Icon.png"
|
||||
}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
filter: isDownloading
|
||||
? "hue-rotate(300deg)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setOpenMenuId(
|
||||
openMenuId === edition.id ? null : edition.id,
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "menu" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "menu") ||
|
||||
(focusIndex === i &&
|
||||
(focusBtn === 0 || focusBtn === 1))
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
filter: updatesAvailable?.[edition.instanceId]
|
||||
? "drop-shadow(0 0 4px rgba(255,255,0,0.8))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{openMenuId === edition.id && (
|
||||
<div
|
||||
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{updatesAvailable?.[edition.instanceId] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
toggleInstall(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt=""
|
||||
className="w-3 h-3 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Update Available!
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(edition.branches) &&
|
||||
edition.branches.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
cycleBranch(edition.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
|
||||
>
|
||||
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
|
||||
Channel
|
||||
</span>
|
||||
<span className="text-[#ffff55] font-bold">
|
||||
{edition.selectedBranch ?? "Latest"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelDownload();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.instanceId);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "cancel" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "cancel") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Open Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
const PANORAMA_PROFILES = [
|
||||
"legacy_evolved",
|
||||
"vanilla_tu19",
|
||||
"360revived",
|
||||
"vanilla_tu24",
|
||||
];
|
||||
const panoId = PANORAMA_PROFILES.includes(
|
||||
edition.id,
|
||||
)
|
||||
? edition.id
|
||||
: "vanilla_tu19";
|
||||
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
|
||||
addToSteam(
|
||||
edition.instanceId,
|
||||
edition.name,
|
||||
edition.titleImage,
|
||||
panoramaUrl,
|
||||
);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<img
|
||||
src="/images/steam.png"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain invert brightness-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
Add to Steam
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setSetUidTargetId(edition.instanceId);
|
||||
setIsSetUidModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -383,69 +545,24 @@ const VersionsView = memo(function VersionsView() {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
Set UID
|
||||
</button>
|
||||
) : edition.comingSoon ? (
|
||||
<div className="w-8 h-8" />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "download" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "download") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Download_Icon.png"
|
||||
alt="Download"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{isDownloading ? (
|
||||
{isCustom ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelDownload();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "cancel" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "cancel") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -453,180 +570,37 @@ const VersionsView = memo(function VersionsView() {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="square"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
Edit Custom
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!downloadingId) toggleInstall(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "update" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "update") ||
|
||||
(focusIndex === i && focusBtn === 1)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
opacity: downloadingId ? 0.5 : 1,
|
||||
}}
|
||||
disabled={!!downloadingId}
|
||||
>
|
||||
<img
|
||||
src="/images/Update_Icon.png"
|
||||
alt="Update"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
TauriService.openInstanceFolder(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "folder" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "folder") ||
|
||||
(focusIndex === i && focusBtn === 2)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Folder_Icon.png"
|
||||
alt="Folder"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
setDeleteConfirmEdition(edition);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "delete" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "delete") ||
|
||||
(focusIndex === i && focusBtn === 3)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/Trash_Bin_Icon.png"
|
||||
alt="Delete"
|
||||
className="w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</button>
|
||||
{isCustom && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setEditingEdition(edition);
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "edit" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "edit") ||
|
||||
(focusIndex === i && focusBtn === 4)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
onDeleteEdition(edition.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredBtn({ row: i, btn: "delete" })
|
||||
}
|
||||
onMouseLeave={() => setHoveredBtn(null)}
|
||||
className="w-8 h-8 flex items-center justify-center text-red-600"
|
||||
style={{
|
||||
backgroundImage:
|
||||
(hoveredBtn?.row === i &&
|
||||
hoveredBtn?.btn === "delete") ||
|
||||
(focusIndex === i && focusBtn === 3)
|
||||
? "url('/images/Button_Square_Highlighted.png')"
|
||||
: "url('/images/Button_Square.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="square"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
<div className="h-[2px] bg-[#555] my-0.5 mx-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playBackSound();
|
||||
if (isCustom) {
|
||||
onDeleteEdition(edition.id);
|
||||
} else {
|
||||
setDeleteConfirmEdition(edition);
|
||||
}
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold"
|
||||
>
|
||||
<img
|
||||
src="/images/Trash_Bin_Icon.png"
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{isCustom ? "Remove Custom" : "Uninstall"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -742,6 +716,16 @@ const VersionsView = memo(function VersionsView() {
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
|
||||
<SetUidModal
|
||||
isOpen={isSetUidModalOpen}
|
||||
onClose={() => setIsSetUidModalOpen(false)}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
instances={editions}
|
||||
installedVersions={installedVersions}
|
||||
targetInstanceId={setUidTargetId}
|
||||
/>
|
||||
|
||||
{deleteConfirmEdition && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div
|
||||
|
||||
@@ -26,15 +26,14 @@ interface UIContextType {
|
||||
onNavigateToMenu: () => void;
|
||||
connected: boolean;
|
||||
updateMessage: string | null;
|
||||
updateUrl: string | null;
|
||||
clearUpdateMessage: () => void;
|
||||
}
|
||||
const UIContext = createContext<UIContextType | undefined>(undefined);
|
||||
|
||||
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
|
||||
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
|
||||
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
|
||||
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | undefined>(undefined);
|
||||
|
||||
export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showIntro, setShowIntro] = useState(true);
|
||||
const [logoAnimDone, setLogoAnimDone] = useState(false);
|
||||
@@ -44,7 +43,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showCredits, setShowCredits] = useState(false);
|
||||
const [focusSection, setFocusSection] = useState<"menu" | "skin">("menu");
|
||||
|
||||
const { updateMessage, clearUpdateMessage } = useUpdateCheck();
|
||||
const { updateMessage, updateUrl, clearUpdateMessage } = useUpdateCheck();
|
||||
|
||||
const configRaw = useAppConfig();
|
||||
const gameRaw = useGameManager({
|
||||
@@ -66,14 +65,18 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
|
||||
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
|
||||
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
|
||||
configRaw.legacyMode, configRaw.animationsEnabled
|
||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
|
||||
]);
|
||||
|
||||
const game = useMemo(() => gameRaw, [
|
||||
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
|
||||
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
|
||||
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
|
||||
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
|
||||
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
|
||||
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
|
||||
gameRaw.deleteCustomEdition, gameRaw.downloadRunner
|
||||
]);
|
||||
|
||||
const audio = useMemo(() => audioRaw, [
|
||||
@@ -126,13 +129,15 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
musicVol: config.musicVol,
|
||||
sfxVol: config.sfxVol,
|
||||
legacyMode: config.legacyMode,
|
||||
mangohudEnabled: config.mangohudEnabled,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [
|
||||
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
|
||||
config.perfBoost, config.customEditions, config.profile,
|
||||
config.vfxEnabled, config.animationsEnabled,
|
||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.isLoaded
|
||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
|
||||
config.mangohudEnabled, config.isLoaded
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,8 +185,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||
isWindowVisible,
|
||||
showCredits, setShowCredits, focusSection, setFocusSection,
|
||||
onNavigateToSkin, onNavigateToMenu, connected,
|
||||
updateMessage, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, clearUpdateMessage]);
|
||||
updateMessage, updateUrl, clearUpdateMessage
|
||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
|
||||
|
||||
return (
|
||||
<UIContext.Provider value={uiValue}>
|
||||
|
||||
@@ -1,227 +1,312 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'Mojangles';
|
||||
src: url('/fonts/Mojangles.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.gamepad-connected-indicator {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: #FFFF55;
|
||||
font-size: 10px;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn {
|
||||
border: 4px solid transparent;
|
||||
border-image: url('/images/Button_Background.png') 4 stretch;
|
||||
image-rendering: pixelated;
|
||||
background: rgba(0,0,0,0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
* {
|
||||
cursor: url('/images/pointer.png') 16 16, auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
font-family: 'Mojangles', monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
.mc-button {
|
||||
background-image: url('/images/button.png');
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mc-button:hover:not(:disabled) {
|
||||
background-image: url('/images/button_highlighted.png');
|
||||
}
|
||||
|
||||
.mc-text-shadow {
|
||||
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
|
||||
}
|
||||
|
||||
.mc-text-shadow-hover {
|
||||
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
|
||||
}
|
||||
|
||||
|
||||
@keyframes sga-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY))) scale(1.2) rotate(var(--rot));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle-burst {
|
||||
animation: sga-burst 0.8s ease-out forwards;
|
||||
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: url('/images/SliderHandlerBackground.png') center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: url('/images/SliderHandlerBackground.png') center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: url('/images/Slider_Handle.png') no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.no-animations * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.workshop-markdown h1,
|
||||
.workshop-markdown h2,
|
||||
.workshop-markdown h3 {
|
||||
color: #FFFF55;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workshop-markdown strong {
|
||||
color: #FFFF55;
|
||||
}
|
||||
|
||||
.workshop-markdown ul,
|
||||
.workshop-markdown ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.workshop-markdown li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.workshop-markdown a {
|
||||
color: #55FFFF;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workshop-markdown code {
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #FF55FF;
|
||||
}
|
||||
|
||||
.workshop-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 2px solid #555;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.workshop-markdown th {
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: #FFFF55;
|
||||
padding: 8px;
|
||||
border: 1px solid #444;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.workshop-markdown td {
|
||||
padding: 8px;
|
||||
border: 1px solid #333;
|
||||
color: #E0E0E0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.workshop-markdown tr:nth-child(even) {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.workshop-markdown blockquote {
|
||||
border-left: 4px solid #555555;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #A0A0A0;
|
||||
font-style: italic;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.workshop-markdown del {
|
||||
color: #AA0000;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.workshop-markdown input[type="checkbox"] {
|
||||
accent-color: #FFFF55;
|
||||
margin-right: 0.5rem;
|
||||
transform: scale(1.2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.workshop-markdown hr {
|
||||
border: none;
|
||||
border-top: 2px solid #373737;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "Mojangles";
|
||||
src: url("/fonts/Mojangles.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.gamepad-connected-indicator {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: #ffff55;
|
||||
font-size: 10px;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn {
|
||||
border: 4px solid transparent;
|
||||
border-image: url("/images/Button_Background.png") 4 stretch;
|
||||
image-rendering: pixelated;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mc-setup-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mc-options-bg {
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mc-lce-tab {
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a0a0a0 100%);
|
||||
border: 2px solid #808080;
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 #e0e0e0,
|
||||
inset -2px 0 0 #808080,
|
||||
0 -2px 0 #606060;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
transform: translateY(2px);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.mc-lce-tab.active {
|
||||
background: linear-gradient(180deg, #d0d0d0 0%, #b0b0b0 100%);
|
||||
transform: translateY(0);
|
||||
z-index: 10;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 #f0f0f0,
|
||||
inset -2px 0 0 #909090,
|
||||
0 -3px 0 #707070;
|
||||
}
|
||||
|
||||
.mc-lce-content {
|
||||
background: linear-gradient(180deg, #b0b0b0 0%, #909090 100%);
|
||||
border: 3px solid #808080;
|
||||
border-radius: 0 4px 4px 4px;
|
||||
box-shadow:
|
||||
inset 3px 3px 0 #d0d0d0,
|
||||
inset -3px -3px 0 #606060;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mc-lce-content::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border: 2px solid #ffffff;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mc-lce-tab-bg {
|
||||
border: 4px solid transparent;
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
transform: translateY(4px);
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mc-lce-tab-bg.active {
|
||||
transform: translateY(-4px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mc-lce-content-bg {
|
||||
border-image: url("/images/options_background.png") 4 fill;
|
||||
border-width: 12px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
* {
|
||||
cursor:
|
||||
url("/images/pointer.png") 16 16,
|
||||
auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #000;
|
||||
font-family: "Mojangles", monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
.mc-button {
|
||||
background-image: url("/images/button.png");
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mc-button:hover:not(:disabled) {
|
||||
background-image: url("/images/button_highlighted.png");
|
||||
}
|
||||
|
||||
.mc-text-shadow {
|
||||
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
|
||||
}
|
||||
|
||||
.mc-text-shadow-hover {
|
||||
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
|
||||
}
|
||||
|
||||
@keyframes sga-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY)))
|
||||
scale(1.2) rotate(var(--rot));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle-burst {
|
||||
animation: sga-burst 0.8s ease-out forwards;
|
||||
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: url("/images/SliderHandlerBackground.png") center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: url("/images/Slider_Handle.png") no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: url("/images/SliderHandlerBackground.png") center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: url("/images/Slider_Handle.png") no-repeat center;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: pixelated;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.no-animations * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.workshop-markdown h1,
|
||||
.workshop-markdown h2,
|
||||
.workshop-markdown h3 {
|
||||
color: #ffff55;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workshop-markdown strong {
|
||||
color: #ffff55;
|
||||
}
|
||||
|
||||
.workshop-markdown ul,
|
||||
.workshop-markdown ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.workshop-markdown li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.workshop-markdown a {
|
||||
color: #55ffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workshop-markdown code {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ff55ff;
|
||||
}
|
||||
|
||||
.workshop-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid #555;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.workshop-markdown th {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #ffff55;
|
||||
padding: 8px;
|
||||
border: 1px solid #444;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.workshop-markdown td {
|
||||
padding: 8px;
|
||||
border: 1px solid #333;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.workshop-markdown tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.workshop-markdown blockquote {
|
||||
border-left: 4px solid #555555;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.workshop-markdown del {
|
||||
color: #aa0000;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.workshop-markdown input[type="checkbox"] {
|
||||
accent-color: #ffff55;
|
||||
margin-right: 0.5rem;
|
||||
transform: scale(1.2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.workshop-markdown hr {
|
||||
border: none;
|
||||
border-top: 2px solid #373737;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useAppConfig() {
|
||||
const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
|
||||
const [perfBoost, setPerfBoost] = useState(false);
|
||||
const [customEditions, setCustomEditions] = useState<any[]>([]);
|
||||
const [mangohudEnabled, setMangohudEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
@@ -33,6 +34,7 @@ export function useAppConfig() {
|
||||
if (config.musicVol !== undefined && config.musicVol !== null) setMusicVol(config.musicVol);
|
||||
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
|
||||
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
|
||||
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
@@ -52,9 +54,10 @@ export function useAppConfig() {
|
||||
musicVol,
|
||||
sfxVol,
|
||||
legacyMode,
|
||||
mangohudEnabled,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, isLoaded]);
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
|
||||
|
||||
return {
|
||||
username,
|
||||
@@ -88,5 +91,7 @@ export function useAppConfig() {
|
||||
isLoaded,
|
||||
hasCompletedSetup,
|
||||
setHasCompletedSetup,
|
||||
mangohudEnabled,
|
||||
setMangohudEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ const SPLASHES = [
|
||||
"Traduis-moi !",
|
||||
"May contains Mr_Anilex",
|
||||
"Neoapps didn't write this splash",
|
||||
"Where's Kinger ?",
|
||||
"Where's Kinger?",
|
||||
"KayJann, Breakcore and code",
|
||||
"Hey Goku!",
|
||||
"Vegeta is a DZ mashallah",
|
||||
@@ -144,14 +144,22 @@ const SPLASHES = [
|
||||
"piebot was here!",
|
||||
"Legacy in an evolved manner.",
|
||||
"neoapps is cool!",
|
||||
"neoapps has put a self insert into this program!",
|
||||
"Also try neoLegacy!",
|
||||
"$20 is $20",
|
||||
"no RenderDragon!",
|
||||
"Iggy Jiggy",
|
||||
"Arch, btw!",
|
||||
"Bedrock bad!",
|
||||
"Bedrock Linux not bad",
|
||||
"Also try Terraria!",
|
||||
"Also try LC Launcher!",
|
||||
"Exclusively abandonware!",
|
||||
"100% legal in all 3 states (of matter)",
|
||||
"Herobrine has been confirmed we are fighting him please help",
|
||||
"LGTM",
|
||||
"git revert",
|
||||
"We do not crypto mine on your computer.",
|
||||
];
|
||||
|
||||
interface AudioControllerProps {
|
||||
|
||||
@@ -52,6 +52,7 @@ export function useDiscordRPC({
|
||||
devtools: "Developing for LCE",
|
||||
skins: "Changing Skins",
|
||||
workshop: "Browsing Workshop",
|
||||
lcelive: "Browsing Friends",
|
||||
"pck-editor": "Editing a PCK file",
|
||||
"options-editor": "Editing Options Files",
|
||||
"arc-editor": "Editing an ARC file",
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
async function imageUrlToBase64(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
const BASE_EDITIONS = [
|
||||
{
|
||||
id: "legacy_evolved", //neo: yes. we're not changing the ID. that will break user data.
|
||||
id: "legacy_evolved",
|
||||
name: "neoLegacy",
|
||||
desc: "Backporting newer title updates and Minigames back to LCE",
|
||||
url: "https://github.com/pieeebot/neoLegacy/releases/download/Nightly/neoLegacyWindows64.zip",
|
||||
url: "https://github.com/pieeebot/neoLegacy/releases/download/latest/neoLegacyWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_neoLegacy.png",
|
||||
supportsSlimSkins: true,
|
||||
logo: "/images/neoLegacy.png"
|
||||
logo: "/images/neoLegacy.png",
|
||||
panorama: "legacy_evolved",
|
||||
},
|
||||
{
|
||||
id: "revelations",
|
||||
@@ -18,6 +34,7 @@ const BASE_EDITIONS = [
|
||||
url: "https://github.com/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
|
||||
titleImage: "/images/minecraft_title_revelations.png",
|
||||
supportsSlimSkins: false,
|
||||
panorama: "vanilla_tu24",
|
||||
},
|
||||
{
|
||||
id: "360revived",
|
||||
@@ -26,24 +43,26 @@ const BASE_EDITIONS = [
|
||||
url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_360revived.png",
|
||||
supportsSlimSkins: false,
|
||||
logo: "/images/360_revived.png"
|
||||
logo: "/images/360_revived.png",
|
||||
panorama: "360revived",
|
||||
},
|
||||
{
|
||||
id: "legacy_nether_fork", //kay: not changing this one also
|
||||
id: "legacy_nether_fork",
|
||||
name: "Hellish Ends",
|
||||
desc: "QoL, Random additions, and Nether/End dimensions overhaul (Modded build !)",
|
||||
url: "https://github.com/deadvoxelx/HellishEnds/releases/download/nightly/LCEWindows64.zip",
|
||||
titleImage: "/images/minecraft_title_hellishends.png",
|
||||
supportsSlimSkins: false,
|
||||
logo: "/images/netherrack_0.png"
|
||||
}
|
||||
logo: "/images/netherrack_0.png",
|
||||
panorama: "vanilla_tu19",
|
||||
},
|
||||
];
|
||||
|
||||
const PARTNERSHIP_SERVERS = [
|
||||
{
|
||||
name: "Kowhaifans Clubhouse",
|
||||
ip: "lce.kowhaifan.net",
|
||||
port: 1026,
|
||||
port: 25565,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,6 +73,23 @@ interface GameManagerProps {
|
||||
setCustomEditions: (editions: any[]) => void;
|
||||
}
|
||||
|
||||
function compareVersions(v1: string, v2: string) {
|
||||
const parts1 = v1.replace(/^v/, "").split(/[.-]/);
|
||||
const parts2 = v2.replace(/^v/, "").split(/[.-]/);
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || "0";
|
||||
const p2 = parts2[i] || "0";
|
||||
const n1 = parseInt(p1);
|
||||
const n2 = parseInt(p2);
|
||||
if (!isNaN(n1) && !isNaN(n2)) {
|
||||
if (n1 !== n2) return n1 - n2;
|
||||
} else {
|
||||
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function useGameManager({
|
||||
profile,
|
||||
setProfile,
|
||||
@@ -69,43 +105,214 @@ export function useGameManager({
|
||||
number | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(null);
|
||||
|
||||
const editions = useMemo(
|
||||
() => [...BASE_EDITIONS, ...customEditions],
|
||||
[customEditions],
|
||||
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dynamicUrls, setDynamicUrls] = useState<Record<string, string>>({});
|
||||
const [branches, setBranches] = useState<Record<string, string[]>>({});
|
||||
const [selectedBranches, setSelectedBranches] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const branchesFetched = useRef<Set<string>>(new Set());
|
||||
const initialBranchesSet = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialBranchesSet.current || !profile) return;
|
||||
BASE_EDITIONS.forEach((e) => {
|
||||
if (profile.startsWith(e.id + "_")) {
|
||||
const branch = profile.replace(e.id + "_", "");
|
||||
setSelectedBranches((prev) => ({ ...prev, [e.id]: branch }));
|
||||
} else if (profile === e.id) {
|
||||
setSelectedBranches((prev) => ({ ...prev, [e.id]: "Stable" }));
|
||||
}
|
||||
});
|
||||
initialBranchesSet.current = true;
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestReleases() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/pieeebot/neoLegacy/releases/latest",
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const asset = data.assets.find(
|
||||
(a: any) => a.name === "neoLegacyWindows64.zip",
|
||||
);
|
||||
if (asset) {
|
||||
setDynamicUrls((prev) => ({
|
||||
...prev,
|
||||
legacy_evolved: asset.browser_download_url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch latest releases:", e);
|
||||
}
|
||||
}
|
||||
fetchLatestReleases();
|
||||
}, []);
|
||||
|
||||
const fetchBranchesForEdition = useCallback(
|
||||
async (editionId: string, url: string) => {
|
||||
if (branchesFetched.current.has(editionId)) return;
|
||||
if (!url.includes("github.com")) return;
|
||||
const parts = url.split("github.com/")[1].split("/");
|
||||
const owner = parts[0];
|
||||
const repo = parts[1];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
let tags: string[] = data
|
||||
.map((r: any) => r.tag_name)
|
||||
.filter((t: string) => !t.toLowerCase().includes("server"));
|
||||
|
||||
const vTags = tags
|
||||
.filter((t) => t.startsWith("v"))
|
||||
.sort(compareVersions);
|
||||
const bestVTag = vTags[vTags.length - 1];
|
||||
if (!tags.includes("Stable")) tags.unshift("Stable");
|
||||
if (bestVTag) {
|
||||
setDynamicUrls((prev) => ({
|
||||
...prev,
|
||||
[`${editionId}_Stable`]: bestVTag,
|
||||
}));
|
||||
}
|
||||
|
||||
setBranches((prev) => ({ ...prev, [editionId]: tags }));
|
||||
branchesFetched.current.add(editionId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch branches for ${editionId}:`, e);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
BASE_EDITIONS.forEach((e) => fetchBranchesForEdition(e.id, e.url));
|
||||
}, [fetchBranchesForEdition]);
|
||||
|
||||
const cycleBranch = useCallback(
|
||||
(editionId: string) => {
|
||||
const available = branches[editionId] || ["Stable"];
|
||||
if (available.length <= 1) return;
|
||||
setSelectedBranches((prev) => {
|
||||
const current = prev[editionId] || available[0];
|
||||
let currentIndex = available.indexOf(current);
|
||||
let nextIndex = currentIndex;
|
||||
let nextBranch = current;
|
||||
do {
|
||||
nextIndex = (nextIndex + 1) % available.length;
|
||||
nextBranch = available[nextIndex];
|
||||
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
|
||||
const oldInstanceId =
|
||||
current === "Stable" ? editionId : `${editionId}_${current}`;
|
||||
const newInstanceId =
|
||||
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
|
||||
if (profile === oldInstanceId) {
|
||||
setProfile(newInstanceId);
|
||||
}
|
||||
return { ...prev, [editionId]: nextBranch };
|
||||
});
|
||||
},
|
||||
[branches, profile, setProfile],
|
||||
);
|
||||
|
||||
const editions = useMemo(() => {
|
||||
return [
|
||||
...BASE_EDITIONS.map((e) => {
|
||||
const availableBranches = branches[e.id] || ["Stable"];
|
||||
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
|
||||
let url = dynamicUrls[e.id] || e.url;
|
||||
const defaultBranchFromUrl = e.url.includes("/releases/download/")
|
||||
? e.url.split("/releases/download/")[1].split("/")[0]
|
||||
: "nightly";
|
||||
const branchToUse =
|
||||
selectedBranch === "Stable"
|
||||
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
|
||||
: selectedBranch;
|
||||
if (e.url.includes("github.com")) {
|
||||
const baseUrl = e.url.split("/releases/download/")[0];
|
||||
const filename = e.url.split("/").pop();
|
||||
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
...e,
|
||||
url,
|
||||
branches: availableBranches,
|
||||
selectedBranch,
|
||||
instanceId:
|
||||
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
|
||||
};
|
||||
}),
|
||||
...customEditions.map((e) => ({ ...e, instanceId: e.id })),
|
||||
];
|
||||
}, [customEditions, dynamicUrls, branches, selectedBranches]);
|
||||
|
||||
const checkInstalls = useCallback(async () => {
|
||||
const results = await Promise.all(
|
||||
editions.map(async (e) => {
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.id);
|
||||
return isInstalled ? e.id : null;
|
||||
const isInstalled = await TauriService.checkGameInstalled(e.instanceId);
|
||||
return isInstalled ? e.instanceId : null;
|
||||
}),
|
||||
);
|
||||
setInstalls(results.filter((id): id is string => id !== null));
|
||||
}, [editions]);
|
||||
|
||||
const [updatesAvailable, setUpdatesAvailable] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const checkForGameUpdates = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
const edition = editions.find(e => e.id === profile);
|
||||
if (!edition) return;
|
||||
try {
|
||||
const isUpdate = await TauriService.checkGameUpdate(profile, edition.url);
|
||||
if (isUpdate) {
|
||||
setGameUpdateMessage(`An update is available for ${edition.name}!`);
|
||||
} else {
|
||||
setGameUpdateMessage(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const checks = await Promise.all(
|
||||
editions.map(async (edition) => {
|
||||
if (!installs.includes(edition.instanceId))
|
||||
return [edition.instanceId, false] as const;
|
||||
try {
|
||||
const isUpdate = await TauriService.checkGameUpdate(
|
||||
edition.instanceId,
|
||||
edition.url,
|
||||
);
|
||||
return [edition.instanceId, isUpdate] as const;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [edition.instanceId, false] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const newUpdates: Record<string, boolean> = {};
|
||||
for (const [id, hasUpdate] of checks) {
|
||||
newUpdates[id as string] = hasUpdate as boolean;
|
||||
}
|
||||
}, [profile, editions]);
|
||||
setUpdatesAvailable(newUpdates);
|
||||
|
||||
const updatedGames = editions.filter((e) => newUpdates[e.id]);
|
||||
if (updatedGames.length > 0) {
|
||||
if (updatedGames.length === 1) {
|
||||
setGameUpdateMessage(
|
||||
`An update is available for ${updatedGames[0].name}!`,
|
||||
);
|
||||
} else {
|
||||
setGameUpdateMessage(
|
||||
`Updates are available for ${updatedGames.length} versions!`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGameUpdateMessage(null);
|
||||
}
|
||||
}, [editions, installs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (installs.includes(profile)) {
|
||||
checkForGameUpdates();
|
||||
}
|
||||
checkForGameUpdates();
|
||||
}, [profile, installs, checkForGameUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,7 +353,7 @@ export function useGameManager({
|
||||
const toggleInstall = useCallback(
|
||||
async (id: string) => {
|
||||
if (downloadingId) return;
|
||||
const edition = editions.find((e) => e.id === id);
|
||||
const edition = editions.find((e) => e.instanceId === id);
|
||||
if (!edition) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -218,7 +425,15 @@ export function useGameManager({
|
||||
}, [profile]);
|
||||
|
||||
const addCustomEdition = useCallback(
|
||||
(edition: { name: string; desc: string; url: string; path?: string; category?: string[]; logo?: string; id?: string }) => {
|
||||
(edition: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
category?: string[];
|
||||
logo?: string;
|
||||
id?: string;
|
||||
}) => {
|
||||
const id = edition.id || `custom_${Date.now()}`;
|
||||
const newEdition = {
|
||||
...edition,
|
||||
@@ -240,7 +455,10 @@ export function useGameManager({
|
||||
);
|
||||
|
||||
const updateCustomEdition = useCallback(
|
||||
(id: string, updated: { name: string; desc: string; url: string; path?: string }) => {
|
||||
(
|
||||
id: string,
|
||||
updated: { name: string; desc: string; url: string; path?: string },
|
||||
) => {
|
||||
setCustomEditions(
|
||||
customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)),
|
||||
);
|
||||
@@ -248,6 +466,30 @@ export function useGameManager({
|
||||
[customEditions, setCustomEditions],
|
||||
);
|
||||
|
||||
const addToSteam = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
name: string,
|
||||
titleImage: string,
|
||||
panoramaImage: string,
|
||||
) => {
|
||||
try {
|
||||
const titleBase64 = await imageUrlToBase64(titleImage);
|
||||
const panoramaBase64 = await imageUrlToBase64(panoramaImage);
|
||||
await TauriService.addToSteam(id, name, titleBase64, panoramaBase64);
|
||||
setSteamSuccessMessage(
|
||||
`Added ${name} to Steam! (Restart Steam to see it)`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
typeof e === "string" ? e : e.message || "Failed to add to Steam",
|
||||
);
|
||||
}
|
||||
},
|
||||
[setError, setSteamSuccessMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
installs,
|
||||
isGameRunning,
|
||||
@@ -270,5 +512,10 @@ export function useGameManager({
|
||||
checkInstalls,
|
||||
gameUpdateMessage,
|
||||
setGameUpdateMessage,
|
||||
steamSuccessMessage,
|
||||
setSteamSuccessMessage,
|
||||
updatesAvailable,
|
||||
addToSteam,
|
||||
cycleBranch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { lceLiveService } from "../services/LceLiveService";
|
||||
export function useLceLiveNotifications() {
|
||||
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
|
||||
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
|
||||
const seenRequests = useRef<Set<string>>(new Set());
|
||||
const seenInvites = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let pollInterval: any;
|
||||
const init = async () => {
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
await lceLiveService.refreshSession();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
reqs.incoming.forEach((r: any) => seenRequests.current.add(r.accountId));
|
||||
invs.filter((i: any) => i.status === "pending").forEach((i: any) => seenInvites.current.add(i.inviteId));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
|
||||
reqs.incoming.forEach((r: any) => {
|
||||
if (!seenRequests.current.has(r.accountId)) {
|
||||
seenRequests.current.add(r.accountId);
|
||||
setFriendRequestMessage(`New request from ${r.displayName}`);
|
||||
}
|
||||
});
|
||||
|
||||
invs.filter((i: any) => i.status === "pending").forEach((i: any) => {
|
||||
if (!seenInvites.current.has(i.inviteId)) {
|
||||
seenInvites.current.add(i.inviteId);
|
||||
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
|
||||
setGameInviteMessage(`${fromName} invited you to play!`);
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage: () => setFriendRequestMessage(null),
|
||||
clearGameInviteMessage: () => setGameInviteMessage(null),
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,42 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
declare const __BUILD_DATE__: string;
|
||||
const REPO_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
export function useUpdateCheck() {
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const [updateUrl, _setUpdateUrl] = useState<string | null>(null);
|
||||
const checkUpdates = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(REPO_URL);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const latestDate = new Date(data.published_at);
|
||||
const buildDate = new Date(__BUILD_DATE__);
|
||||
if (latestDate > buildDate) {
|
||||
setUpdateMessage(`Version ${data.tag_name} is now available!`);
|
||||
} 9
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateMessage(`Downloading update: ${update.version}...`);
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength || 0;
|
||||
setUpdateMessage(`Starting download of ${update.version}...`);
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
if (contentLength > 0) {
|
||||
const percent = Math.round((downloaded / contentLength) * 100);
|
||||
setUpdateMessage(`Downloading update: ${percent}%`);
|
||||
}
|
||||
break;
|
||||
case 'Finished':
|
||||
setUpdateMessage(`Installing update...`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setUpdateMessage("Update installed. Restarting...");
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (e) {
|
||||
console.warn("Could not relaunch immediately:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check for updates:", e);
|
||||
}
|
||||
@@ -24,6 +48,7 @@ export function useUpdateCheck() {
|
||||
|
||||
return {
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage: () => setUpdateMessage(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import ColEditorView from "../components/views/ColEditorView";
|
||||
import OptionsEditorView from "../components/views/OptionsEditorView";
|
||||
import ScreenshotsView from "../components/views/ScreenshotsView";
|
||||
import SwfView from "../components/views/SwfView";
|
||||
import LceLiveView from "../components/views/LceLiveView";
|
||||
import SkinViewer from "../components/common/SkinViewer";
|
||||
import TeamModal from "../components/modals/TeamModal";
|
||||
import PanoramaBackground from "../components/common/PanoramaBackground";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
useSkin,
|
||||
} from "../context/LauncherContext";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
|
||||
import pkg from "../../package.json";
|
||||
export default function App() {
|
||||
const {
|
||||
@@ -45,12 +47,19 @@ export default function App() {
|
||||
focusSection,
|
||||
onNavigateToMenu,
|
||||
updateMessage,
|
||||
updateUrl,
|
||||
clearUpdateMessage,
|
||||
} = useUI();
|
||||
const config = useConfig();
|
||||
const audio = useAudio();
|
||||
const game = useGame();
|
||||
const { skinUrl, setSkinUrl, capeUrl } = useSkin();
|
||||
const {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage,
|
||||
clearGameInviteMessage,
|
||||
} = useLceLiveNotifications();
|
||||
const [showSetup, setShowSetup] = useState(true);
|
||||
const [displayIsDay, setDisplayIsDay] = useState(config.isDayTime);
|
||||
useEffect(() => {
|
||||
@@ -58,12 +67,12 @@ export default function App() {
|
||||
}, [config.isDayTime]);
|
||||
|
||||
const selectedEdition = game.editions.find(
|
||||
(e: any) => e.id === config.profile,
|
||||
(e: any) => e.instanceId === config.profile,
|
||||
);
|
||||
const selectedVersionName = selectedEdition?.name || "";
|
||||
const hasAnyInstall = game.installs.length > 0;
|
||||
const titleImage = hasAnyInstall
|
||||
? (selectedEdition?.titleImage || "/images/MenuTitle.png")
|
||||
? selectedEdition?.titleImage || "/images/MenuTitle.png"
|
||||
: "/images/MenuTitle.png";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,7 +132,7 @@ export default function App() {
|
||||
{...backgroundFade}
|
||||
>
|
||||
<PanoramaBackground
|
||||
profile={config.profile}
|
||||
profile={selectedEdition.panorama}
|
||||
isDay={displayIsDay}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -159,7 +168,10 @@ export default function App() {
|
||||
message={updateMessage}
|
||||
onClose={clearUpdateMessage}
|
||||
onClick={() =>
|
||||
TauriService.openUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest")
|
||||
TauriService.openUrl(
|
||||
updateUrl ||
|
||||
"https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest",
|
||||
)
|
||||
}
|
||||
title="Update Available!"
|
||||
variant="update"
|
||||
@@ -170,12 +182,19 @@ export default function App() {
|
||||
onClose={() => game.setGameUpdateMessage(null)}
|
||||
onClick={() => {
|
||||
game.setGameUpdateMessage(null);
|
||||
game.toggleInstall(config.profile);
|
||||
setActiveView("versions");
|
||||
}}
|
||||
title="Game Update Available!"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={game.steamSuccessMessage}
|
||||
onClose={() => game.setSteamSuccessMessage(null)}
|
||||
title="Steam Integration"
|
||||
variant="steam"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSetup ? (
|
||||
<SetupView
|
||||
@@ -267,36 +286,39 @@ export default function App() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isUiHidden && !displayIsDay && activeView == "devtools" && (
|
||||
<motion.div
|
||||
key="secret-swf-btn"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
audio.playPressSound();
|
||||
setIsUiHidden(false);
|
||||
setActiveView("swf-editor");
|
||||
}}
|
||||
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
|
||||
{isUiHidden &&
|
||||
!displayIsDay &&
|
||||
activeView == "devtools" && (
|
||||
<motion.div
|
||||
key="secret-swf-btn"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png"
|
||||
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "/images/Button_Background.png";
|
||||
<button
|
||||
onClick={() => {
|
||||
audio.playPressSound();
|
||||
setIsUiHidden(false);
|
||||
setActiveView("swf-editor");
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
SWF Editor
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png"
|
||||
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/images/Button_Background.png";
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
SWF Editor
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -406,6 +428,9 @@ export default function App() {
|
||||
{activeView === "swf-editor" && (
|
||||
<SwfView key="swf-editor-view" />
|
||||
)}
|
||||
{activeView === "lcelive" && (
|
||||
<LceLiveView key="lcelive-view" />
|
||||
)}
|
||||
{activeView === "skins" && <SkinsView key="skins-view" />}
|
||||
{activeView === "screenshots" && (
|
||||
<ScreenshotsView key="screenshots-view" />
|
||||
@@ -439,6 +464,28 @@ export default function App() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AchievementToast
|
||||
message={friendRequestMessage}
|
||||
onClose={clearFriendRequestMessage}
|
||||
onClick={() => {
|
||||
clearFriendRequestMessage();
|
||||
setActiveView("lcelive");
|
||||
}}
|
||||
title="Friend Request"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={gameInviteMessage}
|
||||
onClose={clearGameInviteMessage}
|
||||
onClick={() => {
|
||||
clearGameInviteMessage();
|
||||
setActiveView("lcelive");
|
||||
}}
|
||||
title="Game Invite"
|
||||
variant="update"
|
||||
/>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
);
|
||||
|
||||
@@ -286,7 +286,7 @@ export class GrfService {
|
||||
compressedSize = bodyData.length;
|
||||
}
|
||||
|
||||
const finalBuffer = new Uint8Array(10 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + bodyData.length);
|
||||
const finalBuffer = new Uint8Array(11 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + bodyData.length);
|
||||
const fw = new DataView(finalBuffer.buffer);
|
||||
let poff = 0;
|
||||
fw.setInt16(poff, 1, false); poff += 2;
|
||||
@@ -302,4 +302,21 @@ export class GrfService {
|
||||
|
||||
return finalBuffer.buffer;
|
||||
}
|
||||
|
||||
public static createDefaultGRF(): GrfFile {
|
||||
return {
|
||||
header: {
|
||||
compressionLevel: GrfCompressionLevel.CompressedRle,
|
||||
crc: 0,
|
||||
compressionType: GrfCompressionType.Zlib,
|
||||
unknownData: new Uint8Array([0, 0, 0, 0])
|
||||
},
|
||||
files: [],
|
||||
root: {
|
||||
name: "__ROOT__",
|
||||
parameters: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
const LOCAL_STORAGE_KEY = "lcelive_session";
|
||||
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
|
||||
const MAX_REFRESH_RETRIES = 1;
|
||||
import { TauriService } from "./TauriService";
|
||||
export interface LceLiveAccount {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
account: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface DeviceLinkStartResponse {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete?: string;
|
||||
intervalSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DeviceLinkPollResponse {
|
||||
status: string;
|
||||
isLinked?: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
account?: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface GameInvite {
|
||||
inviteId: string;
|
||||
from: LceLiveAccount | string;
|
||||
hostIp: string;
|
||||
hostPort: number;
|
||||
hostName: string;
|
||||
signalingSessionId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface PendingRequests {
|
||||
incoming: FriendRequest[];
|
||||
outgoing: FriendRequest[];
|
||||
}
|
||||
|
||||
export class LceLiveService {
|
||||
private _session: SessionData | null = null;
|
||||
private baseUrl: string = DEFAULT_BASE_URL;
|
||||
private _refreshPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadSession();
|
||||
}
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
get signedIn(): boolean {
|
||||
return this._session !== null;
|
||||
}
|
||||
|
||||
get account(): LceLiveAccount | null {
|
||||
return this._session?.account || null;
|
||||
}
|
||||
|
||||
get displayUsername(): string {
|
||||
if (!this._session) return "Not signed in";
|
||||
return (
|
||||
this._session.account.displayName ||
|
||||
this._session.account.username ||
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
get apiBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this._session?.accessToken || null;
|
||||
}
|
||||
|
||||
public generateDeviceId(): string {
|
||||
let id = localStorage.getItem("lcelive_device_id");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem("lcelive_device_id", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public getDeviceName(): string {
|
||||
return "LCE Emerald Launcher";
|
||||
}
|
||||
|
||||
private loadSession() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
this._session = JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load LceLive session", e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveSession() {
|
||||
if (this._session) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
|
||||
} else {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
authed: boolean = true,
|
||||
retryCount: number = 0,
|
||||
): Promise<any> {
|
||||
if (authed && this._session?.refreshToken && retryCount === 0) {
|
||||
try {
|
||||
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "MCLCE-LceLive/1.0",
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (authed && this._session?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
|
||||
}
|
||||
|
||||
const bodyStr = body ? JSON.stringify(body) : null;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await TauriService.httpProxyRequest(
|
||||
method,
|
||||
`${this.baseUrl}${path}`,
|
||||
bodyStr,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Network error when calling ${path}: ${e}`);
|
||||
}
|
||||
|
||||
if (
|
||||
res.status === 401 &&
|
||||
authed &&
|
||||
this._session?.refreshToken &&
|
||||
retryCount < MAX_REFRESH_RETRIES
|
||||
) {
|
||||
try {
|
||||
await this.refreshSession();
|
||||
return this.request(method, path, body, authed, retryCount + 1);
|
||||
} catch (err) {
|
||||
this.logoutLocal();
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = res.body ? JSON.parse(res.body) : {};
|
||||
} catch {
|
||||
data = { message: res.body };
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const errorMsg =
|
||||
data.message ||
|
||||
data.detail ||
|
||||
data.title ||
|
||||
data.error ||
|
||||
`HTTP ${res.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
|
||||
return this.request(
|
||||
"POST",
|
||||
"/api/auth/device/start",
|
||||
{
|
||||
deviceId: this.generateDeviceId(),
|
||||
deviceName: this.getDeviceName(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
|
||||
const data = await this.request(
|
||||
"GET",
|
||||
`/api/auth/device/poll/${deviceCode}`,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
if (data.isLinked && data.accessToken) {
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
account: data.account,
|
||||
};
|
||||
this.saveSession();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<void> {
|
||||
if (!this._session?.refreshToken) throw new Error("No refresh token");
|
||||
|
||||
if (this._refreshPromise) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
this._refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await this.request(
|
||||
"POST",
|
||||
"/api/auth/refresh",
|
||||
{
|
||||
refreshToken: this._session!.refreshToken,
|
||||
},
|
||||
false,
|
||||
);
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
account: data.account,
|
||||
};
|
||||
this.saveSession();
|
||||
} finally {
|
||||
this._refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this._session?.refreshToken) {
|
||||
try {
|
||||
await this.request(
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
{
|
||||
refreshToken: this._session.refreshToken,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Server logout failed", e);
|
||||
}
|
||||
}
|
||||
this.logoutLocal();
|
||||
}
|
||||
|
||||
logoutLocal(): void {
|
||||
this._session = null;
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
async getFriends(): Promise<LceLiveAccount[]> {
|
||||
const data = await this.request("GET", "/api/social/friends");
|
||||
return data.friends || [];
|
||||
}
|
||||
|
||||
async removeFriend(accountId: string): Promise<void> {
|
||||
await this.request("DELETE", `/api/social/friends/${accountId}`);
|
||||
}
|
||||
|
||||
async sendFriendRequest(username: string): Promise<void> {
|
||||
await this.request("POST", "/api/social/request", { username });
|
||||
}
|
||||
|
||||
async getPendingRequests(): Promise<PendingRequests> {
|
||||
const data = await this.request("GET", "/api/social/requests");
|
||||
return {
|
||||
incoming: (data.incoming || []).map((r: any) => ({
|
||||
accountId: r.requesterUserId || r.accountId || r.userId,
|
||||
username: r.requesterUsername || r.username,
|
||||
displayName: r.requesterDisplayName || r.displayName,
|
||||
})),
|
||||
outgoing: (data.outgoing || []).map((r: any) => ({
|
||||
accountId: r.targetUserId || r.accountId || r.userId,
|
||||
username: r.targetUsername || r.username,
|
||||
displayName: r.targetDisplayName || r.displayName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async acceptFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
|
||||
}
|
||||
|
||||
async getGameInvites(): Promise<GameInvite[]> {
|
||||
const data = await this.request("GET", "/api/sessions/invites");
|
||||
const incoming = data.incoming || [];
|
||||
return incoming.map((inv: any) => ({
|
||||
inviteId: inv.inviteId,
|
||||
from: {
|
||||
accountId: inv.senderAccountId,
|
||||
username: inv.senderUsername,
|
||||
displayName: inv.senderDisplayName,
|
||||
},
|
||||
hostIp: inv.hostIp,
|
||||
hostPort: inv.hostPort,
|
||||
hostName: inv.hostName,
|
||||
status: inv.status,
|
||||
signalingSessionId: inv.signalingSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
async sendGameInvite(
|
||||
recipientAccountId: string,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
hostName: string,
|
||||
signalingSessionId?: string,
|
||||
): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites", {
|
||||
recipientAccountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
hostName,
|
||||
signalingSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptGameInvite(inviteId: string): Promise<any> {
|
||||
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineGameInvite(inviteId: string): Promise<void> {
|
||||
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
|
||||
}
|
||||
|
||||
async deactivateGameInvites(): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites/deactivate", {});
|
||||
}
|
||||
|
||||
async requestJoinTicket(): Promise<string> {
|
||||
const data = await this.request("POST", "/api/sessions/ticket", {});
|
||||
return data.ticket;
|
||||
}
|
||||
|
||||
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
|
||||
return this.request("POST", "/api/sessions/validate", { ticket }, false);
|
||||
}
|
||||
}
|
||||
|
||||
export const lceLiveService = new LceLiveService();
|
||||
@@ -1,12 +1,18 @@
|
||||
import { setActivity, start } from "tauri-plugin-drpc";
|
||||
import { Activity, ActivityType, Assets, Timestamps, Button } from "tauri-plugin-drpc/activity";
|
||||
import {
|
||||
Activity,
|
||||
ActivityType,
|
||||
Assets,
|
||||
Timestamps,
|
||||
Button,
|
||||
} from "tauri-plugin-drpc/activity";
|
||||
class RPC {
|
||||
private startTime: number = Date.now();
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private initialized: boolean = false;
|
||||
public async StartRPC() {
|
||||
if (this.initialized) return;
|
||||
if (sessionStorage.getItem('lce_rpc_started') === 'true') {
|
||||
if (sessionStorage.getItem("lce_rpc_started") === "true") {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
@@ -15,7 +21,7 @@ class RPC {
|
||||
this.initializationPromise = (async () => {
|
||||
try {
|
||||
await start("1482504445152460871");
|
||||
sessionStorage.setItem('lce_rpc_started', 'true');
|
||||
sessionStorage.setItem("lce_rpc_started", "true");
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to start RPC:", e);
|
||||
@@ -26,7 +32,11 @@ class RPC {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
public async updateActivity(details: string, state: string, isPlaying: boolean = false) {
|
||||
public async updateActivity(
|
||||
details: string,
|
||||
state: string,
|
||||
isPlaying: boolean = false,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.StartRPC();
|
||||
if (!this.initialized) return;
|
||||
@@ -44,8 +54,8 @@ class RPC {
|
||||
activity.setAssets(assets);
|
||||
activity.setTimestamps(new Timestamps(this.startTime));
|
||||
activity.setButton([
|
||||
new Button("Discord", "https://discord.gg/NPMp9JhGYH"),
|
||||
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher")
|
||||
new Button("Discord", "https://discord.gg/cQVKhQXcCx"),
|
||||
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher"),
|
||||
]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface AppConfig {
|
||||
musicVol?: number;
|
||||
sfxVol?: number;
|
||||
legacyMode?: boolean;
|
||||
mangohudEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
@@ -226,4 +227,61 @@ export class TauriService {
|
||||
static async downloadLogo(id: string, url: string): Promise<string> {
|
||||
return invoke("download_logo", { id, url });
|
||||
}
|
||||
|
||||
static async addToSteam(
|
||||
instanceId: string,
|
||||
name: string,
|
||||
titleBase64: string,
|
||||
panoramaBase64: string
|
||||
): Promise<void> {
|
||||
return invoke("add_to_steam", { instanceId, name, titleBase64, panoramaBase64 });
|
||||
}
|
||||
|
||||
static async stunDiscover(): Promise<{ ip: string; port: number }> {
|
||||
return invoke("stun_discover");
|
||||
}
|
||||
|
||||
static async startDirectProxy(targetIp: string, targetPort: number): Promise<number> {
|
||||
return invoke("start_direct_proxy", { targetIp, targetPort });
|
||||
}
|
||||
|
||||
static async startRelayProxy(apiBaseUrl: string, accessToken: string, sessionId: string): Promise<number> {
|
||||
return invoke("start_relay_proxy", { apiBaseUrl, accessToken, sessionId });
|
||||
}
|
||||
|
||||
static async startHostRelay(apiBaseUrl: string, accessToken: string, sessionId: string, gamePort: number): Promise<void> {
|
||||
return invoke("start_host_relay", { apiBaseUrl, accessToken, sessionId, gamePort });
|
||||
}
|
||||
|
||||
static async stopProxy(): Promise<void> {
|
||||
return invoke("stop_proxy");
|
||||
}
|
||||
|
||||
static async joinGame(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
sessionId: string,
|
||||
instanceId: string,
|
||||
): Promise<void> {
|
||||
return invoke("join_game", { apiBaseUrl, accessToken, hostIp, hostPort, sessionId, instanceId });
|
||||
}
|
||||
|
||||
static async httpProxyRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: string | null,
|
||||
headers: Record<string, string>
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return invoke("http_proxy_request", { method, url, body, headers });
|
||||
}
|
||||
|
||||
static async getInstancePath(instanceId: string): Promise<string> {
|
||||
return invoke("get_instance_path", { instanceId });
|
||||
}
|
||||
|
||||
static async readScreenshotAsDataUrl(path: string): Promise<string> {
|
||||
return invoke("read_screenshot_as_data_url", { path });
|
||||
}
|
||||
}
|
||||
|
||||