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 +}