Merge branch 'plugins_system' into develop

This commit is contained in:
Sakamoto Shiina
2025-04-27 04:17:49 +09:00
92 changed files with 4118 additions and 466 deletions

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ dist-ssr
.venv
# Customize
/build
/build
error.txt

View File

@@ -243,4 +243,31 @@ config_page:
open_config_filepath:
label: "Open Config File"
switch_compute_device:
label: "Switch VRCT To CPU/GPU Version"
label: "Switch VRCT To CPU/GPU Version"
section_label_plugins: Plugins # Exception, It'll be moved later.
plugins:
downloaded_version: "Downloaded version: {{downloaded_version}}"
latest_version: "Latest version: {{latest_version}}"
available_after_updating: "Available after updating to the latest version"
unavailable_downloaded: "Currently unavailable due to incompatibility with the VRCT version in use"
no_latest_info: "Unable to retrieve the latest information"
using_latest_version: "Using the latest version"
available_latest_version: "Latest version available"
unavailable_latest_version: "Latest version currently unavailable"
available_in_latest_vrct_version: "Available in the latest VRCT version"
unavailable_not_downloaded: "Currently unavailable"
plugin_notifications:
downloading: Downloading the plugin.
downloaded_success: Downloaded successfully.
downloaded_error: Download failed.
updating: Updating the plugin.
updated_success: Updated successfully.
updated_error: Update failed.
disabled_out_of_support: The plugin has been disabled. It's not supported on this VRCT version.
is_enabled: The plugin has enabled.
is_disabled: The plugin has disabled.

View File

@@ -243,4 +243,31 @@ config_page:
open_config_filepath:
label: "設定ファイルを開く"
switch_compute_device:
label: "VRCT CPU/GPUバージョンの切り替え"
label: "VRCT CPU/GPUバージョンの切り替え"
section_label_plugins: プラグイン # Exception, It'll be moved later.
plugins:
downloaded_version: "ダウンロード済バージョン: {{downloaded_version}}"
latest_version: "最新バージョン: {{latest_version}}"
available_after_updating: 最新版にアップデート後 利用可能
unavailable_downloaded: 現在利用不可 使用中VRCTバージョンとの互換性なし
no_latest_info: 最新情報が取得できません
using_latest_version: 最新版を使用中
available_latest_version: 最新版を利用可能
unavailable_latest_version: 最新版は現在利用不可
available_in_latest_vrct_version: VRCT最新版で利用可能
unavailable_not_downloaded: 現在利用不可
plugin_notifications:
downloading: プラグインをダウンロード中。
downloaded_success: プラグインのダウンロードが完了しました。
downloaded_error: プラグインのダウンロードに失敗しました。
updating: プラグインをアップデート中。
updated_success: プラグインのアップデートが完了しました。
updated_error: プラグインのアップデートに失敗しました。
disabled_out_of_support: 現在のバージョンとの互換性がありません。プラグインを無効にしました。
is_enabled: プラグインを有効にしました。
is_disabled: プラグインを無効にしました。

78
map-stack.js Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
// 使用例: node map-stack.js
// カレントディレクトリの error.txt を読み込み、各エラーフレームから
// 対応するソースマップdist/assets/ 以下の *.js.mapを用いて元の位置情報を出力する
import fs from "fs";
import path from "path";
import { SourceMapConsumer } from "source-map";
// 各スタックフレームにマッチする正規表現
const FRAME_REGEX = /^\s*at\s+(.*?)\s+\((.*):(\d+):(\d+)\)$/;
// スタックトレースのパース関数
const parseStackTrace = (text) => {
return text
.split(/\r?\n/)
.map((line) => {
const match = line.match(FRAME_REGEX);
if (match) {
return {
original: line,
functionName: match[1],
file: match[2],
line: Number(match[3]),
column: Number(match[4])
};
} else {
return { original: line };
}
});
};
// エラースタックをソースマップから逆引きする関数(位置情報のみを表示)
const mapStackTrace = async () => {
const errorTxtPath = path.resolve(process.cwd(), "error.txt");
const stackTraceText = fs.readFileSync(errorTxtPath, "utf8");
const frames = parseStackTrace(stackTraceText);
const consumerMap = new Map();
const mappedFrames = await Promise.all(frames.map(async (frame) => {
if (frame.file && frame.line && frame.column) {
const relativeFile = frame.file.replace(/^\//, ""); // 例: "assets/main-Td8-sruo.js"
const mapFilePath = path.resolve(process.cwd(), "dist", relativeFile + ".map");
let consumer = consumerMap.get(mapFilePath);
if (!consumer) {
const rawSourceMap = fs.readFileSync(mapFilePath, "utf8");
consumer = await new SourceMapConsumer(rawSourceMap);
consumerMap.set(mapFilePath, consumer);
}
const pos = consumer.originalPositionFor({
line: frame.line,
column: frame.column
});
if (pos && pos.source && pos.line != null && pos.column != null) {
return ` at ${frame.functionName} (${pos.source}:${pos.line}:${pos.column})`;
} else {
return frame.original;
}
} else {
return frame.original;
}
}));
consumerMap.forEach((consumer) => consumer.destroy());
return mappedFrames.join("\n");
};
mapStackTrace()
.then((mapped) => {
console.log("--- Mapped Stack Trace ---");
console.log(mapped);
})
.catch((err) => {
console.error(err);
process.exit(1);
});

621
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"release-all": "npm run release && npm run release-cuda"
},
"dependencies": {
"@babel/standalone": "7.26.9",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/material": "6.2.0",
@@ -34,17 +35,21 @@
"i18next": "24.1.0",
"jotai": "2.10.3",
"js-base64": "3.7.7",
"js-yaml": "4.1.0",
"jszip": "3.10.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "5.0.0",
"react-i18next": "15.2.0",
"react-resizable-layout": "0.7.2"
"react-resizable-layout": "0.7.2",
"semver": "7.7.1"
},
"devDependencies": {
"@rollup/plugin-yaml": "^4.1.2",
"@tauri-apps/cli": "1.6.3",
"npm-run-all": "4.1.5",
"sass": "1.79.4",
"vite": "6.0.3",
"source-map": "0.7.4",
"vite": "6.2.5",
"vite-plugin-svgr": "4.3.0"
}
}

View File

@@ -555,6 +555,18 @@ class Config:
self._HOTKEYS[key] = value
self.saveConfig(inspect.currentframe().f_code.co_name, self.HOTKEYS, immediate_save=True)
@property
@json_serializable('PLUGINS_STATUS')
def PLUGINS_STATUS(self):
return self._PLUGINS_STATUS
@PLUGINS_STATUS.setter
def PLUGINS_STATUS(self, value):
if isinstance(value, list):
if all(isinstance(item, dict) for item in value):
self._PLUGINS_STATUS = value
self.saveConfig(inspect.currentframe().f_code.co_name, self.PLUGINS_STATUS, immediate_save=True)
@property
@json_serializable('MIC_AVG_LOGPROB')
def MIC_AVG_LOGPROB(self):
@@ -1068,6 +1080,7 @@ class Config:
"toggle_transcription_send": None,
"toggle_transcription_receive": None,
}
self._PLUGINS_STATUS = []
self._MIC_AVG_LOGPROB = -0.8
self._MIC_NO_SPEECH_PROB = 0.6
self._AUTO_SPEAKER_SELECT = True

View File

@@ -447,11 +447,11 @@ class Controller:
return {"status":200, "result":config.VERSION}
def checkSoftwareUpdated(self) -> dict:
update_flag = model.checkSoftwareUpdated()
software_update_info = model.checkSoftwareUpdated()
self.run(
200,
self.run_mapping["update_software_flag"],
update_flag,
self.run_mapping["software_update_info"],
software_update_info,
)
@staticmethod
@@ -1061,6 +1061,15 @@ class Controller:
config.HOTKEYS = data
return {"status":200, "result":config.HOTKEYS}
@staticmethod
def getPluginsStatus(*args, **kwargs) -> dict:
return {"status":200, "result":config.PLUGINS_STATUS}
@staticmethod
def setPluginsStatus(data, *args, **kwargs) -> dict:
config.PLUGINS_STATUS = data
return {"status":200, "result":config.PLUGINS_STATUS}
@staticmethod
def getSpeakerAvgLogprob(*args, **kwargs) -> dict:
return {"status":200, "result":config.SPEAKER_AVG_LOGPROB}

View File

