Improve GUI guest sorting and presence display

This commit is contained in:
every_holiday
2026-06-24 02:38:06 +09:00
parent b6421958af
commit ab9551c241
8 changed files with 398 additions and 1 deletions

View File

@@ -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 := `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="3">
<title>VRC_OSC GUI</title>
<style>
body{font-family:sans-serif;background:#111;color:#eee;margin:20px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.card{background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}
table{width:100%;border-collapse:collapse}
td,th{border-bottom:1px solid #333;padding:6px;text-align:left}
th{cursor:pointer;user-select:none}
th:hover{color:#8ff}
.on{color:#5ff28f}.off{color:#ff8a8a}
</style>
</head>
<body>
<h1>VRC_OSC GUI</h1>
<div id="root">loading...</div>
<script>
const sortKey = 'vrc_osc_gui_sort';
const defaultSort = { key: 'name', dir: 1 };
let sortState = (() => {
try {
const raw = localStorage.getItem(sortKey);
return raw ? JSON.parse(raw) : defaultSort;
} catch (_) {
return defaultSort;
}
})();
function setSort(key) {
if (sortState.key === key) {
sortState.dir = sortState.dir * -1;
} else {
sortState = { key: key, dir: 1 };
}
localStorage.setItem(sortKey, JSON.stringify(sortState));
load();
}
function compareGuests(a, b) {
if (sortState.key === 'name') {
const v = a.name.localeCompare(b.name, 'ja');
return v * sortState.dir;
}
if (sortState.key === 'presence') {
const av = Number(a.present);
const bv = Number(b.present);
if (av !== bv) {
return (bv - av) * sortState.dir;
}
return a.name.localeCompare(b.name, 'ja');
}
if (sortState.key === 'status') {
const av = Number(!a.present);
const bv = Number(!b.present);
if (av !== bv) {
return (av - bv) * sortState.dir;
}
return a.name.localeCompare(b.name, 'ja');
}
return a.name.localeCompare(b.name, 'ja');
}
async function load(){
const r = await fetch('/api/state');
const j = await r.json();
const guests = [...j.guests].sort(compareGuests);
const presentCount = guests.filter(g => g.present).length;
const rows = guests.map(g => {
const status = g.present ? '在席' : (g.absent_for || '1分未満');
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(status) + '</td></tr>';
}).join('');
document.getElementById('root').innerHTML =
'<div class="grid">' +
'<div class="card">' +
'<h2>状態</h2>' +
'<p>World: ' + esc(j.world || '') + '</p>' +
'<p>Discord: <span class="' + (j.discord_muted ? 'on' : 'off') + '">' + (j.discord_muted ? 'Muted' : 'Unmuted') + '</span></p>' +
'<p>OCR: ' + esc(j.ocr || '') + '</p>' +
'<p>翻訳: ' + esc(j.translate || '未実装') + '</p>' +
'</div>' +
'<div class="card">' +
'<h2>ゲスト (' + presentCount + '/' + j.guest_count + ')</h2>' +
'<table>' +
'<tr>' +
'<th data-sort="name">名前' + sortMark('name') + '</th>' +
'<th data-sort="presence">在席' + sortMark('presence') + '</th>' +
'</tr>' +
rows +
'</table>' +
'</div>' +
'</div>';
document.querySelectorAll('th[data-sort]').forEach(function(el) {
el.style.cursor = 'pointer';
el.onclick = function() {
setSort(el.getAttribute('data-sort'));
};
});
}
function sortMark(key) {
if (sortState.key !== key) return '';
return sortState.dir > 0 ? ' ▲' : ' ▼';
}
function esc(s){ return (s || '').replace(/[&<>"]/g, function(m){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]; }); }
setInterval(load, 3000);
load();
</script>
</body>
</html>`
_, _ = 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()
}
}

View File

