Improve GUI guest sorting and presence display
This commit is contained in:
174
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal file
174
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal 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 {'&':'&','<':'<','>':'>','"':'"'}[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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user