@@ -38,7 +38,7 @@ run_mapping = {
"mic_device_list":"/run/mic_device_list",
"speaker_device_list":"/run/speaker_device_list",
"update_software_flag":"/run/update_software_flag",
"software_update_info":"/run/software_update_info",
"initialization_progress":"/run/initialization_progress",
"initialization_complete":"/run/initialization_complete",
@@ -190,6 +190,9 @@ mapping = {
"/get/data/hotkeys": {"status": True, "variable":controller.getHotkeys},
"/set/data/hotkeys": {"status": True, "variable":controller.setHotkeys},
"/get/data/plugins_status": {"status": True, "variable":controller.getPluginsStatus},
"/set/data/plugins_status": {"status": True, "variable":controller.setPluginsStatus},
"/get/data/mic_avg_logprob": {"status": True, "variable":controller.getMicAvgLogprob},
"/set/data/mic_avg_logprob": {"status": True, "variable":controller.setMicAvgLogprob},

View File

@@ -320,6 +320,7 @@ class Model:
def checkSoftwareUpdated():
# check update
update_flag = False
version = ""
try:
response = requests_get(config.GITHUB_URL)
json_data = response.json()
@@ -331,7 +332,10 @@ class Model:
update_flag = True
except Exception:
errorLogging()
return update_flag
return {
"is_update_available": update_flag,
"new_version": version,
}
@staticmethod
def updateSoftware():

507
src-tauri/Cargo.lock generated
View File

@@ -6,7 +6,9 @@ version = 3
name = "VRCT"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"font-kit",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -218,6 +220,9 @@ name = "bytes"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
dependencies = [
"serde",
]
[[package]]
name = "cairo-rs"
@@ -729,7 +734,7 @@ dependencies = [
"rustc_version",
"toml 0.8.19",
"vswhom",
"winreg",
"winreg 0.52.0",
]
[[package]]
@@ -971,6 +976,12 @@ dependencies = [
"syn 2.0.94",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
@@ -984,8 +995,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
@@ -1297,6 +1311,25 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "h2"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap 2.7.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1361,12 +1394,86 @@ dependencies = [
"itoa 1.0.14",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa 1.0.14",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http",
"hyper",
"rustls",
"tokio",
"tokio-rustls",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "iana-time-zone"
version = "0.1.61"
@@ -1613,6 +1720,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "itoa"
version = "0.4.8"
@@ -1849,6 +1962,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.2"
@@ -1859,6 +1978,34 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
[[package]]
name = "native-tls"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.6.0"
@@ -1998,6 +2145,50 @@ dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "openssl"
version = "0.10.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.94",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2516,6 +2707,67 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64 0.21.7",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-rustls",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"winreg 0.50.0",
]
[[package]]
name = "ring"
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@@ -2544,6 +2796,37 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.19"
@@ -2565,6 +2848,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -2577,6 +2869,39 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.6.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.22.0"
@@ -2659,6 +2984,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa 1.0.14",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.12.0"
@@ -2784,6 +3121,16 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "socket2"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "soup2"
version = "0.2.1"
@@ -2812,6 +3159,12 @@ dependencies = [
"system-deps 5.0.0",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -2881,6 +3234,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "synstructure"
version = "0.13.1"
@@ -2892,6 +3251,27 @@ dependencies = [
"syn 2.0.94",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "5.0.0"
@@ -3000,6 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf327e247698d3f39af8aa99401c9708384290d1f5c544bf5d251d44c2fea22"
dependencies = [
"anyhow",
"bytes",
"cocoa 0.24.1",
"dirs-next",
"dunce",
@@ -3014,6 +3395,7 @@ dependencies = [
"heck 0.5.0",
"http",
"ignore",
"indexmap 1.9.3",
"log",
"objc",
"once_cell",
@@ -3024,6 +3406,7 @@ dependencies = [
"rand 0.8.5",
"raw-window-handle",
"regex",
"reqwest",
"semver",
"serde",
"serde_json",
@@ -3296,7 +3679,44 @@ checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"windows-sys 0.52.0",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
@@ -3367,6 +3787,12 @@ dependencies = [
"winnow 0.6.22",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
@@ -3428,6 +3854,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.17.0"
@@ -3446,6 +3878,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[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.4"
@@ -3491,6 +3929,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.0.11"
@@ -3539,6 +3983,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
@@ -3576,6 +4029,19 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.99"
@@ -3605,6 +4071,29 @@ version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "0.18.2"
@@ -3652,6 +4141,12 @@ dependencies = [
"system-deps 6.2.2",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webview2-com"
version = "0.19.1"
@@ -4058,6 +4553,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.52.0"

View File

@@ -11,11 +11,13 @@ edition = "2021"
tauri-build = { version = "1", features = [] }
[dependencies]
tauri = { version = "1", features = [ "window-hide", "window-set-focus", "global-shortcut-all", "window-set-size", "window-set-position", "window-unmaximize", "window-close", "window-maximize", "window-minimize", "window-unminimize", "window-start-dragging", "window-set-decorations", "window-set-always-on-top", "shell-sidecar", "shell-open", "devtools"] }
tauri = { version = "1", features = [ "fs-remove-dir", "fs-read-file", "fs-create-dir", "fs-write-file", "fs-exists", "http-request", "fs-read-dir", "window-hide", "window-set-focus", "global-shortcut-all", "window-set-size", "window-set-position", "window-unmaximize", "window-close", "window-maximize", "window-minimize", "window-unminimize", "window-start-dragging", "window-set-decorations", "window-set-always-on-top", "shell-sidecar", "shell-open", "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
font-kit = "0.14.2"
window-shadows = { git = "https://github.com/tauri-apps/window-shadows.git" }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
base64 = "0.22.1"
[features]

View File

@@ -0,0 +1 @@
// This is for preserving plugins folder. It will be detected and created 'plugins' folder to target/debug/ by tauri build. do not delete it.

View File

@@ -22,7 +22,7 @@ fn main() {
event.window().set_size(tauri::Size::Physical(*new_inner_size)).unwrap();
}
})
.invoke_handler(tauri::generate_handler![get_font_list])
.invoke_handler(tauri::generate_handler![get_font_list, download_zip_asset])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -45,4 +45,23 @@ async fn get_font_list() -> Vec<String> {
}
font_families.into_iter().collect()
}
#[tauri::command]
async fn download_zip_asset(url: String) -> Result<String, String> {
use reqwest;
// reqwest のクライアントを作成
let client = reqwest::Client::new();
// GET リクエストを送信(リダイレクトも自動追従します)
let resp = client.get(&url)
.header("Accept", "application/octet-stream")
.send()
.await.map_err(|e| format!("Request error: {}", e))?;
if !resp.status().is_success() {
return Err(format!("HTTP error: {}", resp.status()));
}
// レスポンスのバイナリデータを取得
let bytes = resp.bytes().await.map_err(|e| format!("Reading bytes error: {}", e))?;
// バイナリデータを base64 エンコードして返す
Ok(base64::encode(&bytes))
}

View File

@@ -30,6 +30,24 @@
"globalShortcut": {
"all": true
},
"fs": {
"readDir": true,
"readFile": true,
"exists": true,
"writeFile": true,
"createDir": true,
"removeDir": true,
"scope": ["$RESOURCE/**", "**/src-tauri/target/debug/plugins/**"]
},
"http": {
"request": true,
"scope": [
"https://api.github.com/repos/**",
"https://github.com/**",
"https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/vrct_plugins_list.json",
"https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/dev_vrct_plugins_list.json"
]
},
"shell": {
"all": false,
"open": true,
@@ -68,7 +86,8 @@
"bin/VRCT-sidecar"
],
"resources": {
"bin/_internal": "_internal"
"bin/_internal": "_internal",
"plugins": "plugins"
},
"windows": {
"nsis": {

View File

@@ -9,6 +9,7 @@ import {
UiSizeController,
FontFamilyController,
TransparencyController,
PluginsController,
} from "./_app_controllers/index.js";
import { WindowTitleBar } from "./window_title_bar/WindowTitleBar";
@@ -20,6 +21,7 @@ import { ModalController } from "./modal_controller/ModalController";
import { SnackbarController } from "./snackbar_controller/SnackbarController";
import styles from "./App.module.scss";
import { useIsBackendReady, useIsSoftwareUpdating, useIsVrctAvailable, useWindow } from "@logics_common";
import { AppErrorBoundary } from "./error_boundary/AppErrorBoundary";
export const App = () => {
const { currentIsVrctAvailable } = useIsVrctAvailable();
@@ -29,22 +31,24 @@ export const App = () => {
return (
<div className={styles.container}>
<KeyEventController />
<StartPythonController />
<GlobalHotKeyController />
<UiLanguageController />
<ConfigPageCloseTriggerController />
<UiSizeController />
<FontFamilyController />
<TransparencyController />
<WindowGeometryController />
<AppErrorBoundary >
<KeyEventController />
<StartPythonController />
<GlobalHotKeyController />
<UiLanguageController />
<ConfigPageCloseTriggerController />
<UiSizeController />
<FontFamilyController />
<TransparencyController />
<WindowGeometryController />
{(currentIsBackendReady.data === false || currentIsVrctAvailable.data === false)
? <SplashComponent />
: <Contents key={i18n.language}/>
}
{(currentIsBackendReady.data === false || currentIsVrctAvailable.data === false)
? <SplashComponent />
: <Contents key={i18n.language} />
}
<SnackbarController />
<SnackbarController />
</AppErrorBoundary>
</div>
);
};
@@ -53,6 +57,8 @@ const Contents = () => {
const { currentIsSoftwareUpdating } = useIsSoftwareUpdating();
return (
<>
<PluginsController />
<WindowTitleBar />
{currentIsSoftwareUpdating.data === false
?

View File

@@ -0,0 +1,24 @@
import React from "react";
import clsx from "clsx";
import * as reactI18next from "react-i18next";
if (typeof window !== "undefined") {
window.React = React;
window.clsx = clsx;
window.reactI18next = reactI18next;
}
import { LoadPluginsController } from "./plugins_controllers/LoadPluginsController";
import { FetchLatestPluginsDataController } from "./plugins_controllers/FetchLatestPluginsDataController";
import { MergePluginsController } from "./plugins_controllers/MergePluginsController";
export const PluginsController = () => {
return (
<>
<MergePluginsController />
<LoadPluginsController />
<FetchLatestPluginsDataController />
</>
);
};

View File

@@ -5,4 +5,5 @@ export { UiLanguageController } from "./UiLanguageController";
export { ConfigPageCloseTriggerController } from "./ConfigPageCloseTriggerController";
export { UiSizeController } from "./UiSizeController";
export { FontFamilyController } from "./FontFamilyController";
export { TransparencyController } from "./TransparencyController";
export { TransparencyController } from "./TransparencyController";
export { PluginsController } from "./PluginsController";

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { usePlugins } from "@logics_configs";
export const FetchLatestPluginsDataController = () => {
const {
asyncFetchPluginsInfo,
isAnyPluginEnabled_Init,
} = usePlugins();
useEffect(() => {
if (isAnyPluginEnabled_Init()) {
asyncFetchPluginsInfo();
}
}, []);
return null;
};

View File

@@ -0,0 +1,26 @@
import { useEffect } from "react";
import { usePlugins } from "@logics_configs";
import { store } from "@store";
export const LoadPluginsController = () => {
const {
asyncLoadAllPlugins,
} = usePlugins();
const asyncInitLoadPlugins = async () => {
try {
await asyncLoadAllPlugins();
} catch (error) {
console.error(error);
}
};
useEffect(() => {
if (!store.is_initialized_load_plugin) {
asyncInitLoadPlugins();
store.is_initialized_load_plugin = true;
}
}, []);
return null;
};

View File

@@ -0,0 +1,206 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { store } from "@store";
import { usePlugins } from "@logics_configs";
import { useSoftwareVersion } from "@logics_common";
import { useNotificationStatus } from "@logics_common";
export const MergePluginsController = () => {
const { t } = useTranslation();
const {
currentLoadedPlugins,
updatePluginsData,
currentPluginsData,
currentFetchedPluginsInfo,
currentSavedPluginsStatus,
downloadAndExtractPlugin,
setTargetSavedPluginsStatus_Init,
} = usePlugins();
const { checkVrctVerCompatibility } = useSoftwareVersion();
const { showNotification_Success, showNotification_Error } = useNotificationStatus();
// downloaded, fetched, saved の各情報をまとめてマージ
useEffect(() => {
const mergePluginData = () => {
updatePluginsData(prev => {
// downloaded, fetched, 保存済み状態のMapをそれぞれ作成plugin_id をキー)
const downloaded_map = new Map(
currentLoadedPlugins.data.map(info => [info.plugin_id, info])
);
const fetched_map = new Map(
currentFetchedPluginsInfo.data.map(info => [info.plugin_id, info])
);
const saved_map = new Map(
currentSavedPluginsStatus.data.map(saved => [saved.plugin_id, saved])
);
const prev_map = new Map(
prev.data.map(item => [item.plugin_id, item])
);
// union_keys: Saved以外の情報に対して重複なくキーを取得する
const union_keys = new Set([
...downloaded_map.keys(),
...fetched_map.keys(),
...prev_map.keys(),
]);
const new_data = [];
for (const id of union_keys) {
const downloaded = downloaded_map.get(id);
const fetched = fetched_map.get(id);
const prev_plugin = prev_map.get(id);
let plugin = {};
if (downloaded) {
// ダウンロード済み情報に対してサポート確認
const { is_plugin_supported, is_plugin_supported_latest_vrct } =
checkVrctVerCompatibility(
downloaded.min_supported_vrct_version,
downloaded.max_supported_vrct_version
);
plugin = {
// prevの情報があれば引き継ぎつつ上書き
...(prev_plugin || {}),
plugin_id: downloaded.plugin_id,
component: downloaded.component,
is_downloaded: true,
downloaded_plugin_info: {
...downloaded,
is_plugin_supported,
is_plugin_supported_latest_vrct,
},
};
if (fetched) {
const is_latest_version_available =
(downloaded.plugin_version !== fetched.plugin_version && fetched.is_plugin_supported);
plugin = {
...plugin,
is_outdated: false,
latest_plugin_info: { ...fetched },
is_latest_version_available:
plugin.is_downloaded && is_latest_version_available,
is_latest_version_already:
downloaded.plugin_version === fetched.plugin_version,
};
} else {
// フェッチ情報がない場合の初期状態
plugin = {
...plugin,
is_latest_version_available: false,
is_latest_version_already: true,
};
}
} else if (fetched) {
// フェッチ情報のみの場合は、ダウンロードしていない初期状態
plugin = {
...(prev_plugin || {}),
plugin_id: fetched.plugin_id,
is_downloaded: false,
is_latest_version_available: fetched.is_plugin_supported,
is_latest_version_already: false,
is_outdated: false,
latest_plugin_info: { ...fetched },
};
} else if (prev_plugin) {
// 既存情報のみ存在する場合は outdated フラグを付与
plugin = { ...prev_plugin, is_outdated: true };
}
// いずれかの情報がある場合のみ new_data に追加
if (plugin.plugin_id) {
new_data.push(plugin);
}
}
// 保存済み状態currentSavedPluginsStatusのマージ
// ・new_dataに存在する各プラグインに対して、保存済みの is_enabled を上書き
new_data.forEach(plugin => {
if (saved_map.has(plugin.plugin_id)) {
plugin.is_enabled = saved_map.get(plugin.plugin_id).is_enabled;
}
});
// ・prev.data には存在せず、保存済み情報にのみある場合は追加
for (const [id, saved] of saved_map.entries()) {
if (!new_data.some(item => item.plugin_id === id)) {
new_data.push({ plugin_id: saved.plugin_id, is_enabled: saved.is_enabled });
}
}
console.log("merged plugin data", new_data);
return new_data;
});
};
mergePluginData();
}, [currentFetchedPluginsInfo.data, currentLoadedPlugins.data, currentSavedPluginsStatus]);
// --- 自動アップデート(ダウンロード処理)---
// ※downloadAndExtractPlugin の重複実行を防ぐため、実行中の plugin_id を useRef で管理
const downloadingRef = useRef(new Set());
useEffect(() => {
if (!currentPluginsData.data.length) return;
// マージ結果の currentPluginsData.data を元にダウンロード処理をチェック
currentPluginsData.data.forEach(plugin => {
if (plugin.is_downloaded &&
plugin.is_enabled &&
!plugin.downloaded_plugin_info.is_plugin_supported &&
!plugin.is_latest_version_already &&
plugin.is_latest_version_available
) {
if (!downloadingRef.current.has(plugin.plugin_id)) {
showNotification_Success(t("plugin_notifications.updating"));
downloadingRef.current.add(plugin.plugin_id);
const target_plugin_id = plugin.plugin_id;
downloadAndExtractPlugin(plugin)
.then(() => {
console.log(`Plugin ${target_plugin_id} updated successfully`);
downloadingRef.current.delete(target_plugin_id);
showNotification_Success(t("plugin_notifications.updated_success"));
})
.catch((error) => {
console.error(`Plugin ${target_plugin_id} update failed`, error);
downloadingRef.current.delete(target_plugin_id);
showNotification_Error(t("plugin_notifications.updated_error"));
});
}
}
});
}, [currentPluginsData.data]);
useEffect(() => {
// ダウンロード済みかつ有効なプラグインで、サポート対象でない場合は無効化
if (store.is_initialized_fetched_plugin_info) {
updatePluginsData(prev => {
prev.data.forEach(plugin => {
if (plugin.is_downloaded && plugin.is_enabled) {
if (
!plugin.downloaded_plugin_info.is_plugin_supported &&
plugin.latest_plugin_info &&
!plugin.latest_plugin_info?.is_plugin_supported
) {
showNotification_Error(t("plugin_notifications.disabled_out_of_support"));
plugin.is_enabled = false;
setTargetSavedPluginsStatus_Init(plugin.plugin_id, false);
}
if (
!plugin.downloaded_plugin_info.is_plugin_supported &&
plugin.is_outdated
) {
showNotification_Error(t("plugin_notifications.disabled_out_of_support"));
plugin.is_enabled = false;
setTargetSavedPluginsStatus_Init(plugin.plugin_id, false);
}
}
});
return prev.data;
});
}
}, [store.is_initialized_fetched_plugin_info]);
return null;
};

View File

@@ -9,6 +9,7 @@ import {
AdvancedSettings,
Vr,
Hotkeys,
// Plugins,
Supporters,
AboutVrct,
} from "@setting_box";
@@ -32,6 +33,8 @@ export const SettingBox = () => {
return <Hotkeys />;
case "advanced_settings":
return <AdvancedSettings />;
// case "plugins":
// return <Plugins />;
case "supporters":
return <Supporters />;
case "about_vrct":

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import CircularProgress from "@mui/material/CircularProgress";
import styles from "./_DownloadButton.module.scss";
export const _DownloadButton = ({option, ...props}) => {
const { t } = useTranslation();
const renderContent = () => {
const circular_progress = Math.floor(option.progress / 10) * 10;
switch (true) {
case option.progress !== null:
return (
<>
<CircularProgress
variant={(option.progress === 100) ? "indeterminate" : "determinate"}
value={circular_progress}
size="3rem"
sx={{ color: "var(--primary_300_color)" }}
/>
<p className={styles.progress_label}>{`${Math.round(option.progress)}%`}</p>
</>
);
case option.is_pending:
return <CircularProgress size="3rem" sx={{ color: "var(--dark_600_color)" }}/>;
case !option.is_downloaded:
return (
<button
className={styles.download_button}
onClick={() => props.downloadStartFunction(option.id)}
>
<p className={styles.download_button_label}>{t("config_page.model_download_button_label")}</p>
</button>
);
case option.update_button:
return (
<button
className={styles.update_button}
onClick={() => props.downloadStartFunction(option.id)}
>
<p className={styles.download_button_label}>Update</p>
</button>
);
default:
return null;
}
};
return <div className={styles.download_container}>{renderContent()}</div>;
};

View File

@@ -0,0 +1,45 @@
@import "@scss_mixins";
.download_container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 8rem;
}
.download_button {
pointer-events: auto;
background-color: var(--dark_800_color);
padding: 0.8rem;
flex-shrink: 0;
border-radius: 0.2rem;
&:hover {
background-color: var(--dark_750_color);
}
&:active {
background-color: var(--dark_800_color);
}
}
.download_button_label {
font-size: 1.2rem;
}
.progress_label {
position: absolute;
font-size: 1rem;
}
.update_button {
pointer-events: auto;
background-color: var(--primary_400_color);
padding: 0.8rem;
flex-shrink: 0;
border-radius: 0.2rem;
&:hover {
background-color: var(--primary_450_color);
}
&:active {
background-color: var(--primary_500_color);
}
}

View File

@@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
import CircularProgress from "@mui/material/CircularProgress";
import styles from "./DownloadModels.module.scss";
import {
RadioButton,
} from "../index";
import { _DownloadButton } from "../_atoms/_download_button/_DownloadButton";
export const DownloadModels = (props) => {
const options = props.options.map(item => ({
@@ -19,47 +17,9 @@ export const DownloadModels = (props) => {
options={options}
checked_variable={props.checked_variable}
column={true}
ChildComponent={ModelSelector}
ChildComponent={_DownloadButton}
downloadStartFunction={props.downloadStartFunction}
/>
</>
);
};
const ModelSelector = ({option, ...props}) => {
const { t } = useTranslation();
const renderContent = () => {
const circular_progress = Math.floor(option.progress / 10) * 10;
switch (true) {
case option.progress !== null:
return (
<>
<CircularProgress
variant={(option.progress === 100) ? "indeterminate" : "determinate"}
value={circular_progress}
size="3rem"
sx={{ color: "var(--primary_300_color)" }}
/>
<p className={styles.progress_label}>{`${Math.round(option.progress)}%`}</p>
</>
);
case option.is_pending:
return <CircularProgress size="3rem" sx={{ color: "var(--dark_600_color)" }}/>;
case !option.is_downloaded:
return (
<button
className={styles.download_button}
onClick={() => props.downloadStartFunction(option.id)}
>
<p className={styles.download_button_label}>{t("config_page.model_download_button_label")}</p>
</button>
);
default:
return null;
}
};
return <div className={styles.download_container}>{renderContent()}</div>;
};
};

View File

@@ -1,30 +0,0 @@
@import "@scss_mixins";
.download_container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 8rem;
}
.download_button {
pointer-events: auto;
background-color: var(--dark_800_color);
padding: 0.8rem;
flex-shrink: 0;
&:hover {
background-color: var(--dark_750_color);
}
&:active {
background-color: var(--dark_800_color);
}
}
.download_button_label {
font-size: 1.2rem;
}
.progress_label {
position: absolute;
font-size: 1rem;
}

View File

@@ -2,7 +2,7 @@ import styles from "./HotkeysEntry.module.scss";
import { _Entry } from "../_atoms/_entry/_Entry";
import { useState, useRef, useEffect } from "react";
import DeleteSvg from "@images/cancel.svg?react";
import { clsx } from "clsx";
import clsx from "clsx";
export const HotkeysEntry = (props) => {
const [isAcceptingInput, setIsAcceptingInput] = useState(false);

View File

@@ -12,4 +12,5 @@ export { Slider } from "./slider/Slider";
export { SwitchBox } from "./switch_box/SwitchBox";
export { ThresholdComponent } from "./threshold_component/ThresholdComponent";
export { WordFilter, WordFilterListToggleComponent } from "./word_filter/WordFilter";
export { DownloadModels } from "./download_models/DownloadModels";
export { DownloadModels } from "./download_models/DownloadModels";
export { PluginsControlComponent } from "./plugins_control_component/PluginsControlComponent";

View File

@@ -0,0 +1,156 @@
import React from "react";
import { SwitchBox } from "../index";
import { _DownloadButton } from "../_atoms/_download_button/_DownloadButton";
import styles from "./PluginsControlComponent.module.scss";
import { useTranslation } from "react-i18next";
export const PluginsControlComponent = ({
variable_state,
plugin_status,
toggleFunction,
downloadStartFunction,
}) => {
const { t } = useTranslation();
const option = {
id: plugin_status.plugin_id,
is_pending: plugin_status.is_pending,
is_downloaded: plugin_status.is_downloaded,
data: plugin_status.is_enabled,
update_button: plugin_status.is_downloaded && plugin_status.is_latest_version_available,
state: variable_state,
progress: null,
};
const downloaded_version = plugin_status.downloaded_plugin_info?.plugin_version;
const latest_version = plugin_status.latest_plugin_info?.plugin_version;
const downloaded_version_label = t("config_page.plugins.downloaded_version",
{ downloaded_version: downloaded_version }
);
const latest_version_label = t("config_page.plugins.latest_version",
{ latest_version: latest_version }
);
if (plugin_status.is_downloaded) {
return (
<DownloadedPluginControl
option={option}
plugin_status={plugin_status}
toggleFunction={toggleFunction}
downloadStartFunction={downloadStartFunction}
downloaded_version_label={downloaded_version_label}
latest_version_label={latest_version_label}
/>
);
} else {
return (
<NotDownloadedPluginControl
option={option}
plugin_status={plugin_status}
downloadStartFunction={downloadStartFunction}
downloaded_version_label={downloaded_version_label}
latest_version_label={latest_version_label}
/>
);
}
};
const DownloadedPluginControl = ({
option,
plugin_status,
toggleFunction,
downloadStartFunction,
downloaded_version_label,
latest_version_label,
}) => {
const { t } = useTranslation();
const togglePlugin = () => {
toggleFunction(plugin_status.plugin_id);
};
if (!plugin_status.downloaded_plugin_info.is_plugin_supported) {
if (plugin_status.is_latest_version_available) {
return (
<div className={styles.container}>
<p>{downloaded_version_label}</p>
<p>{latest_version_label}</p>
<p>{t("config_page.plugins.available_after_updating")}</p>
<_DownloadButton option={option} downloadStartFunction={downloadStartFunction} />
</div>
);
}
return (
<div className={styles.container}>
<p>{t("config_page.plugins.unavailable_downloaded")}</p>
</div>
);
} else if (plugin_status.is_outdated) {
return (
<div className={styles.container}>
<p>{t("config_page.plugins.no_latest_info")}</p>
<SwitchBox variable={option} toggleFunction={togglePlugin} />
</div>
);
} else if (plugin_status.is_latest_version_already) {
return (
<div className={styles.container}>
<p>{latest_version_label}</p>
<p>{t("config_page.plugins.using_latest_version")}</p>
<SwitchBox variable={option} toggleFunction={togglePlugin} />
</div>
);
} else if (plugin_status.is_latest_version_available) {
return (
<div className={styles.container}>
<p>{latest_version_label}</p>
<p>{t("config_page.plugins.available_latest_version")}</p>
<_DownloadButton option={option} downloadStartFunction={downloadStartFunction} />
<SwitchBox variable={option} toggleFunction={togglePlugin} />
</div>
);
} else {
return (
<div className={styles.container}>
<p>{t("config_page.plugins.available_latest_version")}</p>
<SwitchBox variable={option} toggleFunction={togglePlugin} />
</div>
);
}
};
const NotDownloadedPluginControl = ({
option,
plugin_status,
downloadStartFunction,
downloaded_version_label,
latest_version_label,
}) => {
const { t } = useTranslation();
if (plugin_status.is_latest_version_available) {
return (
<div className={styles.container}>
<p>{latest_version_label}</p>
<_DownloadButton option={option} downloadStartFunction={downloadStartFunction} />
</div>
);
} else if (plugin_status.latest_plugin_info?.is_plugin_supported_latest_vrct) {
return (
<div className={styles.container}>
<p>{latest_version_label}</p>
<p>{t("config_page.plugins.available_in_latest_vrct_version")}</p>
</div>
);
} else {
return (
<div className={styles.container}>
<p>{latest_version_label}</p>
<p>{t("config_page.plugins.unavailable_not_downloaded")}</p>
</div>
);
}
};

View File

@@ -0,0 +1,11 @@
.container {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
}
.unavailable_text {
padding: 1rem;
font-size: 1.2rem;
}

View File

@@ -1,7 +1,7 @@
import React from "react";
import styles from "./Slider.module.scss";
import MUI_Slider from "@mui/material/Slider";
import { clsx } from "clsx";
import clsx from "clsx";
export const Slider = (props) => {
return (

View File

@@ -1,7 +1,6 @@
@import "@scss_mixins";
.switchbox_container {
width: 100%;
display: flex;
justify-content: end;
align-items: center;
@@ -22,6 +21,7 @@
}
.toggle_control {
position: relative;
@include toggle_control_styles;
display: flex;
justify-content: center;

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styles from "./AdvancedSettings.module.scss";
import { Plugins } from "./plugins/Plugins";
import { useOpenFolder } from "@logics_common";
import {
useOscIpAddress,
@@ -10,22 +12,35 @@ import {
import {
ActionButtonContainer,
EntryContainer,
EntryWithSaveButtonContainer,
} from "../_templates/Templates";
import {
SectionLabelComponent,
} from "../_components/";
import OpenFolderSvg from "@images/open_folder.svg?react";
import HelpSvg from "@images/help.svg?react";
export const AdvancedSettings = () => {
const { t } = useTranslation();
return (
<>
<OscIpAddressContainer />
<OscPortContainer />
<OpenConfigFolderContainer />
<OpenSwitchComputeDeviceModalContainer />
</>
<div className={styles.container}>
<div>
<OscIpAddressContainer />
<OscPortContainer />
<OpenConfigFolderContainer />
<OpenSwitchComputeDeviceModalContainer />
</div>
<div>
<SectionLabelComponent label={t("config_page.advanced_settings.section_label_plugins")} />
<Plugins />
</div>
</div>
);
};

View File

@@ -1,22 +1,5 @@
.container {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
padding: 2rem;
align-items: center;
gap: 2rem;
&.flex_column {
flex-direction: column;
}
border-bottom: solid 0.1rem var(--dark_800_color);
}
.switch_section_container {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
align-items: center;
gap: 2rem;
gap: 6.4rem;
flex-direction: column;
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { usePlugins } from "@logics_configs";
import styles from "./Plugins.module.scss";
import { PluginsControlComponent } from "../../_components/plugins_control_component/PluginsControlComponent";
import { useNotificationStatus } from "@logics_common";
export const Plugins = () => {
const {
asyncFetchPluginsInfo,
} = usePlugins();
const hasRunRef = useRef(false);
useEffect(() => {
if (!hasRunRef.current) {
asyncFetchPluginsInfo();
}
return () => hasRunRef.current = true;
}, []);
return (
<div className={styles.container}>
<PluginDownloadContainer />
</div>
);
};
const PluginDownloadContainer = () => {
const { t, i18n } = useTranslation();
const {
downloadAndExtractPlugin,
currentPluginsData,
currentSavedPluginsStatus,
toggleSavedPluginsStatus,
handlePendingPlugin,
currentFetchedPluginsInfo,
} = usePlugins();
const { showNotification_Success, showNotification_Error } = useNotificationStatus();
// ダウンロード開始時の状態更新処理
const downloadStartFunction = async (target_plugin_id) => {
handlePendingPlugin(target_plugin_id, true);
showNotification_Success(t("plugin_notifications.downloading"));
const target_plugin_info = currentPluginsData.data.find(
(d) => d.plugin_id === target_plugin_id
);
downloadAndExtractPlugin(target_plugin_info).then(() => {
handlePendingPlugin(target_plugin_id, false);
showNotification_Success(t("plugin_notifications.downloaded_success"));
}).catch(error => {
console.error(error);
showNotification_Error(t("plugin_notifications.downloaded_error"));
});
};
// プラグインのオンオフ切り替え処理
const toggleFunction = (target_plugin_id) => {
toggleSavedPluginsStatus(target_plugin_id);
};
const variable_state = currentSavedPluginsStatus.state;
const filtered_plugins_data = currentPluginsData.data.filter(plugin => !plugin.is_outdated)
// plugin_id で ABC 順にソート
const sorted_plugins_data = filtered_plugins_data.sort((a, b) =>
a.plugin_id.localeCompare(b.plugin_id)
);
// Duplicate
const is_failed_to_fetch = currentFetchedPluginsInfo.state === "error";
const is_fetching = currentFetchedPluginsInfo.state === "pending";
return (
<div className={styles.plugins_list_container}>
{is_failed_to_fetch && <p>Failed to fetch plugins data</p>}
{is_fetching && <p>Fetching plugins data...</p>}
{sorted_plugins_data.map((plugin) => {
const target_info = plugin.is_downloaded
? plugin.downloaded_plugin_info
: plugin.latest_plugin_info;
const target_locale = target_info.locales && target_info.locales[i18n.language]
? target_info.locales[i18n.language]
: {
title: target_info.title,
desc: target_info.desc || null,
};
return (
<div key={plugin.plugin_id} className={styles.plugin_wrapper}>
<div className={styles.labels_wrapper}>
<p className={styles.title}>
{target_locale.title}
</p>
<p className={styles.desc}>
{target_locale.desc}
</p>
{/* <p className={styles.plugin_id}>{plugin.plugin_id}</p> */}
</div>
<div className={styles.plugin_info_wrapper}>
{plugin.is_error ? (
<p style={{ color: "red" }}>Error: {plugin.error_message}</p>
) : (
<PluginsControlComponent
variable_state={variable_state}
toggleFunction={toggleFunction}
downloadStartFunction={downloadStartFunction}
plugin_status={plugin}
/>
)}
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,58 @@
.container {
display: flex;
gap: 6.4rem;
flex-direction: column;
}
.plugins_list_container {
display: flex;
flex-direction: column;
align-items: center;
}
.plugin_wrapper {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem;
gap: 2rem;
&:not(:last-child) {
border-bottom: 0.1rem solid var(--dark_750_color);
}
}
.labels_wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-width: 50%;
}
.plugin_info_wrapper {
display: flex;
justify-content: end;
align-items: center;
}
.title {
font-size: 1.6rem;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.desc {
font-size: 1.4rem;
width: 100%;
overflow: hidden;
color: var(--dark_500_color);
}
// .plugin_id {
// font-size: 1rem;
// color: var(--dark_600_color);
// width: 100%;
// overflow: hidden;
// white-space: nowrap;
// text-overflow: ellipsis;
// }

View File

@@ -6,5 +6,6 @@ export { Others, VrcMicMuteSyncContainer } from "./others/Others";
export { AdvancedSettings } from "./advanced_settings/AdvancedSettings";
export { Vr } from "./vr/Vr";
export { Hotkeys } from "./hotkeys/Hotkeys";
// export { Plugins } from "./plugins/Plugins";
export { AboutVrct } from "./about_vrct/AboutVrct";
export { Supporters } from "./supporters/Supporters";

View File

@@ -3,7 +3,7 @@ import fanbox_logo from "@images/supporters/fanbox_logo.png";
import kofi_logo from "@images/supporters/kofi_logo.png";
import patreon_logo from "@images/supporters/patreon_logo.png";
import styles from "./SupportUsContainer.module.scss";
import { clsx } from "clsx";
import clsx from "clsx";
export const SupportUsContainer = () => {
return (

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { clsx } from "clsx";
import clsx from "clsx";
import styles from "./Vr.module.scss";
import { ui_configs } from "@ui_configs";
import { Slider } from "../_components/";

View File

@@ -12,6 +12,7 @@ export const SidebarSection = () => {
<Tab tab_id="vr" />
<Tab tab_id="others" />
<Tab tab_id="hotkeys" />
{/* <Tab tab_id="plugins" /> */}
<Tab tab_id="advanced_settings" />
</div>
<div className={styles.separated_tabs_wrapper}>

View File

@@ -1,10 +1,9 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { clsx } from "clsx";
import clsx from "clsx";
import styles from "./VersionLabel.module.scss";
import { useSoftwareVersion } from "@logics_configs";
import { useComputeMode } from "@logics_common";
import { useSoftwareVersion, useComputeMode } from "@logics_common";
import CopySvg from "@images/copy.svg?react";
import CheckMarkSvg from "@images/check_mark.svg?react";

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import { appWindow } from "@tauri-apps/api/window";
import { ErrorBoundary } from "react-error-boundary";
import XMarkSvg from "@images/cancel.svg?react";
import CopySvg from "@images/copy.svg?react";
import CheckMarkSvg from "@images/check_mark.svg?react";
import { ContactsContainer } from "./contacts_container/ContactsContainer";
import styles from "./AppErrorBoundary.module.scss";
export const AppErrorBoundary = ({children}) => {
return (
<ErrorBoundary
fallbackRender={({ error }) => (
<ErrorContainer error={error} />
)
}>
{children}
</ErrorBoundary>
);
};
const ErrorContainer = ({error}) => {
const [is_copied, setIsCopied] = useState(false);
const formatted_stack = error ? formatStackTrace(error.stack) : "Unknown error";
const copyToClipboard = async () => {
if (is_copied) return;
await navigator.clipboard.writeText(formatted_stack);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
};
return (
<div className={styles.container}>
<CloseButtonContainer />
<div className={styles.wrapper}>
<p className={styles.error_message}>An error occurred. Please restart VRCT or contact the developers.</p>
{error ?
<div className={styles.error_detail_container}>
<div className={styles.error_stack_container}>
<p className={styles.error_stack}>
{formatted_stack}
</p>
</div>
<button className={styles.copy_error_message_button} onClick={copyToClipboard}>
<p className={styles.copy_text}>Copy</p>
{is_copied
? <CheckMarkSvg className={styles.check_mark_svg}/>
: <CopySvg className={styles.copy_svg}/>
}
</button>
</div>
: null}
<ContactsContainer />
</div>
</div>
);
};
const CloseButtonContainer = () => {
const close = () => {
appWindow.close();
};
return (
<button className={styles.close_button_wrapper} onClick={close}>
<div className={styles.close_button}>
<XMarkSvg className={styles.x_mark_svg}/>
</div>
</button>
);
};
const formatStackTrace = (stack) => {
if (!stack) return "";
// フルパスの除去(例として window.location.origin や絶対パス部分を削除)
// ※必要に応じて正規表現を調整してください
const formatted = stack.replace(new RegExp(window.location.origin, "g"), "");
return formatted;
};

View File

@@ -0,0 +1,110 @@
.container {
width: 100%;
height: 100%;
position: relative;
}
.wrapper {
width: 100%;
height: 100vh;
display: flex;
justify-content: safe center;
align-items: center;
flex-direction: column;
padding: 2rem;
overflow-y: auto;
}
.error_message {
font-size: 2rem;
text-align: center;
user-select: text;
margin-bottom: 3.2rem;
}
.error_detail_container {
display: flex;
flex-direction: column;
align-items: end;
gap: 1rem;
}
.error_stack_container {
max-height: 10rem;
width: 100%;
overflow-y: scroll;
padding: 1rem;
background-color: var(--dark_950_color);
border-radius: 0.4rem;
}
.error_stack {
font-size: 1rem;
user-select: text;
}
.copy_error_message_button {
// background-color: var(--dark_800_color);
padding: 0.8rem 1rem;
font-size: 1.4rem;
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
border-radius: 0.4rem;
background-color: var(--dark_825_color);
&:hover {
background-color: var(--dark_800_color);
}
&:active {
background-color: var(--dark_850_color);
}
}
.copy_svg {
width: 1.4rem;
color: var(--dark_500_color);
}
.check_mark_svg {
width: 1.4rem;
color: var(--primary_300_color);
}
.close_button_wrapper {
position: absolute;
top: 0;
left: 100%;
transform: translate(-50%, -50%) rotate(45deg);
display: flex;
justify-content: center;
align-items: end;
width: 68px;
aspect-ratio: 1 / 1;
background-color: var(--error_bc_color);
& .x_mark_svg {
color: var(--dark_200_color);
}
&:hover {
& .x_mark_svg {
transform: rotate(45deg);
}
}
&:active {
background-color: var(--error_bc_active_color);
}
transition: all 0.1s ease;
}
.close_button {
// width: 100%;
// height: 100%;
}
.x_mark_svg {
width: 24px;
transform: rotate(-45deg);
color: var(--dark_700_color);
transition: transform 0.3s ease;
}

View File

@@ -0,0 +1,29 @@
import styles from "./ContactsContainer.module.scss";
export const ContactsContainer = () => {
return (
<div className={styles.container}>
<OpenLinkContainer className={styles.github_issues} href_id="github_issues" text="Github Issues"/>
<OpenLinkContainer className={styles.google_forms} href_id="google_forms" text="Google Forms"/>
</div>
);
};
import dev_github_icon from "@images/about_vrct/dev_github_icon.png";
import document from "@images/document.png";
const contacts_links = {
github_issues: { img: dev_github_icon, href: "https://github.com/misyaguziya/VRCT/issues" },
google_forms: { img: document, href: "https://docs.google.com/forms/d/e/1FAIpQLSei-xoydOY60ivXqhOjaTzNN8PiBQIDcNhzfy6cw2sjYkcg_g/viewform" },
};
const OpenLinkContainer = ({className, href_id, text}) => {
const href = contacts_links[href_id].href;
const img = contacts_links[href_id].img;
return (
<a className={className} href={href} target="_blank" rel="noreferrer" >
<img className={styles.contact_button_icon} src={img} />
<p className={styles.contact_button_label}>{text}</p>
</a>
);
};

View File

@@ -0,0 +1,28 @@
.container {
display: flex;
gap: 3.2rem;
}
.github_issues, .google_forms {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 1rem;
border-radius: 0.4rem;
gap: 1rem;
&:hover {
background-color: var(--dark_800_color);
}
&:active {
background-color: var(--dark_900_color)
}
}
.contact_button_icon {
width: 5.2rem;
}
.contact_button_label {
font-size: 1.4rem;
white-space: nowrap;
}

View File

@@ -9,12 +9,27 @@ import { useStore_IsOpenedLanguageSelector } from "@store";
import { useLanguageSettings } from "@logics_main";
import { useEffect } from "react";
import { PluginHost } from "./PluginHost";
import { usePlugins } from "@logics_configs";
export const MainSection = () => {
const { currentPluginsData } = usePlugins();
const render_plugins = currentPluginsData.data.filter((plugin) => (
plugin.is_downloaded &&
plugin.is_enabled &&
plugin.downloaded_plugin_info.is_plugin_supported &&
plugin.downloaded_plugin_info.location === "main_section"
));
return (
<div className={styles.container}>
<TopBar />
<MessageContainer />
{render_plugins.length
? <PluginHost render_components={render_plugins}/>
: <MessageContainer />
}
<HandleLanguageSelector />
</div>
);

View File

@@ -4,7 +4,8 @@
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
// justify-content: space-between;
overflow-y: auto;
}
.language_selector_container {

View File

@@ -0,0 +1,14 @@
import React from "react";
export const PluginHost = ({render_components}) => {
return (
<>
{render_components
.map((plugin, index) => {
const PluginComponent = plugin.component;
return PluginComponent ? <PluginComponent key={index} /> : null;
})}
</>
);
};

View File

@@ -4,7 +4,7 @@ import RefreshSvg from "@images/refresh.svg?react";
import HelpSvg from "@images/help.svg?react";
import { useStore_OpenedQuickSetting } from "@store";
import { useIsSoftwareUpdateAvailable } from "@logics_common";
import { useSoftwareVersion } from "@logics_common";
import { useIsEnabledOverlaySmallLog, useIsEnabledOverlayLargeLog, useEnableVrcMicMuteSync } from "@logics_configs";
import { OpenQuickSettingButton } from "./_buttons/OpenQuickSettingButton";
@@ -66,9 +66,9 @@ const OpenVrcMicMuteSyncQuickSetting = () => {
};
const SoftwareUpdateAvailableButton = () => {
const { currentIsSoftwareUpdateAvailable } = useIsSoftwareUpdateAvailable();
const { currentLatestSoftwareVersionInfo } = useSoftwareVersion();
const { t } = useTranslation();
if (currentIsSoftwareUpdateAvailable.data === false) return null;
if (currentLatestSoftwareVersionInfo.data.is_update_available === false) return null;
const { updateOpenedQuickSetting } = useStore_OpenedQuickSetting();

View File

@@ -1,9 +1,16 @@
import clsx from "clsx";
import styles from "./UpdateModal.module.scss";
import { useTranslation } from "react-i18next";
import { useStore_OpenedQuickSetting } from "@store";
import { useComputeMode, useUpdateSoftware } from "@logics_common";
import { useIsSoftwareUpdating, useIsSoftwareUpdateAvailable } from "@logics_common";
import clsx from "clsx";
import { usePlugins } from "@logics_configs";
import {
useComputeMode,
useUpdateSoftware,
useIsSoftwareUpdating,
useSoftwareVersion,
} from "@logics_common";
import { PluginCompatibilityList } from "./plugins_compatibility_list/PluginCompatibilityList";
export const UpdateModal = () => {
const { t } = useTranslation();
@@ -11,9 +18,10 @@ export const UpdateModal = () => {
const { updateSoftware, updateSoftware_CUDA } = useUpdateSoftware();
const { updateIsSoftwareUpdating } = useIsSoftwareUpdating();
const { currentComputeMode } = useComputeMode();
const { currentIsSoftwareUpdateAvailable } = useIsSoftwareUpdateAvailable();
const { currentLatestSoftwareVersionInfo } = useSoftwareVersion();
const { isAnyPluginEnabled } = usePlugins();
const is_latest_version_already = currentIsSoftwareUpdateAvailable.data === false;
const is_latest_version_already = currentLatestSoftwareVersionInfo.data.is_update_available === false;
const is_cpu_version = currentComputeMode.data === "cpu";
const onClickUpdateSoftware = () => {
@@ -37,30 +45,34 @@ export const UpdateModal = () => {
return (
<div className={styles.container}>
<div className={styles.wrapper}>
<div className={styles.update_section}>
<div className={styles.cpu_section}>
<div className={styles.button_wrapper}>
<button className={cpu_accept_button_class_name} onClick={onClickUpdateSoftware}>CPU</button>
{is_cpu_version ? <CurrentVersionLabel is_latest_version_already={is_latest_version_already} /> : null}
<div className={styles.update_section_wrapper}>
{isAnyPluginEnabled() && <PluginCompatibilityList />}
<div className={styles.update_section}>
<div className={styles.cpu_section}>
<div className={styles.button_wrapper}>
<button className={cpu_accept_button_class_name} onClick={onClickUpdateSoftware}>CPU</button>
{is_cpu_version ? <CurrentVersionLabel is_latest_version_already={is_latest_version_already} /> : null}
</div>
<div className={styles.version_desc_container}>
<VersionDescComponent desc={t("update_modal.cpu_desc")} />
</div>
</div>
<div className={styles.version_desc_container}>
<VersionDescComponent desc={t("update_modal.cpu_desc")} />
<div className={styles.cuda_section}>
<div className={styles.button_wrapper}>
<button className={cuda_accept_button_class_name} onClick={onClickUpdateSoftware_CUDA}>CUDA (GPU)</button>
{!is_cpu_version ? <CurrentVersionLabel is_latest_version_already={is_latest_version_already} is_cuda={true}/> : null}
</div>
<div className={styles.version_desc_container}>
<VersionDescComponent desc={t("update_modal.cuda_desc")} />
<VersionDescComponent desc={t("update_modal.cuda_compare_cpu_desc")} />
<VersionDescComponent desc={t("update_modal.cuda_disk_space_desc", {size: "5GB"})} />
</div>
</div>
</div>
<div className={styles.cuda_section}>
<div className={styles.button_wrapper}>
<button className={cuda_accept_button_class_name} onClick={onClickUpdateSoftware_CUDA}>CUDA (GPU)</button>
{!is_cpu_version ? <CurrentVersionLabel is_latest_version_already={is_latest_version_already} is_cuda={true}/> : null}
</div>
<div className={styles.version_desc_container}>
<VersionDescComponent desc={t("update_modal.cuda_desc")} />
<VersionDescComponent desc={t("update_modal.cuda_compare_cpu_desc")} />
<VersionDescComponent desc={t("update_modal.cuda_disk_space_desc", {size: "5GB"})} />
</div>
</div>
<p className={styles.update_desc}>{t("update_modal.download_latest_and_restart")}</p>
<p className={styles.update_desc}>{t("update_modal.download_latest_and_restart")}</p>
</div>
</div>
<div className={styles.button_wrapper}>
<button className={styles.deny_button} onClick={() => updateOpenedQuickSetting("")} >{t("update_modal.close_modal")}</button>
</div>

View File

@@ -3,7 +3,7 @@
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: safe center;
align-items: center;
gap: 2.4rem;
}
@@ -16,6 +16,14 @@
gap: 8rem;
}
.update_section_wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4rem;
}
.update_section {
border: 0.1rem solid var(--dark_600_color);
border-radius: 0.4rem;

View File

@@ -0,0 +1,78 @@
import { useEffect } from "react";
import styles from "./PluginCompatibilityList.module.scss";
import { usePlugins } from "@logics_configs";
import CheckMarkSvg from "@images/check_mark.svg?react";
import XSvg from "@images/x_mark.svg?react";
import WarningSvg from "@images/warning.svg?react";
export const PluginCompatibilityList = () => {
const {
enabledPluginsList,
asyncFetchPluginsInfo,
currentFetchedPluginsInfo,
} = usePlugins();
useEffect(() => {
asyncFetchPluginsInfo();
}, []);
// ダウンロード済みのもの
const downloaded_plugin = enabledPluginsList().filter(p => p.is_downloaded);
const compatible_plugins_list = [];
const incompatible_plugins_list = [];
for (const p of downloaded_plugin) {
if (!p.downloaded_plugin_info?.is_plugin_supported_latest_vrct && !p.latest_plugin_info?.is_plugin_supported_latest_vrct) {
// プラグイン最新版でも、VRCT最新版VRCTアプデ後に非対応のもの
incompatible_plugins_list.push(p);
} else {
// 現プラグイン or 最新版が、VRCT最新版VRCTアプデ後に対応しているもの
compatible_plugins_list.push(p);
}
}
const is_any_incompatible_plugin = incompatible_plugins_list.length > 0;
const is_any_compatible_plugin = compatible_plugins_list.length > 0;
if (!is_any_incompatible_plugin && !is_any_compatible_plugin) return null; // This is just for safety.
// Duplicate
const is_failed_to_fetch = currentFetchedPluginsInfo.state === "error";
const is_fetching = currentFetchedPluginsInfo.state === "pending";
return (
<div className={styles.container}>
<p className={styles.title}>使用中プラグインの互換性チェック</p>
{is_failed_to_fetch && <p>Failed to fetch plugins data</p>}
{is_fetching && <p>Fetching plugins data...</p>}
<div className={styles.plugins_compatibility_container}>
{incompatible_plugins_list.map(plugin => {
const target_data = plugin.downloaded_plugin_info;
return <PluginContainer key={target_data.plugin_id} target_data={target_data} is_compatible={false}/>;
})}
{compatible_plugins_list.map(plugin => {
const target_data = plugin.downloaded_plugin_info;
return <PluginContainer key={target_data.plugin_id} target_data={target_data} is_compatible={true} />;
})}
</div>
{is_any_incompatible_plugin &&
<div className={styles.warning_container}>
<WarningSvg className={styles.warning_svg}/>
<p className={styles.warning_text}>VRCT最新バージョンで互換性のないプラグインはアップデート後に無効化されます引き続き使用したい場合は各プラグインの更新を待ってください</p>
</div>
}
</div>
);
};
const PluginContainer = ({ target_data, is_compatible }) => {
return (
<div className={styles.plugin_box}>
<p className={clsx(styles.plugin_label, {[styles.is_compatible]: is_compatible})} >{target_data.title}</p>
{is_compatible
? <CheckMarkSvg className={styles.check_mark_svg}/>
: <XSvg className={styles.x_svg}/>
}
</div>
);
};

View File

@@ -0,0 +1,63 @@
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 1rem;
}
.title {
font-size: 1.6rem;
}
.plugins_compatibility_container {
display: flex;
justify-content: center;
align-items: center;
gap: 0.2rem 1rem;
flex-wrap: wrap;
}
.plugin_box {
display: flex;
justify-content: center;
align-items: center;
padding: 0.4rem 0.6rem;
gap: 0.6rem;
}
.plugin_label {
font-size: 1.4rem;
color: var(--error_bc_color);
&.is_compatible {
color: var(--primary_300_color);
}
}
.check_mark_svg {
width: 1.8rem;
color: var(--primary_300_color);
}
.x_svg {
width: 1.8rem;
color: var(--error_bc_color);
}
.warning_container {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.warning_svg {
padding-bottom: 0.4rem;
width: 2.4rem;
color: var(--waring_color);
flex-shrink: 0;
}
.warning_text {
font-size: 1.2rem;
}

View File

@@ -1,4 +1,4 @@
import { clsx } from "clsx";
import clsx from "clsx";
import Snackbar from "@mui/material/Snackbar";
import Slide from "@mui/material/Slide";

BIN
src-ui/assets/document.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
src-ui/assets/x_mark.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.954 21.03l-9.184-9.095 9.092-9.174-2.832-2.807-9.09 9.179-9.176-9.088-2.81 2.81 9.186 9.105-9.095 9.184 2.81 2.81 9.112-9.192 9.18 9.1z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@@ -1,4 +1,4 @@
import { clsx } from "clsx";
import clsx from "clsx";
import styles from "./Checkbox.module.scss";
export const Checkbox = ({
checkboxId,

View File

@@ -1,9 +1,9 @@
export { useSoftwareVersion } from "./useSoftwareVersion";
export { useComputeMode } from "./useComputeMode";
export { useInitProgress } from "./useInitProgress";
export { useIsBackendReady } from "./useIsBackendReady";
export { useWindow } from "./useWindow";
export { useIsOpenedConfigPage } from "./useIsOpenedConfigPage";
export { useIsSoftwareUpdateAvailable } from "./useIsSoftwareUpdateAvailable";
export { useIsSoftwareUpdating } from "./useIsSoftwareUpdating";
export { useNotificationStatus } from "./useNotificationStatus";
export { useOpenFolder } from "./useOpenFolder";
@@ -11,4 +11,5 @@ export { useMessage } from "./useMessage";
export { useUpdateSoftware } from "./useUpdateSoftware";
export { useVolume } from "./useVolume";
export { useHandleNetworkConnection } from "./useHandleNetworkConnection";
export { useIsVrctAvailable } from "./useIsVrctAvailable";
export { useIsVrctAvailable } from "./useIsVrctAvailable";
export { useFetch } from "./useFetch";

View File

@@ -0,0 +1,21 @@
import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
export const useFetch = () => {
const asyncTauriFetchGithub = async (url) => {
console.log("tauriFetch", url);
const release_response = await tauriFetch(url, {
method: "GET",
responseType: ResponseType.Json,
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": "VRCTPluginApp"
}
});
return release_response;
};
return {
asyncTauriFetchGithub,
};
};

View File

@@ -1,10 +0,0 @@
import { useStore_IsSoftwareUpdateAvailable } from "@store";
export const useIsSoftwareUpdateAvailable = () => {
const { currentIsSoftwareUpdateAvailable, updateIsSoftwareUpdateAvailable } = useStore_IsSoftwareUpdateAvailable();
return {
currentIsSoftwareUpdateAvailable,
updateIsSoftwareUpdateAvailable,
};
};

View File

@@ -0,0 +1,40 @@
import semver from "semver";
import { useStore_SoftwareVersion, useStore_LatestSoftwareVersionInfo } from "@store";
import { useStdoutToPython } from "@logics/useStdoutToPython";
export const useSoftwareVersion = () => {
const { asyncStdoutToPython } = useStdoutToPython();
const { currentLatestSoftwareVersionInfo, updateLatestSoftwareVersionInfo } = useStore_LatestSoftwareVersionInfo();
const { currentSoftwareVersion, updateSoftwareVersion, pendingSoftwareVersion } = useStore_SoftwareVersion();
const getSoftwareVersion = () => {
pendingSoftwareVersion();
asyncStdoutToPython("/get/data/version");
};
const isPluginCompatible = (main_version, lower_version, upper_version) => {
// lower_version 以上かつ upper_version 以下なら互換性ありと判定
return semver.gte(main_version, lower_version) && semver.lte(main_version, upper_version);
};
const checkVrctVerCompatibility = (min_version, max_version) => {
const current_vrct_version = currentSoftwareVersion.data;
const latest_vrct_version = currentLatestSoftwareVersionInfo.data.new_version;
const is_plugin_supported = isPluginCompatible(current_vrct_version, min_version, max_version);
const is_plugin_supported_latest_vrct = isPluginCompatible(latest_vrct_version, min_version, max_version);
return { is_plugin_supported, is_plugin_supported_latest_vrct };
};
return {
currentSoftwareVersion,
getSoftwareVersion,
updateSoftwareVersion,
currentLatestSoftwareVersionInfo,
updateLatestSoftwareVersionInfo,
checkVrctVerCompatibility,
};
};

View File

@@ -60,6 +60,6 @@ export { useOscPort } from "./advanced_settings/useOscPort";
export { useSupporters } from "./supporters/useSupporters";
export { usePlugins } from "./plugins/usePlugins";
export { useSettingBoxScrollPosition } from "./useSettingBoxScrollPosition";
export { useSoftwareVersion } from "./useSoftwareVersion";
export { useSettingBoxScrollPosition } from "./useSettingBoxScrollPosition";

View File

@@ -0,0 +1,451 @@
import { invoke } from "@tauri-apps/api/tauri";
import { useTranslation } from "react-i18next";
import { IS_PLUGIN_PATH_DEV_MODE, getPluginsList } from "@ui_configs";
import {
store,
createAtomWithHook,
useStore_SavedPluginsStatus,
useStore_PluginsData,
useStore_FetchedPluginsInfo,
useStore_LoadedPlugins,
} from "@store";
import { useStdoutToPython } from "@logics/useStdoutToPython";
import { transform } from "@babel/standalone";
import { writeFile, createDir, exists, removeDir, readDir, BaseDirectory, readTextFile } from "@tauri-apps/api/fs";
import { dev_plugins } from "@plugins_index";
const imported_dev_plugins = [];
dev_plugins.forEach(async ({entry_path}) => {
imported_dev_plugins.push({
index: await import(`@plugins_path/${entry_path}/index.jsx`),
downloaded_plugin_info: await import(`@plugins_path/${entry_path}/plugin_info.json`),
});
})
import JSZip from "jszip";
import { useFetch, useSoftwareVersion, useNotificationStatus } from "@logics_common";
import * as logics_configs from "@logics_configs";
import * as logics_main from "@logics_main";
import * as logics_common from "@logics_common";
// PLUGIN_LIST_URL は中央リポジトリにある、各プラグインの plugin_info.json への URL の配列を保持する JSON の URL
const PLUGIN_LIST_URL = getPluginsList();
export const usePlugins = () => {
const { t } = useTranslation();
const { showNotification_Success, showNotification_Error } = useNotificationStatus();
const { asyncStdoutToPython } = useStdoutToPython();
const { currentFetchedPluginsInfo, updateFetchedPluginsInfo, pendingFetchedPluginsInfo, errorFetchedPluginsInfo } = useStore_FetchedPluginsInfo();
const { currentLoadedPlugins, updateLoadedPlugins, pendingLoadedPlugins } = useStore_LoadedPlugins();
const { currentSavedPluginsStatus, updateSavedPluginsStatus, pendingSavedPluginsStatus } = useStore_SavedPluginsStatus();
const { currentPluginsData, updatePluginsData, pendingPluginsData } = useStore_PluginsData();
const { checkVrctVerCompatibility } = useSoftwareVersion();
const { asyncTauriFetchGithub } = useFetch();
const { i18n } = useTranslation();
const generatePluginContext = (downloaded_plugin_info) => {
const plugin_context = {
registerComponent: (component) => {
if (!downloaded_plugin_info.plugin_id || !downloaded_plugin_info.location || !component) {
return console.error("An invalid plugin was detected.", downloaded_plugin_info.plugin_id, downloaded_plugin_info.location, component);
}
updateLoadedPlugins(prev => {
const prev_map = new Map(prev.data.map(item => [item.plugin_id, item]));
prev_map.set(downloaded_plugin_info.plugin_id, {
...downloaded_plugin_info,
component: component,
});
return Array.from(prev_map.values());
});
},
createAtomWithHook: (...args) => createAtomWithHook(...args),
logics: { ...logics_common, ...logics_configs, ...logics_main },
i18n: i18n,
};
return plugin_context;
}
const asyncLoadPlugin = async (plugin_folder_relative_path) => {
const init_path = "plugins/" + plugin_folder_relative_path + "/index.esm.js";
const downloaded_plugin_info_path = "plugins/" + plugin_folder_relative_path + "/plugin_info.json";
const plugin_css_path = "plugins/" + plugin_folder_relative_path + "/main.css";
try {
const downloaded_plugin_info_json = await readTextFile(downloaded_plugin_info_path, { dir: BaseDirectory.Resource, recursive: true });
const downloaded_plugin_info = JSON.parse(downloaded_plugin_info_json);
const plugin_code = await readTextFile(init_path, { dir: BaseDirectory.Resource, recursive: true });
const cleaned_code = removeImportStatements(plugin_code);
const transpiled_code = transform(cleaned_code, {
presets: [
["env", { modules: false }],
"react",
],
sourceType: "module"
}).code;
const blob = new Blob([transpiled_code], { type: "text/javascript" });
const blob_url = URL.createObjectURL(blob);
const plugin_module = await import(/* @vite-ignore */ blob_url);
URL.revokeObjectURL(blob_url);
if (plugin_module && plugin_module.init) {
plugin_module.init(generatePluginContext(downloaded_plugin_info));
}
await loadPluginCSS(plugin_css_path);
} catch (error) {
console.error("Failed to load plugin from", plugin_folder_relative_path, error);
}
};
const asyncLoadAllPlugins = async () => {
if (IS_PLUGIN_PATH_DEV_MODE) {
imported_dev_plugins.forEach(({ index, downloaded_plugin_info }) => {
if (!index || !downloaded_plugin_info) {
console.error("Invalid development plugin detected", index, downloaded_plugin_info);
return;
}
const plugin_context = generatePluginContext(downloaded_plugin_info);
if (index.init) {
index.init(plugin_context);
} else {
console.error("Plugin missing init function", downloaded_plugin_info);
}
});
} else {
const is_plugins_dir_exists = await exists("plugins", { dir: BaseDirectory.Resource });
if (!is_plugins_dir_exists) return;
try {
const plugin_entries = await readDir("plugins", { dir: BaseDirectory.Resource, recursive: true });
const plugin_files = plugin_entries.filter(entry => entry.children && Array.isArray(entry.children));
for (const target_dir of plugin_files) {
const target_path = target_dir.name;
await asyncLoadPlugin(target_path);
}
} catch (error) {
console.error("Error loading plugins:", error);
}
}
};
const downloadAndExtractPlugin = async (plugin) => {
const latest_plugin_info = plugin.latest_plugin_info;
try {
const plugin_zip_url = await fetchLatestPluginZipUrl(latest_plugin_info);
console.log("start download", plugin_zip_url);
// Rust コマンド経由で ZIP をダウンロード
const base64_zip = await invoke("download_zip_asset", { url: plugin_zip_url });
// base64_zip をデコードして Uint8Array に変換
const binary_string = atob(base64_zip);
const len = binary_string.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
// JSZip で ZIP を解凍
const zip = await JSZip.loadAsync(bytes);
// 展開先ディレクトリのパス(例:"plugins/<plugin_id>" とする)
const target_plugin_path = "plugins/" + latest_plugin_info.plugin_id;
// 既に存在する場合は削除してから新規作成
if (await exists(target_plugin_path, { dir: BaseDirectory.Resource, recursive: true })) {
await removeDir(target_plugin_path, { dir: BaseDirectory.Resource, recursive: true });
}
await createDir(target_plugin_path, { dir: BaseDirectory.Resource, recursive: true });
const file_promises = [];
zip.forEach((relative_path, zip_entry) => {
// .git 以下のファイルはスキップ
if (relative_path.startsWith(".git") || relative_path.includes("/.git/")) {
return;
}
const file_path = target_plugin_path + "/" + relative_path;
if (zip_entry.dir) {
file_promises.push(
createDir(file_path, { dir: BaseDirectory.Resource, recursive: true }).catch((err) => {
if (!err.message?.includes("already exists")) {
console.error("Failed to create directory:", file_path, err);
}
})
);
} else {
const dir_path = file_path.substring(0, file_path.lastIndexOf("/"));
const promise = createDir(dir_path, { dir: BaseDirectory.Resource, recursive: true })
.catch((err) => {
if (!err.message?.includes("already exists")) {
console.error("Failed to create parent directory:", dir_path, err);
}
})
.then(() => zip_entry.async("text"))
.then(async (file_data) => {
await writeFile(file_path, file_data, { dir: BaseDirectory.Resource, recursive: true });
});
file_promises.push(promise);
}
});
await Promise.all(file_promises);
console.log("Plugin downloaded successfully.");
const index_file_relative_path = plugin.plugin_id;
await asyncLoadPlugin(index_file_relative_path);
console.log("Plugin loaded successfully.");
} catch (error) {
console.error("Error downloading and extracting plugin:", error);
}
};
const fetchLatestPluginZipUrl = async (plugin) => {
const api_url = plugin.url;
const response = await asyncTauriFetchGithub(api_url);
if (response.status !== 200) {
throw new Error("Failed to fetch latest release info, status: " + response.status);
}
const release_info = response.data;
const asset = release_info.assets.find((a) => a.name === plugin.asset_name);
if (!asset) {
throw new Error(`Asset ${plugin.asset_name} not found in the latest release`);
}
return asset.browser_download_url;
};
const asyncFetchPluginsInfo = async () => {
if (store.is_fetched_plugins_info_already) return;
store.is_fetched_plugins_info_already = true;
try {
const response = await asyncTauriFetchGithub(PLUGIN_LIST_URL);
if (response.status !== 200) {
throw new Error("Failed to fetch plugins list, status: " + response.status);
}
const plugins_data = response.data;
const updated_list = await Promise.all(
plugins_data.map(async (plugin_data) => {
try {
const plugin_info = await asyncFetchPluginInfo(plugin_data.url);
return plugin_info;
} catch (error) {
console.error("Error fetching plugin info for URL: ", plugin_data.url, error);
return {
title: plugin_data.title,
plugin_id: plugin_data.plugin_id || plugin_data.title,
is_error: true,
error_message: error.message,
url: plugin_data.url
};
}
})
);
updateFetchedPluginsInfo(updated_list);
} catch (error) {
console.error("Error fetching plugin info list: ", error);
errorFetchedPluginsInfo();
}
store.is_initialized_fetched_plugin_info = true;
}
const asyncFetchPluginInfo = async (plugin_info_asset_url) => {
const release_response = await asyncTauriFetchGithub(plugin_info_asset_url);
if (release_response.status !== 200) {
throw new Error(`Failed to fetch release info from ${plugin_info_asset_url}`);
}
const plugin_info_json = release_response.data.assets.find(asset => asset.name === "plugin_info.json");
if (!plugin_info_json) {
throw new Error("plugin_info.json not found in release assets");
}
const plugin_info_json_response = await asyncTauriFetchGithub(plugin_info_json.browser_download_url);
if (plugin_info_json_response.status !== 200) {
throw new Error(`Failed to fetch plugin_info.json from ${plugin_info_json.browser_download_url}`);
}
const plugin_info = plugin_info_json_response.data;
const { is_plugin_supported, is_plugin_supported_latest_vrct } = checkVrctVerCompatibility(plugin_info.min_supported_vrct_version, plugin_info.max_supported_vrct_version);
return {
...plugin_info,
is_plugin_supported: is_plugin_supported,
is_plugin_supported_latest_vrct: is_plugin_supported_latest_vrct,
url: plugin_info_asset_url,
};
}
const handlePendingPlugin = (target_plugin_id, is_pending) => {
updatePluginsData((old_value) => {
const new_value = old_value.data.map((d) => {
if (d.plugin_id === target_plugin_id) {
d.is_pending = is_pending;
}
return d;
});
return new_value;
});
};
const toggleSavedPluginsStatus = (target_plugin_id) => {
const is_exists = currentSavedPluginsStatus.data.some(
(d) => d.plugin_id === target_plugin_id
);
let new_value = [];
if (is_exists) {
new_value = currentSavedPluginsStatus.data.map((d) => {
if (d.plugin_id === target_plugin_id) {
d.is_enabled = !d.is_enabled;
(d.is_enabled)
? showNotification_Success(t("plugin_notifications.is_enabled"))
: showNotification_Success(t("plugin_notifications.is_disabled"));
}
return d;
});
} else {
new_value.push(...currentSavedPluginsStatus.data);
new_value.push({
plugin_id: target_plugin_id,
is_enabled: true,
});
showNotification_Success(t("plugin_notifications.is_enabled"))
}
// 「currentPluginsData.data」でis_downloadedがtrueのものだけ残す
new_value = new_value.filter((item) =>
currentPluginsData.data.some(
(plugin) => plugin.plugin_id === item.plugin_id && plugin.is_downloaded
)
);
setSavedPluginsStatus(new_value);
};
// Init時の処理 非対応のものを無効化する際に、savedDPluginsStatusから不要なものを削除する処理が邪魔になるので該当コードを削除したバージョン。Init以外で使用する時にはリファクタが必要になる。
const setTargetSavedPluginsStatus_Init = (target_plugin_id, is_enabled) => {
const is_exists = currentSavedPluginsStatus.data.some(
(d) => d.plugin_id === target_plugin_id
);
let new_value = [];
if (is_exists) {
new_value = currentSavedPluginsStatus.data.map((d) => {
if (d.plugin_id === target_plugin_id) {
d.is_enabled = is_enabled;
}
return d;
});
} else {
new_value.push(...currentSavedPluginsStatus.data);
new_value.push({
plugin_id: target_plugin_id,
is_enabled: is_enabled,
});
}
setSavedPluginsStatus(new_value);
};
const setSavedPluginsStatus = (plugins_status) => {
pendingSavedPluginsStatus();
asyncStdoutToPython("/set/data/plugins_status", plugins_status);
};
// init時、currentPluginsDataからのデータではデータ更新が間に合わないので、currentSavedPluginsStatusから直接取得
const isAnyPluginEnabled_Init = () => {
return currentSavedPluginsStatus.data.some(plugin => plugin.is_enabled);
};
const isAnyPluginEnabled = () => {
return currentPluginsData.data.some(plugin => plugin.is_enabled);
};
const enabledPluginsList = () => {
return currentPluginsData.data.filter(plugin => plugin.is_enabled);
}
const updateTargetPluginData = (target_plugin_id, attribute, value) => {
updatePluginsData(prev => {
prev.data.forEach(plugin => {
if (plugin.plugin_id === target_plugin_id) {
plugin[attribute] = value;
}
});
return prev.data;
});
}
return {
asyncFetchPluginsInfo,
isAnyPluginEnabled_Init,
isAnyPluginEnabled,
enabledPluginsList,
asyncLoadAllPlugins,
downloadAndExtractPlugin,
currentSavedPluginsStatus,
updateSavedPluginsStatus,
currentPluginsData,
updatePluginsData,
updateTargetPluginData,
currentFetchedPluginsInfo,
updateFetchedPluginsInfo,
currentLoadedPlugins,
updateLoadedPlugins,
toggleSavedPluginsStatus,
setTargetSavedPluginsStatus_Init,
setSavedPluginsStatus,
handlePendingPlugin,
};
};
const removeImportStatements = (code) => {
return code
.split("\n")
.filter(line => !line.match(/^import\s+.*['"]react['"]/))
.join("\n");
};
// import { readTextFile, BaseDirectory } from "@tauri-apps/api/fs";
const loadPluginCSS = async (plugin_css_path) => {
if (!await exists(plugin_css_path, { dir: BaseDirectory.Resource, recursive: true })) return;
try {
// プラグインフォルダのルートにある main.css を読み込む
const css_content = await readTextFile(plugin_css_path, { dir: BaseDirectory.Resource });
// style タグを作成して head に挿入する
const style_tag = document.createElement("style");
style_tag.id = `plugin-css-${plugin_css_path.replace(/[^a-zA-Z0-9_-]/g, "")}`;
style_tag.textContent = css_content;
document.head.appendChild(style_tag);
console.log("Plugin CSS loaded for:", plugin_css_path);
} catch (error) {
console.error("Failed to load plugin CSS from", plugin_css_path, error);
}
};
export { loadPluginCSS };

View File

@@ -1,18 +0,0 @@
import { useStore_SoftwareVersion } from "@store";
import { useStdoutToPython } from "@logics/useStdoutToPython";
export const useSoftwareVersion = () => {
const { asyncStdoutToPython } = useStdoutToPython();
const { currentSoftwareVersion, updateSoftwareVersion, pendingSoftwareVersion } = useStore_SoftwareVersion();
const getSoftwareVersion = () => {
pendingSoftwareVersion();
asyncStdoutToPython("/get/data/version");
};
return {
currentSoftwareVersion,
getSoftwareVersion,
updateSoftwareVersion,
};
};

View File

@@ -8,13 +8,13 @@ import {
useNotificationStatus,
useHandleNetworkConnection,
useSoftwareVersion,
useComputeMode,
useInitProgress,
useIsBackendReady,
useWindow,
useMessage,
useVolume,
useIsSoftwareUpdateAvailable,
} from "@logics_common";
import {
@@ -26,7 +26,6 @@ import {
} from "@logics_main";
import {
useSoftwareVersion,
useEnableAutoMicSelect,
useEnableAutoSpeakerSelect,
useMicHostList,
@@ -73,6 +72,7 @@ import {
useOverlayShowOnlyTranslatedMessages,
useEnableNotificationVrcSfx,
useHotkeys,
usePlugins,
useOscIpAddress,
useOscPort,
} from "@logics_configs";
@@ -104,7 +104,7 @@ export const useReceiveRoutes = () => {
addSentMessageLog,
addReceivedMessageLog,
} = useMessage();
const { updateIsSoftwareUpdateAvailable } = useIsSoftwareUpdateAvailable();
const { updateLatestSoftwareVersionInfo } = useSoftwareVersion();
const { updateSoftwareVersion } = useSoftwareVersion();
const { updateEnableAutoMicSelect } = useEnableAutoMicSelect();
const { updateEnableAutoSpeakerSelect } = useEnableAutoSpeakerSelect();
@@ -176,6 +176,7 @@ export const useReceiveRoutes = () => {
const { updateEnableNotificationVrcSfx } = useEnableNotificationVrcSfx();
const { updateHotkeys } = useHotkeys();
const { updateSavedPluginsStatus } = usePlugins();
const { updateOscIpAddress } = useOscIpAddress();
const { updateOscPort } = useOscPort();
@@ -205,7 +206,12 @@ export const useReceiveRoutes = () => {
"/set/data/main_window_geometry": () => {},
"/run/open_filepath_logs": () => console.log("Opened Directory, Message Logs"),
"/run/open_filepath_config_file": () => console.log("Opened Directory, Config File"),
"/run/update_software_flag": updateIsSoftwareUpdateAvailable,
"/run/software_update_info": (payload) => {
updateLatestSoftwareVersionInfo(prev => ({
is_update_available: payload.is_update_available,
new_version: payload.new_version || prev.data.new_version,
}));
},
"/run/connected_network": handleNetworkConnection,
// Main Page
@@ -488,6 +494,10 @@ export const useReceiveRoutes = () => {
"/get/data/hotkeys": updateHotkeys,
"/set/data/hotkeys": updateHotkeys,
// Plugins
"/get/data/plugins_status": updateSavedPluginsStatus,
"/set/data/plugins_status": updateSavedPluginsStatus,
// Advanced Settings
"/get/data/osc_ip_address": updateOscIpAddress,
"/set/data/osc_ip_address": updateOscIpAddress,

View File

@@ -0,0 +1,25 @@
import { initStore, StoreContext } from "@plugin_store";
import { initI18n } from "@initI18n";
import { SubtitleSystemContainer } from "./subtitle_system_container/SubtitleSystemContainer";
import { SubtitlesController } from "./subtitle_system_container/_controllers/SubtitlesController.jsx";
export const init = (plugin_context) => {
const { createAtomWithHook, i18n, logics } = plugin_context;
initStore(createAtomWithHook);
initI18n(i18n);
const EntryComponents = () => {
return (
<StoreContext.Provider value={logics}>
<SubtitlesController />
<SubtitleSystemContainer />
</StoreContext.Provider>
);
};
plugin_context.registerComponent(EntryComponents);
};
export default init;

View File

@@ -0,0 +1,2 @@
main_page:
title: "VRCT Subtitles"

View File

@@ -0,0 +1,11 @@
import en from "./en.yml";
import ja from "./ja.yml";
import plugin_info from "../plugin_info.json";
export const initI18n = (i18n) => {
const ns = plugin_info.plugin_id;
// addResourceBundle will merge into i18ns store
i18n.addResourceBundle("en", ns, en, /* deep = */ true, /* overwrite = */ true);
i18n.addResourceBundle("ja", ns, ja, /* deep = */ true, /* overwrite = */ true);
};

View File

@@ -0,0 +1,2 @@
main_page:
title: "字幕プレイヤー"

View File

@@ -0,0 +1,7 @@
import { useTranslation } from "react-i18next";
import plugin_info from "../plugin_info.json";
export const usePluginTranslation = () => {
const ns = plugin_info.plugin_id;
return useTranslation(ns);
};

View File

@@ -0,0 +1,7 @@
export const configs = {
alias: {
"@plugin_store": "store/store.js",
"@initI18n": "locales/initI18n.js",
"@usePluginTranslation": "locales/usePluginTranslation.jsx",
}
}

View File

@@ -0,0 +1,20 @@
{
"title": "VRCT Subtitles",
"desc": "No description",
"plugin_id": "vrct_plugin_subtitles",
"asset_name": "vrct_plugin_subtitles.zip",
"location": "main_section",
"plugin_version": "0.0.5",
"min_supported_vrct_version": "3.0.5",
"max_supported_vrct_version": "3.0.5",
"locales": {
"en": {
"title": "VRCT Subtitles",
"desc": "No description"
},
"ja": {
"title": "VRCT 字幕表示機能",
"desc": "VRCTのオーバーレイ機能を使い、目の前に字幕としてテキストを表示する機能です。ワールドギミックの開始タイミングに合わせて字幕を設定し、同時に表示しているだけではあります。"
}
}
}

View File

@@ -0,0 +1,37 @@
const store_hooks = {};
export const initStore = (createAtomWithHook) => {
Object.assign(store_hooks, {
useStore_IsSubtitlePlaying: createAtomWithHook(false, "IsSubtitlePlaying", { is_state_ok: true }).useHook,
useStore_SubtitlePlaybackMode: createAtomWithHook("relative", "SubtitlePlaybackMode", { is_state_ok: true }).useHook,
useStore_SubtitleAbsoluteTargetTime: createAtomWithHook({
hour: "23",
minute: "00",
}, "SubtitleAbsoluteTargetTime", { is_state_ok: true }).useHook,
useStore_IsCuesScheduled: createAtomWithHook(false, "IsCuesScheduled", { is_state_ok: true }).useHook,
useStore_CountdownAdjustment: createAtomWithHook(0, "CountdownAdjustment", { is_state_ok: true }).useHook,
useStore_EffectiveCountdown: createAtomWithHook(null, "EffectiveCountdown", { is_state_ok: true }).useHook,
useStore_SubtitleCues: createAtomWithHook([], "SubtitleCues", { is_state_ok: true }).useHook,
useStore_SubtitleTimers: createAtomWithHook([], "SubtitleTimers", { is_state_ok: true }).useHook,
useStore_SubtitleCountdownTimerId: createAtomWithHook([], "SubtitleCountdownTimerId", { is_state_ok: true }).useHook,
useStore_SubtitleFileName: createAtomWithHook("ファイルが選択されていません", "SubtitleFileName", { is_state_ok: true }).useHook,
});
};
export const useStore = (hook_name) => {
if (!store_hooks[hook_name]) {
throw new Error(`Hook ${hook_name} is not initialized.`);
}
return store_hooks[hook_name]();
};
// StoreContext.js
import React, { createContext, useContext } from "react";
export const StoreContext = createContext(null);
export const useStoreContext = () => {
return useContext(StoreContext);
};

View File

@@ -0,0 +1,47 @@
import styles from "./SubtitleSystemContainer.module.scss";
import { InputFileContainer } from "./input_file_container/InputFileContainer";
import { ModeSelectorContainer } from "./mode_selector_container/ModeSelectorContainer";
import { PlayControlContainer } from "./play_control_container/PlayControlContainer";
import { CountdownContainer } from "./countdown_container/CountdownContainer";
import { SubtitlesListContainer } from "./subtitles_list_container/SubtitlesListContainer";
import { usePluginTranslation } from "@usePluginTranslation";
export const SubtitleSystemContainer = () => {
const { t } = usePluginTranslation();
// const [srtContent, setSrtContent] = useState("");
// const [cues, setCues] = useState([]);
// const [isPlaying, setIsPlaying] = useState(false);
// 再生モード ("relative": ボタン押下から、"absolute": 指定時刻から)
// const [playbackMode, setPlaybackMode] = useState("relative");
// 絶対モード用の再生開始時刻ドロップダウンで選択、HH:MM
// const [targetHour, setTargetHour] = useState("23");
// const [targetMinute, setTargetMinute] = useState("00");
// カウントダウン状態
// // initialCountdown: 再生開始ボタン押下時に算出される元の残り秒数
// const [initialCountdown, setInitialCountdown] = useState(null);
// countdownAdjustment: ユーザーが上下ボタンで調整する値(秒単位)
// const [countdownAdjustment, setCountdownAdjustment] = useState(0);
// effectiveCountdown: (initialCountdown + countdownAdjustment) から経過秒数を差し引いた表示用の値
// const [effectiveCountdown, setEffectiveCountdown] = useState(null);
// cuesScheduled: 字幕タイマーが一度スケジュールされたか
// const [cuesScheduled, setCuesScheduled] = useState(false);
// // タイマーsetTimeout/setIntervalのID管理用
// const timersRef = useRef([]);
// // カウントダウンタイマー専用の ref
// const countdownIntervalRef = useRef(null);
return (
<div className={styles.container}>
<h1 className={styles.title}>{t("main_page.title")}</h1>
<InputFileContainer />
<ModeSelectorContainer />
<PlayControlContainer />
<div className={styles.border}></div>
<CountdownContainer />
<SubtitlesListContainer />
</div>
);
};

View File

@@ -0,0 +1,124 @@
.container {
padding: 2rem 4rem;
background: var(--dark_900_color);
border-radius: 1rem;
flex-shrink: 0;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2rem;
}
.title {
font-size: 1.8rem;
text-align: center;
flex-shrink: 0;
}
.border {
width: 100%;
height: 0.2rem;
background-color: var(--dark_800_color);
flex-shrink: 0;
}
// label {
// display: block;
// font-size: 1.6rem;
// margin-bottom: 0.5rem;
// }
// input,
// select {
// font-size: 1.6rem;
// padding: 0.5rem;
// border-radius: 0.5rem;
// border: 0.1rem solid #ccc;
// background: #333;
// color: #fff;
// }
// ボタンの基本スタイル
// button {
// // font-size: 1.8rem;
// // padding: 1rem 2rem;
// // border: none;
// // border-radius: 0.5rem;
// // cursor: pointer;
// // // transition: background 0.3s;
// // margin-right: 1rem;
// &:focus {
// outline: none;
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
// }
// }
// 再生開始用ボタン(通常時)
.primary {
background: #007bff;
color: #fff;
&:hover {
background: #0056b3;
}
}
// 再生停止用ボタン
.secondary {
background: #dc3545;
color: #fff;
&:hover {
background: #a71d2a;
}
}
// 「再生中」状態(クリック不可)用のスタイル
.is_playing {
background: #6c757d;
cursor: not-allowed;
pointer-events: none;
}
// 字幕一覧のテーブル
table {
width: 100%;
border-collapse: collapse;
margin-top: 2rem;
th,
td {
padding: 1rem;
border: 0.1rem solid #444;
text-align: left;
font-size: 1.4rem;
}
th {
background: #555;
}
tbody {
tr {
cursor: pointer;
transition: background 0.2s;
&:nth-child(even) {
background: #2a2a2a;
}
&:hover {
background: #444;
}
}
}
}
.subtitle_lists {
font-size: 1.4rem;
}

View File

@@ -0,0 +1,42 @@
import { useStoreContext } from "../../store/store.js";
import { useSubtitles } from "../_logics/useSubtitles";
import { secToDayTime } from "../_subtitles_utils"
import { useEffect } from "react";
export const SubtitlesController = () => {
const { useSendTextToOverlay } = useStoreContext();
const { sendTextToOverlay } = useSendTextToOverlay();
const {
currentIsSubtitlePlaying,
currentIsCuesScheduled,
updateIsCuesScheduled,
currentCountdownAdjustment,
currentEffectiveCountdown,
scheduleCues,
} = useSubtitles();
// currentEffectiveCountdown.data が 0 になったとき、字幕開始
useEffect(() => {
if (
currentIsSubtitlePlaying.data &&
currentEffectiveCountdown.data !== null &&
currentEffectiveCountdown.data <= 0 &&
!currentIsCuesScheduled.data
) {
sendTextToOverlay("スタート!");
console.log("スタート!");
// 調整後のタイミングで字幕スケジュールを開始
scheduleCues(0);
updateIsCuesScheduled(true);
}
if (currentEffectiveCountdown.data > 0) {
console.log(secToDayTime(currentEffectiveCountdown.data));
sendTextToOverlay(secToDayTime(currentEffectiveCountdown.data));
}
}, [currentEffectiveCountdown.data, currentIsSubtitlePlaying.data, currentIsCuesScheduled.data, currentCountdownAdjustment.data]);
return null;
};

View File

@@ -0,0 +1,182 @@
import { useStore, useStoreContext } from "../../store/store.js";
export const useSubtitles = () => {
const { useSendTextToOverlay } = useStoreContext();
const { sendTextToOverlay } = useSendTextToOverlay();
const { currentSubtitleFileName, updateSubtitleFileName } = useStore("useStore_SubtitleFileName");
const { currentIsSubtitlePlaying, updateIsSubtitlePlaying } = useStore("useStore_IsSubtitlePlaying");
const { currentSubtitlePlaybackMode, updateSubtitlePlaybackMode } = useStore("useStore_SubtitlePlaybackMode");
const { currentSubtitleAbsoluteTargetTime, updateSubtitleAbsoluteTargetTime } = useStore("useStore_SubtitleAbsoluteTargetTime");
const { currentIsCuesScheduled, updateIsCuesScheduled } = useStore("useStore_IsCuesScheduled");
const { currentCountdownAdjustment, updateCountdownAdjustment } = useStore("useStore_CountdownAdjustment");
const { currentEffectiveCountdown, updateEffectiveCountdown } = useStore("useStore_EffectiveCountdown");
const { currentSubtitleCues, updateSubtitleCues } = useStore("useStore_SubtitleCues");
// タイマーsetTimeout/setIntervalのID管理用
const { currentSubtitleTimers, updateSubtitleTimers, addSubtitleTimers } = useStore("useStore_SubtitleTimers");
// const timersRef = useRef([]);
// カウントダウンタイマー専用の ref
const { currentSubtitleCountdownTimerId, updateSubtitleCountdownTimerId, AddSubtitleCountdownTimerId } = useStore("useStore_SubtitleCountdownTimerId");
// cues のスケジュールを行う(字幕開始時のオフセットは調整後のタイミングに合わせる)
const scheduleCues = (offset) => {
// 字幕開始時の処理
const startFunction = (cue) => {
let send_text = "";
if (cue.actor !== "") {
send_text = `[${cue.actor}] ${cue.text}`;
} else {
send_text = `${cue.text}`;
}
console.log(`字幕開始 (index: ${cue.index}) send_text:${send_text}`);
sendTextToOverlay(send_text);
};
// 字幕終了時の処理
const endFunction = (cue) => {
console.log(`字幕終了 (index: ${cue.index}): ${cue.text}`);
// 必要に応じた終了処理(例:テキストクリア)を実装可能
// sendTextToOverlay("");
};
currentSubtitleCues.data.forEach((cue) => {
const startDelay = cue.startTime * 1000 + offset;
const endDelay = cue.endTime * 1000 + offset;
if (startDelay >= 0) {
const timerId = setTimeout(() => startFunction(cue), startDelay);
addSubtitleTimers(timerId);
}
if (endDelay >= 0) {
const timerId = setTimeout(() => endFunction(cue), endDelay);
addSubtitleTimers(timerId);
}
});
};
// カウントダウンタイマーの開始再登録指定した値から1秒ごとに減らす
const startCountdownInterval = (startValue) => {
// 既存のタイマーがあればクリア
if (currentSubtitleCountdownTimerId.data) {
clearInterval(currentSubtitleCountdownTimerId.data);
}
// 新たな開始値を設定
updateEffectiveCountdown(startValue);
const countdown_timer_id = setInterval(() => {
updateEffectiveCountdown((prev) => {
if (prev.data <= 1) {
clearInterval(currentSubtitleCountdownTimerId.data);
return 0;
}
return prev.data - 1;
});
}, 1000);
updateSubtitleCountdownTimerId(countdown_timer_id);
addSubtitleTimers(currentSubtitleCountdownTimerId.data);
};
// 字幕一覧の表示relative モードの場合、クリックでジャンプ)
// テーブル内の字幕行をクリックrelative モードのみ)でジャンプ
const handleJump = (jumpCue) => {
if (currentSubtitlePlaybackMode.data !== "relative") return;
handleSubtitlesStop();
const offset = -jumpCue.startTime * 1000;
scheduleCues(offset);
updateIsSubtitlePlaying(true);
};
// 「再生開始」ボタン押下時の処理
const handleSubtitlesStart = () => {
handleSubtitlesStop();
updateIsSubtitlePlaying(true);
updateIsCuesScheduled(false);
const target_time = currentSubtitleAbsoluteTargetTime.data;
let computedCountdown = 0;
if (currentSubtitlePlaybackMode.data === "absolute") {
const now = new Date();
const hour = parseInt(target_time.hour, 10);
const minute = parseInt(target_time.minute, 10);
let targetDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hour,
minute,
0,
0
);
if (targetDate.getTime() < now.getTime()) {
targetDate.setDate(targetDate.getDate() + 1);
}
computedCountdown = Math.ceil((targetDate.getTime() - now.getTime()) / 1000);
} else {
computedCountdown = 10; // relative モードの場合は固定値
}
// setInitialCountdown(computedCountdown);
// 調整値を反映した開始値
const startValue = computedCountdown + currentCountdownAdjustment.data;
startCountdownInterval(startValue);
sendTextToOverlay(startValue.toString());
};
// すべてのタイマーを停止し、各状態を初期化する
const handleSubtitlesStop = () => {
currentSubtitleTimers.data.forEach((timerId) => {
clearTimeout(timerId);
clearInterval(timerId);
});
updateSubtitleTimers([]);
if (currentSubtitleCountdownTimerId.data) {
clearInterval(currentSubtitleCountdownTimerId.data);
updateSubtitleCountdownTimerId(null);
}
console.log("再生を停止しました。");
updateIsSubtitlePlaying(false);
// setInitialCountdown(null);
updateEffectiveCountdown(null);
updateCountdownAdjustment(0);
updateIsCuesScheduled(false);
};
return {
currentSubtitleFileName,
updateSubtitleFileName,
currentIsSubtitlePlaying,
updateIsSubtitlePlaying,
currentSubtitlePlaybackMode,
updateSubtitlePlaybackMode,
currentSubtitleAbsoluteTargetTime,
updateSubtitleAbsoluteTargetTime,
currentIsCuesScheduled,
updateIsCuesScheduled,
currentCountdownAdjustment,
updateCountdownAdjustment,
currentEffectiveCountdown,
updateEffectiveCountdown,
currentSubtitleCues,
updateSubtitleCues,
handleSubtitlesStart,
handleSubtitlesStop,
startCountdownInterval,
scheduleCues,
handleJump,
}
};

View File

@@ -0,0 +1,112 @@
/**
* SRT形式の文字列を解析する関数
* 改行コードを正規化し、空行で分割して解析する
* actor は存在しないため、空文字列をセット)
*/
export const parseSRT = (data) => {
const cues = [];
const normalizedData = data.replace(/\r\n/g, "\n").trim();
const blocks = normalizedData.split(/\n\s*\n/);
blocks.forEach((block) => {
const lines = block.split("\n").filter((line) => line.trim() !== "");
if (lines.length >= 3) {
const index = parseInt(lines[0], 10);
const timeMatch = lines[1].match(/([\d:,]+)\s+-->\s+([\d:,]+)/);
if (!timeMatch) return;
const start = parseTime(timeMatch[1]);
const end = parseTime(timeMatch[2]);
const text = lines.slice(2).join("\n");
cues.push({ index, startTime: start, endTime: end, actor: "", text });
}
});
return cues;
};
/**
* ASS形式の文字列を解析する関数
* [Events] セクション内の "Dialogue:" 行から、
* フォーマット "Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
* に沿って分割する。
* ここでは Name を actor、Text を text として抽出する。
*/
export const parseASS = (data) => {
const cues = [];
const lines = data.split(/\r?\n/);
let index = 1;
lines.forEach((line) => {
if (line.startsWith("Dialogue:")) {
const dialogueLine = line.substring("Dialogue:".length).trim();
const parts = dialogueLine.split(",");
// parts[0]: Layer, parts[1]: Start, parts[2]: End, parts[3]: Style, parts[4]: Name, parts[5]: MarginL, parts[6]: MarginR, parts[7]: MarginV, parts[8]: Effect, parts[9]: Text
if (parts.length < 10) return;
const startTime = parseASSTime(parts[1].trim());
const endTime = parseASSTime(parts[2].trim());
const actor = parts[4].trim();
const text = parts.slice(9).join(",").trim();
cues.push({ index: index++, startTime, endTime, actor, text });
}
});
return cues;
};
/**
* "H:MM:SS.cc" 形式の ASS 時刻文字列を秒数に変換する関数
* 例: "0:00:10.52" → 10.52 秒
*/
export const parseASSTime = (timeString) => {
const parts = timeString.split(":");
if (parts.length !== 3) return 0;
const hours = parseFloat(parts[0]);
const minutes = parseFloat(parts[1]);
const seconds = parseFloat(parts[2]);
return hours * 3600 + minutes * 60 + seconds;
};
/**
* "HH:MM:SS,mmm" 形式の SRT 時刻文字列を秒数に変換する関数
*/
export const parseTime = (timeString) => {
const [hms, ms] = timeString.split(",");
const [hours, minutes, seconds] = hms.split(":").map(Number);
return hours * 3600 + minutes * 60 + seconds + Number(ms) / 1000;
};
const padTime = (int) => {
return String(int).padStart(2, "0");
};
export const secToDayTime = (seconds) => {
const day = Math.floor(seconds / 86400);
const hour = Math.floor((seconds % 86400) / 3600);
const min = Math.floor((seconds % 3600) / 60);
const sec = seconds % 60;
let time = "";
// day が 0 の場合は「日」は出力しないhour や min も同様)
if (day !== 0) {
time = `${day}${hour}時間${min}${sec}`;
} else if (hour !== 0) {
time = `${padTime(hour)}:${padTime(min)}:${padTime(sec)}`;
} else {
time = `${padTime(min)}:${padTime(sec)}`;
}
// } else {
// time = `${padTime(sec)}`;
// }
return time;
};
// HH:MM:SS 形式に変換する補助関数
export const formatTime = (timeInSeconds) => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
return (
String(hours).padStart(2, "0") +
":" +
String(minutes).padStart(2, "0") +
":" +
String(seconds).padStart(2, "0")
);
};

View File

@@ -0,0 +1,72 @@
import React, { useState, useRef, useEffect } from "react";
import styles from "./CountdownContainer.module.scss";
import { secToDayTime } from "../_subtitles_utils";
import { useSubtitles } from "../_logics/useSubtitles";
export const CountdownContainer = () => {
const {
updateCountdownAdjustment,
currentEffectiveCountdown,
currentIsCuesScheduled,
startCountdownInterval,
} = useSubtitles();
// カウントダウン表示:字幕開始前は常に表示
// if (currentEffectiveCountdown.data === 0) return null;
if (currentEffectiveCountdown.data === null && currentIsCuesScheduled.data) return null;
return (
<div className={styles.container}>
<span>カウントダウン: {secToDayTime(currentEffectiveCountdown.data)}</span>
<div className={styles.adjust_button_container}>
{/* 1分単位の調整ボタン */}
<div className={styles.adjust_button_wrapper}>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data + 60;
updateCountdownAdjustment((prev) => prev.data + 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data - 60;
updateCountdownAdjustment((prev) => prev.data - 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
<div className={styles.adjust_button_border}></div>
{/* 1秒単位の調整ボタン */}
<div className={styles.adjust_button_wrapper}>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data + 1;
updateCountdownAdjustment((prev) => prev.data + 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data - 1;
updateCountdownAdjustment((prev) => prev.data - 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,47 @@
.container {
margin-top: 1rem;
font-size: 2.6rem;
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
span {
font-weight: bold;
}
}
.adjust_button_container {
display: flex;
gap: 10rem;
}
.adjust_button_wrapper {
display: flex;
flex-direction: column;
gap: 4rem;
}
.adjust_button {
padding: 1rem 1.4rem;
font-size: 1.8rem;
border-radius: 0.4rem;
background: var(--primary_600_color);
color: #fff;
cursor: pointer;
&:hover {
background: var(--primary_400_color);
}
&:active {
background: var(--primary_650_color);
}
}
.adjust_button_border {
background-color: var(--dark_600_color);
width: 0.2rem;
}

View File

@@ -0,0 +1,62 @@
import React, { useState, useRef, useEffect } from "react";
import styles from "./InputFileContainer.module.scss";
import { useSubtitles } from "../_logics/useSubtitles";
import { parseSRT, parseASS } from "../_subtitles_utils";
export const InputFileContainer = () => {
const {
updateSubtitleFileName,
currentSubtitleFileName,
updateSubtitleCues,
handleSubtitlesStop
} = useSubtitles();
// ファイルアップロード時の処理
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
let parsedCues = [];
// 拡張子により ASS と SRT を判定
if (file.name.toLowerCase().endsWith(".ass")) {
parsedCues = parseASS(content);
} else {
parsedCues = parseSRT(content);
}
updateSubtitleCues(parsedCues);
console.log("Parsed cues:", parsedCues);
updateSubtitleFileName(file.name);
};
reader.readAsText(file);
};
// ファイルクリア
const handleClearFile = () => {
handleSubtitlesStop();
updateSubtitleFileName("ファイルが選択されていません");
updateSubtitleCues([]);
};
return (
<div className={styles.container}>
<div className={styles.input_file_wrapper}>
<label htmlFor="subtitle_file_input" className={styles.input_file_label}>SRT/ASSファイルを選択</label>
<input
id="subtitle_file_input"
type="file"
accept=".srt,.ass"
onChange={handleFileUpload}
className={styles.input_file_i}
/>
<p className={styles.file_name}>{currentSubtitleFileName.data}</p>
</div>
<button onClick={handleClearFile} className={styles.file_clear}>
ファイルクリア
</button>
</div>
);
};

View File

@@ -0,0 +1,42 @@
.container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6rem;
}
.input_file_wrapper {
display: flex;
align-items: center;
gap: 1rem;
}
.input_file_label {
font-size: 1.4rem;
border-radius: 0.4rem;
padding: 1rem;
background-color: var(--dark_850_color);
border: 0.1rem solid var(--dark_400_color);
flex-shrink: 0;
}
.input_file_i {
display: none;
}
.file_name {
font-size: 1.6rem;
padding: 0.5rem;
width: 100%;
max-width: 60rem;
}
.file_clear {
background: var(--dark_800_color);
color: var(--dark_200_color);
border-radius: 0.4rem;
font-size: 1.2rem;
padding: 0.6rem 1rem;
cursor: pointer;
&:hover {
background: var(--error_bc_color);
}
}

View File

@@ -0,0 +1,74 @@
import styles from "./ModeSelectorContainer.module.scss";
import { useSubtitles } from "../_logics/useSubtitles";
export const ModeSelectorContainer = () => {
const {
currentSubtitlePlaybackMode,
updateSubtitlePlaybackMode,
currentSubtitleAbsoluteTargetTime,
updateSubtitleAbsoluteTargetTime,
} = useSubtitles();
const target_time = currentSubtitleAbsoluteTargetTime.data;
const handleOnchangeTargetTime = (key, value) => {
updateSubtitleAbsoluteTargetTime((old_value) => {
return {
...old_value.data,
[key]: value,
}
});
};
return (
<div className={styles.container}>
<div className={styles.mode_selector_wrapper}>
<select
value={currentSubtitlePlaybackMode.data}
onChange={(e) => updateSubtitlePlaybackMode(e.target.value)}
className={styles.mode_selector}
>
<option className={styles.mode_selector_item} value="relative">相対モードボタン押下から</option>
<option className={styles.mode_selector_item} value="absolute">絶対モード指定時刻から</option>
</select>
</div>
{currentSubtitlePlaybackMode.data === "absolute" && (
<div className={styles.time_section}>
<label className={styles.absolute_time_label}>再生開始時刻 (HH:MM):</label>
<div className={styles.time_selects}>
<select
value={target_time.hour}
onChange={(e) => handleOnchangeTargetTime("hour", e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, "0");
return (
<option key={i} value={hour}>
{hour}
</option>
);
})}
</select>
<span>:</span>
<select
value={target_time.minute}
onChange={(e) => handleOnchangeTargetTime("minute", e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 60 }, (_, i) => {
const minute = i.toString().padStart(2, "0");
return (
<option key={i} value={minute}>
{minute}
</option>
);
})}
</select>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,50 @@
.container {
// background-color: red;
display: flex;
gap: 4rem;
}
.mode_selector_wrapper {
display: flex;
align-items: center;
gap: 2rem;
}
.absolute_time_label {
font-size: 1.4rem;
}
.mode_selector {
font-size: 1.6rem;
padding: 0.6rem 1rem;
border-radius: 0.5rem;
border: 0.1rem solid var(--dark_400_color);
cursor: pointer;
}
.mode_selector_item {
background-color: var(--dark_800_color);
}
.time_section {
display: flex;
gap: 2rem;
justify-content: center;
align-items: center;
}
.time_selects {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time_selects_item {
width: 6rem;
text-align: center;
padding: 0.6rem 1rem;
font-size: 1.8rem;
background-color: var(--dark_850_color);
border: 0.1rem solid var(--dark_400_color);
cursor: pointer;
}

View File

@@ -0,0 +1,33 @@
// import React, { useState, useRef, useEffect } from "react";
import styles from "./PlayControlContainer.module.scss";
import { useSubtitles } from "../_logics/useSubtitles";
import clsx from "clsx";
export const PlayControlContainer = () => {
const {
currentIsSubtitlePlaying,
handleSubtitlesStart,
handleSubtitlesStop,
} = useSubtitles();
const is_playing = currentIsSubtitlePlaying.data;
const playback_button_classname = clsx(styles.playback_button, {
[styles.is_playing]: is_playing,
});
return (
<div className={styles.container}>
<button
onClick={handleSubtitlesStart}
className={playback_button_classname}
>
{is_playing ? "再生中" : "字幕を登録・再生"}
</button>
{is_playing &&
<button onClick={handleSubtitlesStop} className={styles.playback_stop_button}>
停止
</button>
}
</div>
);
};

View File

@@ -0,0 +1,31 @@
.container {
display: flex;
gap: 4rem;
display: flex;
justify-content: center;
}
.playback_button, .playback_stop_button {
font-size: 1.6rem;
padding: 1rem 2rem;
cursor: pointer;
border-radius: 0.4rem;
}
.playback_button {
background-color: var(--primary_550_color);
&:hover {
background-color: var(--primary_400_color);
}
&.is_playing {
background-color: var(--primary_650_color);
pointer-events: none;
color: var(--dark_400_color);
}
}
.playback_stop_button {
background-color: var(--dark_800_color);
&:hover {
background-color: var(--error_bc_color);
}
}

View File

@@ -0,0 +1,45 @@
import React, { useState, useRef, useEffect } from "react";
import styles from "./SubtitlesListContainer.module.scss";
import { useSubtitles } from "../_logics/useSubtitles";
import { formatTime } from "../_subtitles_utils";
export const SubtitlesListContainer = () => {
const { currentSubtitleCues, handleJump } = useSubtitles();
if (currentSubtitleCues.data.length < 0 ) return null;
return (
<div className={styles.subtitleSection}>
<h2>字幕一覧</h2>
<table className={styles.table}>
<thead>
<tr>
<th>番号</th>
<th>開始</th>
<th>終了</th>
<th>Actor</th>
<th>テキスト</th>
</tr>
</thead>
<tbody className={styles.subtitle_lists}>
{currentSubtitleCues.data.map((cue) => (
<tr
key={cue.index}
onClick={() => handleJump(cue)}
className={styles.tableRow}
>
<td>{cue.index}</td>
<td>{formatTime(cue.startTime)}</td>
<td>{formatTime(cue.endTime)}</td>
<td>{cue.actor}</td>
<td>{cue.text}</td>
</tr>
))}
</tbody>
</table>
<p className={styles.note}>
行をクリックするとその字幕の位置にジャンプします相対モードのみ
</p>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export const dev_plugins = [
{ entry_path: "dev_plugin_subtitles" }
];

View File

@@ -20,6 +20,9 @@ export const store = {
log_box_ref: null,
text_area_ref: null,
is_applied_init_message_box_height: false,
is_initialized_load_plugin: false,
is_fetched_plugins_info_already: false,
is_initialized_fetched_plugin_info: false,
};
const generatePropertyNames = (base_name) => ({
@@ -34,7 +37,7 @@ const generatePropertyNames = (base_name) => ({
});
const createAtomWithHook = (initialValue, base_name, options) => {
export const createAtomWithHook = (initialValue, base_name, options) => {
const property_names = generatePropertyNames(base_name);
const atomInstance = atom({
state: (options?.is_state_ok) ? "ok" : "pending",
@@ -114,7 +117,10 @@ export const { atomInstance: Atom_MainFunctionsStateMemory, useHook: useStore_Ma
transcription_receive: false,
}, "MainFunctionsStateMemory");
export const { atomInstance: Atom_OpenedQuickSetting, useHook: useStore_OpenedQuickSetting } = createAtomWithHook("", "OpenedQuickSetting");
export const { atomInstance: Atom_IsSoftwareUpdateAvailable, useHook: useStore_IsSoftwareUpdateAvailable } = createAtomWithHook(false, "IsSoftwareUpdateAvailable");
export const { atomInstance: Atom_LatestSoftwareVersionInfo, useHook: useStore_LatestSoftwareVersionInfo } = createAtomWithHook({
is_update_available: false,
new_version: "0.0.0",
}, "LatestSoftwareVersionInfo");
export const { atomInstance: Atom_InitProgress, useHook: useStore_InitProgress } = createAtomWithHook(0, "InitProgress");
export const { atomInstance: Atom_IsBreakPoint, useHook: useStore_IsBreakPoint } = createAtomWithHook(false, "IsBreakPoint");
export const { atomInstance: Atom_IsSoftwareUpdating, useHook: useStore_IsSoftwareUpdating } = createAtomWithHook(false, "IsSoftwareUpdating");
@@ -273,6 +279,12 @@ export const { atomInstance: Atom_Hotkeys, useHook: useStore_Hotkeys } = createA
toggle_transcription_receive: null,
}, "Hotkeys");
// Plugins
export const { atomInstance: Atom_FetchedPluginsInfo, useHook: useStore_FetchedPluginsInfo } = createAtomWithHook([], "FetchedPluginsInfo");
export const { atomInstance: Atom_LoadedPlugins, useHook: useStore_LoadedPlugins } = createAtomWithHook([], "LoadedPlugins");
export const { atomInstance: Atom_SavedPluginsStatus, useHook: useStore_SavedPluginsStatus } = createAtomWithHook([], "SavedPluginsStatus");
export const { atomInstance: Atom_PluginsData, useHook: useStore_PluginsData } = createAtomWithHook([], "PluginsData");
// Advanced Settings
export const { atomInstance: Atom_OscIpAddress, useHook: useStore_OscIpAddress } = createAtomWithHook("127.0.0.1", "OscIpAddress");
export const { atomInstance: Atom_OscPort, useHook: useStore_OscPort } = createAtomWithHook("9000", "OscPort");

View File

@@ -52,6 +52,21 @@ export const ui_configs = {
]
};
// true: src-ui\plugins false: src-tauri\target\debug\plugins
export const IS_PLUGIN_PATH_DEV_MODE = false;
// true: dev_vrct_plugins_list.json false: vrct_plugins_list.json
export const IS_PLUGIN_LIST_URL_DEV_MODE = false;
export const getPluginsList = () => {
const base_url = "https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/";
const plugins_list_url = (IS_PLUGIN_LIST_URL_DEV_MODE)
? base_url + "dev_vrct_plugins_list.json"
: base_url + "vrct_plugins_list.json";
return plugins_list_url;
};
if (IS_PLUGIN_PATH_DEV_MODE || IS_PLUGIN_LIST_URL_DEV_MODE) console.warn("ui_configs IS_PLUGIN_PATH_DEV_MODE or IS_PLUGIN_LIST_URL_DEV_MODE is true. Turn to 'false' when it's production environment.");
export const translator_status = [
{ id: "DeepL", label: "DeepL", is_available: false },
{ id: "DeepL_API", label: `DeepL API`, is_available: false },

View File

@@ -1,62 +1,103 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import yaml from "@rollup/plugin-yaml";
import path from "path";
import { dev_plugins } from "./src-ui/plugins/plugins_index.js";
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react(), svgr()],
assetsInclude: ["**/*.yml"],
export default defineConfig(async () => {
const plugin_aliases = await getPluginAliases();
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
return {
plugins: [
yaml({ include: ["**/*.yml", "**/*.yaml"] }),
react(),
svgr(),
],
build: {
outDir: path.resolve(__dirname, "dist"),
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
},
resolve: {
alias: {
"@root": path.resolve(__dirname),
"@test_data": path.resolve(__dirname, "./test_data.js"),
"@ui_configs": path.resolve(__dirname, "src-ui/ui_configs.js"),
"@scss_mixins": path.resolve(__dirname, "src-ui/common_css/mixins.scss"),
"@store": path.resolve(__dirname, "src-ui/store.js"),
"@images": path.resolve(__dirname, "src-ui/assets"),
"@utils": path.resolve(__dirname, "src-ui/utils.js"),
"@logics": path.resolve(__dirname, "src-ui/logics"),
"@logics_common": path.resolve(__dirname, "src-ui/logics/common"),
"@logics_main": path.resolve(__dirname, "src-ui/logics/main"),
"@logics_configs": path.resolve(__dirname, "src-ui/logics/configs"),
"@setting_box": path.resolve(__dirname, "src-ui/app/config_page/setting_section/setting_box/index.js"),
"@common_components": path.resolve(__dirname, "src-ui/common_components/index.js"),
build: {
outDir: path.resolve(__dirname, "dist"),
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
},
},
sourcemap: true,
},
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler"
resolve: {
alias: {
"@root": path.resolve(__dirname),
"@test_data": path.resolve(__dirname, "./test_data.js"),
"@ui_configs": path.resolve(__dirname, "src-ui/ui_configs.js"),
"@scss_mixins": path.resolve(__dirname, "src-ui/common_css/mixins.scss"),
"@store": path.resolve(__dirname, "src-ui/store.js"),
"@images": path.resolve(__dirname, "src-ui/assets"),
"@utils": path.resolve(__dirname, "src-ui/utils.js"),
"@logics": path.resolve(__dirname, "src-ui/logics"),
"@logics_common": path.resolve(__dirname, "src-ui/logics/common"),
"@logics_main": path.resolve(__dirname, "src-ui/logics/main"),
"@logics_configs": path.resolve(__dirname, "src-ui/logics/configs"),
"@setting_box": path.resolve(__dirname, "src-ui/app/config_page/setting_section/setting_box/index.js"),
"@common_components": path.resolve(__dirname, "src-ui/common_components/index.js"),
// Plugins
"@plugins_path": path.resolve(__dirname, "src-ui/plugins"),
"@plugins_index": path.resolve(__dirname, "src-ui/plugins/plugins_index.js"),
...plugin_aliases,
},
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler"
}
}
}
}
};
});
}));
// 各プラグインのエイリアスを動的に読み込む関数
const getPluginAliases = async () => {
const aliases = {};
// dev_plugins 配列の各プラグインについて処理する
for (const plugin of dev_plugins) {
const entry_path = plugin.entry_path; // 例: "dev_plugin_subtitles"
try {
// エイリアス設定ファイルは各プラグインフォルダ内の "configs.js" に記述されている前提
const pluginConfig = await import(`./src-ui/plugins/${entry_path}/plugin_configs.js`);
if (pluginConfig.configs && pluginConfig.configs.alias) {
for (const [alias_key, alias_relative_path] of Object.entries(pluginConfig.configs.alias)) {
// ホスト側の絶対パスに変換
aliases[alias_key] = path.resolve(__dirname, "src-ui/plugins", entry_path, alias_relative_path);
}
}
} catch (error) {
console.error(`Error loading alias config for plugin ${plugin.plugin_info.plugin_id}:`, error);
}
}
return aliases;
};