@@ -29,16 +29,19 @@ func Run(configPath string, mode string) error {
if err != nil { if err != nil {
return err 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) 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) server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
discordState := &discordMuteState{muted: true} discordState := &discordMuteState{muted: true}
SetDiscordMuted(true)
server.Map("DiscordSend", func(_ string, args []osc.Value) error { server.Map("DiscordSend", func(_ string, args []osc.Value) error {
log.Printf("received DiscordSend args=%+v", args) log.Printf("received DiscordSend args=%+v", args)
if err := handleDiscordSend(discordState, args); err != nil { if err := handleDiscordSend(discordState, args); err != nil {
log.Printf("discord mute failed: %v", err) log.Printf("discord mute failed: %v", err)
return err return err
} }
SetDiscordMuted(discordState.muted)
return nil return nil
}) })
server.Map("ocrEnabled", func(_ string, args []osc.Value) error { server.Map("ocrEnabled", func(_ string, args []osc.Value) error {

View File

@@ -36,6 +36,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err return err
} }
state.muted = false state.muted = false
SetDiscordMuted(state.muted)
state.lastAction = now state.lastAction = now
} else { } else {
log.Printf("INFO already unmuted") log.Printf("INFO already unmuted")
@@ -51,6 +52,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err return err
} }
state.muted = true state.muted = true
SetDiscordMuted(state.muted)
state.lastAction = now state.lastAction = now
} else { } else {
log.Printf("INFO already muted") log.Printf("INFO already muted")

View File

@@ -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)
}

View File

@@ -20,6 +20,8 @@ runOcrFromScreen()
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if len(out) > 0 { if len(out) > 0 {
log.Printf("ocr output: %s", string(out)) log.Printf("ocr output: %s", string(out))
SetOCRText(string(out))
SetTranslateText("翻訳: 未実装")
} }
if err != nil { if err != nil {
return fmt.Errorf("ocr failed: %w", err) return fmt.Errorf("ocr failed: %w", err)

View File

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

View File

@@ -128,11 +128,19 @@ func watchVrchatLog() {
if changed, worldLabel := updateWorldState(state, line); changed { if changed, worldLabel := updateWorldState(state, line); changed {
log.Printf("world changed: %s", worldLabel) log.Printf("world changed: %s", worldLabel)
state.presentSet = map[string]struct{}{} state.presentSet = map[string]struct{}{}
SetCurrentWorld(worldLabel)
if tracker := GetGuestTracker(); tracker != nil {
tracker.SetCurrentInstance(worldLabel)
}
_ = appendRuntimeLog("VRC WORLD", worldLabel) _ = appendRuntimeLog("VRC WORLD", worldLabel)
} }
at := extractLineTime(line)
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{} state.presentSet[name] = struct{}{}
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, at)
}
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
_ = appendJoinLeaveLog(out) _ = appendJoinLeaveLog(out)
@@ -141,6 +149,9 @@ func watchVrchatLog() {
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
delete(state.presentSet, name) delete(state.presentSet, name)
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, at)
}
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
_ = appendJoinLeaveLog(out) _ = appendJoinLeaveLog(out)
@@ -178,11 +189,17 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{} state.presentSet[name] = struct{}{}
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, extractLineTime(line))
}
continue continue
} }
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
delete(state.presentSet, name) delete(state.presentSet, name)
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, extractLineTime(line))
}
continue continue
} }
} }
@@ -192,6 +209,10 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.initialized = true state.initialized = true
if state.worldName != "" { if state.worldName != "" {
log.Printf("world changed: %s", 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) _ = appendRuntimeLog("VRC WORLD", state.worldName)
} }
return nil return nil
@@ -311,3 +332,14 @@ func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
} }
return true 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()
}

View File

@@ -23,6 +23,7 @@ type OSCConfig struct {
type VrcLogConfig struct { type VrcLogConfig struct {
SelfName string SelfName string
GuestNames []string GuestNames []string
GuestFile string
StaffNames []string StaffNames []string
MissingCount int MissingCount int
LogPatterns []string LogPatterns []string
@@ -91,11 +92,22 @@ func Load(path string) (*Config, error) {
cfg.VrcLog.StaffNames = parseList(val) cfg.VrcLog.StaffNames = parseList(val)
} }
case "guest": case "guest":
if key == "names" { switch key {
case "names":
cfg.VrcLog.GuestNames = parseList(val) 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() return cfg, scanner.Err()
} }
@@ -116,3 +128,19 @@ func parseList(value string) []string {
} }
return out 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
}