diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
new file mode 100644
index 0000000..2d6120d
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os/exec"
+ "runtime"
+ "time"
+
+ "vrc_osc_go/internal/app"
+)
+
+func main() {
+ go func() {
+ if err := app.Run("config/config.toml", ""); err != nil {
+ log.Printf("backend stopped: %v", err)
+ }
+ }()
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", handleIndex)
+ mux.HandleFunc("/api/state", handleState)
+
+ addr := "127.0.0.1:18181"
+ go openBrowser("http://" + addr)
+ log.Printf("gui listening on http://%s", addr)
+ log.Fatal(http.ListenAndServe(addr, mux))
+}
+
+func handleIndex(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ html := `
+
+
+
+
+ VRC_OSC GUI
+
+
+
+ VRC_OSC GUI
+ loading...
+
+
+`
+ _, _ = fmt.Fprint(w, html)
+}
+
+func handleState(w http.ResponseWriter, r *http.Request) {
+ s := app.SnapshotRuntime()
+ guests := []app.GuestStatus{}
+ if t := app.GetGuestTracker(); t != nil {
+ guests = t.Snapshot(time.Now())
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "world": s.CurrentWorld,
+ "discord_muted": s.DiscordMuted,
+ "ocr": s.LastOCRText,
+ "translate": s.LastTranslate,
+ "guests": guests,
+ "guest_count": len(guests),
+ })
+}
+
+func openBrowser(url string) {
+ switch runtime.GOOS {
+ case "windows":
+ _ = exec.Command("cmd", "/c", "start", "", url).Start()
+ default:
+ _ = exec.Command("xdg-open", url).Start()
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/app.go b/VRWT_Tool/VRC_OSC/internal/app/app.go
index b80b1a7..605ece2 100644
--- a/VRWT_Tool/VRC_OSC/internal/app/app.go
+++ b/VRWT_Tool/VRC_OSC/internal/app/app.go
@@ -29,16 +29,19 @@ func Run(configPath string, mode string) error {
if err != nil {
return err
}
+ SetGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
discordState := &discordMuteState{muted: true}
+ SetDiscordMuted(true)
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
log.Printf("received DiscordSend args=%+v", args)
if err := handleDiscordSend(discordState, args); err != nil {
log.Printf("discord mute failed: %v", err)
return err
}
+ SetDiscordMuted(discordState.muted)
return nil
})
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
index 3577e3c..bd085bd 100644
--- a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
+++ b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
@@ -36,6 +36,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err
}
state.muted = false
+ SetDiscordMuted(state.muted)
state.lastAction = now
} else {
log.Printf("INFO already unmuted")
@@ -51,6 +52,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err
}
state.muted = true
+ SetDiscordMuted(state.muted)
state.lastAction = now
} else {
log.Printf("INFO already muted")
diff --git a/VRWT_Tool/VRC_OSC/internal/app/guest_status.go b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go
new file mode 100644
index 0000000..8646a3e
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go
@@ -0,0 +1,109 @@
+package app
+
+import (
+ "fmt"
+ "sort"
+ "sync"
+ "time"
+)
+
+type GuestStatus struct {
+ Name string `json:"name"`
+ Present bool `json:"present"`
+ LastJoin time.Time `json:"last_join"`
+ LastLeave time.Time `json:"last_leave"`
+ AbsentFor string `json:"absent_for"`
+}
+
+type GuestTracker struct {
+ mu sync.RWMutex
+ current string
+ items map[string]*GuestStatus
+}
+
+func NewGuestTracker(names []string) *GuestTracker {
+ items := make(map[string]*GuestStatus, len(names))
+ for _, name := range names {
+ items[name] = &GuestStatus{Name: name}
+ }
+ return &GuestTracker{items: items}
+}
+
+func (t *GuestTracker) SetCurrentInstance(label string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.current = label
+}
+
+func (t *GuestTracker) CurrentInstance() string {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.current
+}
+
+func (t *GuestTracker) MarkJoin(name string, at time.Time) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.ensure(name)
+ s.Present = true
+ s.LastJoin = at
+ s.AbsentFor = ""
+}
+
+func (t *GuestTracker) MarkLeave(name string, at time.Time) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.ensure(name)
+ s.Present = false
+ s.LastLeave = at
+ if !s.LastJoin.IsZero() {
+ s.AbsentFor = humanSince(at)
+ }
+}
+
+func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ out := make([]GuestStatus, 0, len(t.items))
+ for _, s := range t.items {
+ cp := *s
+ if !cp.Present && !cp.LastLeave.IsZero() {
+ cp.AbsentFor = humanSince(cp.LastLeave, now)
+ }
+ out = append(out, cp)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+func (t *GuestTracker) ensure(name string) *GuestStatus {
+ if s, ok := t.items[name]; ok {
+ return s
+ }
+ s := &GuestStatus{Name: name}
+ t.items[name] = s
+ return s
+}
+
+func humanSince(at time.Time, now ...time.Time) string {
+ ref := time.Now()
+ if len(now) > 0 {
+ ref = now[0]
+ }
+ if at.IsZero() {
+ return ""
+ }
+ d := ref.Sub(at)
+ if d < time.Minute {
+ return "1分未満"
+ }
+ m := int(d.Minutes())
+ if m < 60 {
+ return fmt.Sprintf("%d分前", m)
+ }
+ h := m / 60
+ if h < 24 {
+ return fmt.Sprintf("%d時間前", h)
+ }
+ return fmt.Sprintf("%d日前", h/24)
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/ocr.go b/VRWT_Tool/VRC_OSC/internal/app/ocr.go
index f5c8a83..6f6e2ca 100644
--- a/VRWT_Tool/VRC_OSC/internal/app/ocr.go
+++ b/VRWT_Tool/VRC_OSC/internal/app/ocr.go
@@ -20,6 +20,8 @@ runOcrFromScreen()
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("ocr output: %s", string(out))
+ SetOCRText(string(out))
+ SetTranslateText("翻訳: 未実装")
}
if err != nil {
return fmt.Errorf("ocr failed: %w", err)
diff --git a/VRWT_Tool/VRC_OSC/internal/app/state.go b/VRWT_Tool/VRC_OSC/internal/app/state.go
new file mode 100644
index 0000000..a913670
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/state.go
@@ -0,0 +1,47 @@
+package app
+
+import "sync"
+
+type RuntimeState struct {
+ mu sync.RWMutex
+ DiscordMuted bool
+ LastOCRText string
+ LastTranslate string
+ CurrentWorld string
+}
+
+var runtimeState = &RuntimeState{}
+var guestTracker *GuestTracker
+
+func SetGuestTracker(t *GuestTracker) { guestTracker = t }
+func GetGuestTracker() *GuestTracker { return guestTracker }
+
+func SetDiscordMuted(v bool) {
+ runtimeState.mu.Lock()
+ defer runtimeState.mu.Unlock()
+ runtimeState.DiscordMuted = v
+}
+
+func SetOCRText(v string) {
+ runtimeState.mu.Lock()
+ defer runtimeState.mu.Unlock()
+ runtimeState.LastOCRText = v
+}
+
+func SetTranslateText(v string) {
+ runtimeState.mu.Lock()
+ defer runtimeState.mu.Unlock()
+ runtimeState.LastTranslate = v
+}
+
+func SetCurrentWorld(v string) {
+ runtimeState.mu.Lock()
+ defer runtimeState.mu.Unlock()
+ runtimeState.CurrentWorld = v
+}
+
+func SnapshotRuntime() RuntimeState {
+ runtimeState.mu.RLock()
+ defer runtimeState.mu.RUnlock()
+ return *runtimeState
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
index fc45e1e..9a1fdf8 100644
--- a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
+++ b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
@@ -128,11 +128,19 @@ func watchVrchatLog() {
if changed, worldLabel := updateWorldState(state, line); changed {
log.Printf("world changed: %s", worldLabel)
state.presentSet = map[string]struct{}{}
+ SetCurrentWorld(worldLabel)
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.SetCurrentInstance(worldLabel)
+ }
_ = appendRuntimeLog("VRC WORLD", worldLabel)
}
+ at := extractLineTime(line)
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{}
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.MarkJoin(name, at)
+ }
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out)
_ = appendJoinLeaveLog(out)
@@ -141,6 +149,9 @@ func watchVrchatLog() {
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
delete(state.presentSet, name)
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.MarkLeave(name, at)
+ }
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out)
_ = appendJoinLeaveLog(out)
@@ -178,11 +189,17 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{}
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.MarkJoin(name, extractLineTime(line))
+ }
continue
}
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
delete(state.presentSet, name)
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.MarkLeave(name, extractLineTime(line))
+ }
continue
}
}
@@ -192,6 +209,10 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.initialized = true
if state.worldName != "" {
log.Printf("world changed: %s", state.worldName)
+ SetCurrentWorld(state.worldName)
+ if tracker := GetGuestTracker(); tracker != nil {
+ tracker.SetCurrentInstance(state.worldName)
+ }
_ = appendRuntimeLog("VRC WORLD", state.worldName)
}
return nil
@@ -311,3 +332,14 @@ func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
}
return true
}
+
+func extractLineTime(line string) time.Time {
+ if len(line) < 19 {
+ return time.Now()
+ }
+ t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
+ if err == nil {
+ return t
+ }
+ return time.Now()
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/config/config.go b/VRWT_Tool/VRC_OSC/internal/config/config.go
index a5037c5..e584686 100644
--- a/VRWT_Tool/VRC_OSC/internal/config/config.go
+++ b/VRWT_Tool/VRC_OSC/internal/config/config.go
@@ -23,6 +23,7 @@ type OSCConfig struct {
type VrcLogConfig struct {
SelfName string
GuestNames []string
+ GuestFile string
StaffNames []string
MissingCount int
LogPatterns []string
@@ -91,11 +92,22 @@ func Load(path string) (*Config, error) {
cfg.VrcLog.StaffNames = parseList(val)
}
case "guest":
- if key == "names" {
+ switch key {
+ case "names":
cfg.VrcLog.GuestNames = parseList(val)
+ case "file":
+ cfg.VrcLog.GuestFile = val
}
}
}
+ if cfg.VrcLog.GuestFile != "" {
+ if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
+ cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
+ }
+ if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
+ cfg.VrcLog.GuestNames = names
+ }
+ }
return cfg, scanner.Err()
}
@@ -116,3 +128,19 @@ func parseList(value string) []string {
}
return out
}
+
+func loadLines(path string) ([]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ lines := strings.Split(string(data), "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
+ if line != "" && !strings.HasPrefix(line, "#") {
+ out = append(out, line)
+ }
+ }
+ return out, nil
+}