diff --git a/main.go b/main.go index 6d27c99..bfd3e0e 100644 --- a/main.go +++ b/main.go @@ -20,16 +20,22 @@ import ( ) type activeRequest struct { - RoomId string `json:"room_id"` - AdminSecret string `json:"admin_secret"` - PublicClientId string `json:"public_client_id"` - Active bool `json:"active"` + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + ClientId string `json:"client_id"` + Active bool `json:"active"` + Solo bool `json:"solo"` } type adminRequest struct { - RoomId string `json:"room_id"` - AdminSecret string `json:"admin_secret"` - PublicClientId string `json:"public_client_id"` + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + ClientId string `json:"client_id"` +} + +type resetRequest struct { + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` } type announceRequest struct { @@ -56,12 +62,12 @@ type removeRequest struct { } type client struct { - PublicClientId string `json:"public_client_id"` - Name string `json:"name"` - Admin bool `json:"admin"` - Active bool `json:"active"` + ClientId string `json:"client_id"` + Name string `json:"name"` + Admin bool `json:"admin"` + Active bool `json:"active"` + ActiveStart int64 `json:"active_start"` - clientId string room *room lastSeen time.Time eventChan chan *event @@ -79,6 +85,8 @@ type adminEvent struct { type standardEvent struct { Active bool `json:"active"` + ActiveStart int64 `json:"active_start"` + TimerStart int64 `json:"timer_start"` AdminSecret string `json:"admin_secret"` } @@ -87,25 +95,14 @@ type controlEvent struct { } type room struct { - roomId string - clientById map[string]*client - clientByPublicId map[string]*client - present map[*presentState]bool -} - -type watchState struct { - responseWriter http.ResponseWriter - request *http.Request - flusher http.Flusher - room *room - client *client - admin bool - eventChan chan *event + roomId string + timerStart time.Time + clientById map[string]*client + present map[*presentState]bool } type presentState struct { responseWriter http.ResponseWriter - request *http.Request flusher http.Flusher room *room controlChan chan *controlEvent @@ -119,6 +116,7 @@ func main() { rand.Seed(time.Now().UnixNano()) keyFlag := flag.String("key", "", "secret key") + bindFlag := flag.String("bind", ":2000", "host:port to listen on") flag.Parse() @@ -140,10 +138,11 @@ func main() { http.HandleFunc("/api/create", create) http.HandleFunc("/api/present", present) http.HandleFunc("/api/remove", remove) + http.HandleFunc("/api/reset", reset) http.HandleFunc("/api/watch", watch) server := http.Server{ - Addr: ":2000", + Addr: *bindFlag, } err := server.ListenAndServe() if err != nil { @@ -178,11 +177,11 @@ func scan() { mu.Lock() defer mu.Unlock() - cutoff := time.Now().UTC().Add(-30 * time.Second) + grace := 10 * time.Second for _, rm := range roomById { for _, c := range rm.clientById { - if c.lastSeen.Before(cutoff) { + if time.Now().Sub(c.lastSeen) > grace { c.remove() } } @@ -208,17 +207,38 @@ func active(w http.ResponseWriter, r *http.Request) { return } - c := rm.clientByPublicId[req.PublicClientId] + c := rm.clientById[req.ClientId] if c == nil { - http.Error(w, "invalid public_client_id", http.StatusBadRequest) + http.Error(w, "invalid client_id", http.StatusBadRequest) return } - c.Active = req.Active + c.Active = req.Active || req.Solo + if c.Active { + c.ActiveStart = time.Now().Unix() + } else { + c.ActiveStart = 0 + } c.update() rm.sendAdminEvent(&adminEvent{ Client: c, }) + + if req.Solo { + for _, iter := range rm.clientById { + if iter == c { + continue + } + if iter.Active { + iter.Active = false + iter.ActiveStart = 0 + iter.update() + rm.sendAdminEvent(&adminEvent{ + Client: iter, + }) + } + } + } } func admin(w http.ResponseWriter, r *http.Request) { @@ -240,9 +260,9 @@ func admin(w http.ResponseWriter, r *http.Request) { return } - c := rm.clientByPublicId[req.PublicClientId] + c := rm.clientById[req.ClientId] if c == nil { - http.Error(w, "invalid public_client_id", http.StatusBadRequest) + http.Error(w, "invalid client_id", http.StatusBadRequest) return } @@ -314,6 +334,7 @@ func control(w http.ResponseWriter, r *http.Request) { if !c.Active { http.Error(w, "client is not active", http.StatusBadRequest) + return } rm.sendControlEvent(&controlEvent{ @@ -348,7 +369,7 @@ func present(w http.ResponseWriter, r *http.Request) { } closeChan := w.(http.CloseNotifier).CloseNotify() - ticker := time.NewTicker(15 * time.Second) + ticker := time.NewTicker(5 * time.Second) for { select { @@ -386,32 +407,66 @@ func remove(w http.ResponseWriter, r *http.Request) { c.remove() } +func reset(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &resetRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + + if req.AdminSecret != rm.adminSecret() { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return + } + + rm.timerStart = time.Now() + rm.updateAllClients() +} + func watch(w http.ResponseWriter, r *http.Request) { - ws := newWatchState(w, r) - if ws == nil { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusBadRequest) + return + } + + client, eventChan := registerWatch(w, r) + if client == nil { return } closeChan := w.(http.CloseNotifier).CloseNotify() - ticker := time.NewTicker(15 * time.Second) + ticker := time.NewTicker(5 * time.Second) - ws.sendInitial() + writeInitial(client, w, flusher) for { select { case <-closeChan: - ws.close() - return + close(eventChan) + + mu.Lock() + if client.eventChan == eventChan { + client.eventChan = nil + } + mu.Unlock() case <-ticker.C: - mu.Lock() - ws.sendHeartbeat() - mu.Unlock() + writeHeartbeat(w, flusher) - case event := <-ws.eventChan: - mu.Lock() - ws.sendEvent(event) - mu.Unlock() + case event, ok := <-eventChan: + if ok { + writeEvent(event, w, flusher) + } else { + return + } } } } @@ -423,8 +478,11 @@ func (c *client) sendEvent(e *event) { } func (c *client) remove() { - delete(c.room.clientById, c.clientId) - delete(c.room.clientByPublicId, c.PublicClientId) + if c.eventChan != nil { + close(c.eventChan) + } + + delete(c.room.clientById, c.ClientId) c.room.sendAdminEvent(&adminEvent{ Client: c, @@ -435,7 +493,9 @@ func (c *client) remove() { func (c *client) update() { e := &event{ StandardEvent: &standardEvent{ - Active: c.Active, + Active: c.Active, + ActiveStart: c.ActiveStart, + TimerStart: c.room.timerStart.Unix(), }, } if c.Admin { @@ -446,10 +506,10 @@ func (c *client) update() { func newRoom(roomId string) *room { return &room{ - roomId: roomId, - clientById: map[string]*client{}, - clientByPublicId: map[string]*client{}, - present: map[*presentState]bool{}, + roomId: roomId, + timerStart: time.Now(), + clientById: map[string]*client{}, + present: map[*presentState]bool{}, } } @@ -471,12 +531,10 @@ func (rm *room) getClient(clientId string) *client { c := rm.clientById[clientId] if c == nil { c = &client{ - clientId: clientId, - PublicClientId: uuid.New().String(), - room: rm, + ClientId: clientId, + room: rm, } rm.clientById[clientId] = c - rm.clientByPublicId[c.PublicClientId] = c rm.sendAdminEvent(&adminEvent{ Client: c, @@ -505,89 +563,76 @@ func (rm *room) sendControlEvent(ce *controlEvent) { } } -func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { +func (rm *room) updateAllClients() { + for _, client := range rm.clientById { + client.update() + } +} + +func registerWatch(w http.ResponseWriter, r *http.Request) (*client, chan *event) { mu.Lock() defer mu.Unlock() - ws := &watchState{ - responseWriter: w, - request: r, - eventChan: make(chan *event, 100), - } - - var ok bool - ws.flusher, ok = w.(http.Flusher) - if !ok { - http.Error(ws.responseWriter, "streaming unsupported", http.StatusBadRequest) - return nil - } - roomId := r.URL.Query().Get("room_id") - ws.room = getRoom(roomId) + room := getRoom(roomId) clientId := r.URL.Query().Get("client_id") - ws.client = ws.room.getClient(clientId) + client := room.getClient(clientId) adminSecret := r.URL.Query().Get("admin_secret") if adminSecret != "" { - if adminSecret == ws.room.adminSecret() { - ws.admin = true + if adminSecret == room.adminSecret() { + client.Admin = true } else { http.Error(w, "invalid admin_secret", http.StatusBadRequest) - return nil + return nil, nil } } - ws.client.eventChan = ws.eventChan + if client.eventChan != nil { + close(client.eventChan) + } + + client.eventChan = make(chan *event, 100) + + client.update() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - return ws + // Return eventChan because we're reading it with the lock held + return client, client.eventChan } -func (ws *watchState) sendInitial() { +func writeInitial(client *client, w http.ResponseWriter, flusher http.Flusher) { mu.Lock() defer mu.Unlock() - if !ws.admin { + if !client.Admin { return } - for _, client := range ws.room.clientById { - ws.sendEvent(&event{ + for _, iter := range client.room.clientById { + writeEvent(&event{ AdminEvent: &adminEvent{ - Client: client, + Client: iter, }, - }) + }, w, flusher) } - - ws.flusher.Flush() } -func (ws *watchState) sendHeartbeat() { - fmt.Fprintf(ws.responseWriter, ":\n\n") - ws.flusher.Flush() +func writeHeartbeat(w http.ResponseWriter, flusher http.Flusher) { + writeEvent(&event{}, w, flusher) } -func (ws *watchState) sendEvent(e *event) { +func writeEvent(e *event, w http.ResponseWriter, flusher http.Flusher) { j, err := json.Marshal(e) if err != nil { log.Fatal(err) } - fmt.Fprintf(ws.responseWriter, "data: %s\n\n", j) - ws.flusher.Flush() -} - -func (ws *watchState) close() { - mu.Lock() - defer mu.Unlock() - - if ws.client.eventChan == ws.eventChan { - ws.client.eventChan = nil - close(ws.eventChan) - } + fmt.Fprintf(w, "data: %s\n\n", j) + flusher.Flush() } func newPresentState(w http.ResponseWriter, r *http.Request) *presentState { @@ -596,7 +641,6 @@ func newPresentState(w http.ResponseWriter, r *http.Request) *presentState { ps := &presentState{ responseWriter: w, - request: r, controlChan: make(chan *controlEvent, 100), } diff --git a/static/remote.css b/static/remote.css index c58b1d9..9e3a474 100644 --- a/static/remote.css +++ b/static/remote.css @@ -42,7 +42,8 @@ tfoot tr { } .admin, -.active { +.active, +.solo { cursor: pointer; opacity: 0.3; user-select: none; @@ -78,6 +79,16 @@ tfoot tr { cursor: pointer; } +.action { + color: var(--highlight-color); + cursor: pointer; + margin-left: 10px; +} + +.users tbody tr td:nth-child(2) { + text-align: right; +} + .github { bottom: 0; color: var(--subtle-color); diff --git a/static/remote.js b/static/remote.js index b631402..7ef87da 100644 --- a/static/remote.js +++ b/static/remote.js @@ -1,4 +1,5 @@ "use strict"; +const messageBus = new EventTarget(); function main() { const url = new URL(location.href); if (url.searchParams.has("room")) { @@ -26,7 +27,7 @@ function renderRoom(roomId) { watch(roomId, clientId, adminSecret, prnt); } function newRoom() { - fetch("/api/create", { method: "POST" }) + fetch("api/create", { method: "POST" }) .then(resp => resp.json()) .then(data => { const resp = data; @@ -43,7 +44,7 @@ function announce(roomId, clientId, adminSecret, name) { admin_secret: adminSecret, name: name.value, }; - fetch("/api/announce", { + fetch("api/announce", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -52,22 +53,54 @@ function announce(roomId, clientId, adminSecret, name) { }) .then(() => { setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }) + .catch(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); }); } function watch(roomId, clientId, adminSecret, prnt) { - const url = new URL("/api/watch", location.href); + const url = new URL("api/watch", location.href); url.searchParams.set("room_id", roomId); url.searchParams.set("client_id", clientId); if (adminSecret) { url.searchParams.set("admin_secret", adminSecret); } - const es = new EventSource(url.toString()); - renderControls(roomId, clientId, adminSecret, prnt, es); + createEventSource(url); + renderControls(roomId, clientId, adminSecret, prnt); + renderTimers(roomId, adminSecret, prnt); if (adminSecret) { - renderAdmin(roomId, adminSecret, prnt, es); + renderAdmin(roomId, adminSecret, prnt); } } -function renderControls(roomId, clientId, adminSecret, prnt, es) { +function createEventSource(url) { + const es = new EventSource(url.toString()); + let lastMessage = performance.now(); + const intId = setInterval(() => { + if (performance.now() - lastMessage > 10000) { + console.warn("timeout"); + es.dispatchEvent(new Event("error")); + } + }, 1000); + es.addEventListener("open", () => { + console.info("connected"); + messageBus.dispatchEvent(new Event("open")); + }); + es.addEventListener("message", (e) => { + messageBus.dispatchEvent(new MessageEvent("message", { + data: e.data, + lastEventId: e.lastEventId, + })); + lastMessage = performance.now(); + }); + es.addEventListener("error", () => { + console.warn("disconnected"); + es.close(); + clearInterval(intId); + setTimeout(() => createEventSource(url), 3000); + messageBus.dispatchEvent(new Event("error")); + }); +} +function renderControls(roomId, clientId, adminSecret, prnt) { const controls = create(prnt, "div", undefined, ["controls"]); const left = create(controls, "span", "<<<", ["control-button"]); left.addEventListener("click", () => control(roomId, clientId, controls, "left")); @@ -84,7 +117,8 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { break; } }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev; const event = JSON.parse(e.data); if (!event.standard_event) { return; @@ -101,31 +135,102 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { } }); } -function renderAdmin(roomId, adminSecret, prnt, es) { +function renderTimers(roomId, adminSecret, prnt) { + let overallStart = null; + let meStart = null; + messageBus.addEventListener("message", (ev) => { + const e = ev; + const event = JSON.parse(e.data); + if (!event.standard_event) { + return; + } + overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + meStart = parseInt(event.standard_event.active_start || "0", 10) || null; + }); + const width = 10; + const statusDiv = create(prnt, "div", "Status: ".padStart(width, "\u00a0")); + const status = create(statusDiv, "span"); + messageBus.addEventListener("open", () => { + status.innerText = "\u{1f7e2}"; + }); + messageBus.addEventListener("error", () => { + status.innerText = "\u{1f534}"; + }); + const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); + const clock = create(clockDiv, "span"); + const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); + const overall = create(overallDiv, "span"); + const meDiv = create(prnt, "div", "Me: ".padStart(width, "\u00a0")); + const me = create(meDiv, "span"); + if (adminSecret) { + const reset = create(overallDiv, "span", "↺", ["action"]); + reset.addEventListener("click", () => { + const req = { + room_id: roomId, + admin_secret: adminSecret, + }; + fetch("api/reset", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); + }); + } + setInterval(() => { + const now = new Date(); + clock.innerText = `${now.getHours().toString().padStart(2, "0")}h${now.getMinutes().toString().padStart(2, "0")}m${now.getSeconds().toString().padStart(2, "0")}s`; + if (overallStart) { + const o = Math.trunc(now.getTime() / 1000 - overallStart); + overall.innerText = `${Math.trunc(o / 3600).toString().padStart(2, "0")}h${Math.trunc(o % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(o % 60).toString().padStart(2, "0")}s`; + } + else { + overall.innerText = ""; + } + if (meStart) { + const d = Math.trunc(now.getTime() / 1000 - meStart); + me.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + else { + me.innerText = ""; + } + }, 250); +} +function renderAdmin(roomId, adminSecret, prnt) { const table = create(prnt, "table", undefined, ["users"]); const head = create(table, "thead"); const head1 = create(head, "tr"); create(head1, "th", "Name"); + create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); + create(head1, "th", "🌟"); const body = create(table, "tbody"); const rows = new Map(); - es.addEventListener("message", (e) => { + messageBus.addEventListener("open", () => { + rows.clear(); + body.innerHTML = ""; + }); + messageBus.addEventListener("message", (ev) => { + const e = ev; const event = JSON.parse(e.data); if (!event.admin_event) { return; } const client = event.admin_event.client; - let row = rows.get(client.public_client_id); + let row = rows.get(client.client_id); if (row) { row.remove(); - rows.delete(client.public_client_id); + rows.delete(client.client_id); } if (event.admin_event.remove) { return; } row = document.createElement("tr"); row.dataset.name = client.name; + row.dataset.active = client.active ? "active" : ""; + row.dataset.activeStart = client.active_start; let before = null; for (const iter of body.children) { const iterRow = iter; @@ -136,30 +241,69 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } body.insertBefore(row, before); create(row, "td", client.name); + create(row, "td"); const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]); adminCell.addEventListener("click", () => { if (!client.admin) { if (!confirm(`Grant admin access to ${client.name}?`)) { return; } - admin(roomId, adminSecret, client.public_client_id); + admin(roomId, adminSecret, client.client_id); } }); const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]); activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable"), false); }); - rows.set(client.public_client_id, row); + const soloCell = create(row, "td", "🌟", ["solo"]); + soloCell.addEventListener("click", () => { + if (soloCell.classList.contains("enable")) { + active(roomId, adminSecret, client.client_id, false, false); + } + else { + active(roomId, adminSecret, client.client_id, true, true); + } + }); + rows.set(client.client_id, row); + setSolo(rows); }); + setInterval(() => { + const now = new Date(); + for (const row of rows.values()) { + const cell = row.children[1]; + const as = parseInt(row.dataset.activeStart || "0", 10) || null; + if (as) { + const d = Math.trunc(now.getTime() / 1000 - as); + cell.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + } + }, 250); } -function active(roomId, adminSecret, publicClientId, val) { +function setSolo(rows) { + let activeCount = 0; + for (const row of rows.values()) { + if (row.dataset.active === "active") { + activeCount++; + } + } + for (const row of rows.values()) { + if (activeCount === 1 && row.dataset.active === "active") { + row.children[4].classList.add("enable"); + } + else { + row.children[4].classList.remove("enable"); + } + } +} +function active(roomId, adminSecret, clientId, val, solo) { const req = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, active: val, + solo, }; - fetch("/api/active", { + fetch("api/active", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -167,13 +311,13 @@ function active(roomId, adminSecret, publicClientId, val) { body: JSON.stringify(req), }); } -function admin(roomId, adminSecret, publicClientId) { +function admin(roomId, adminSecret, clientId) { const req = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, }; - fetch("/api/admin", { + fetch("api/admin", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -190,7 +334,7 @@ function control(roomId, clientId, controls, ctrl) { client_id: clientId, control: ctrl, }; - fetch("/api/control", { + fetch("api/control", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -214,7 +358,7 @@ function remove(roomId, clientId) { room_id: roomId, client_id: clientId, }; - navigator.sendBeacon("/api/remove", JSON.stringify(req)); + navigator.sendBeacon("api/remove", JSON.stringify(req)); } function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { diff --git a/static/remote.ts b/static/remote.ts index 01c2b7a..2800ec8 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -1,14 +1,15 @@ interface ActiveRequest { room_id: string; admin_secret: string; - public_client_id: string; + client_id: string; active: boolean; + solo: boolean; } interface AdminRequest { room_id: string; admin_secret: string; - public_client_id: string; + client_id: string; } interface AnnounceRequest { @@ -34,13 +35,20 @@ interface RemoveRequest { client_id: string; } +interface ResetRequest { + room_id: string; + admin_secret: string; +} + interface Event { standard_event?: StandardEvent; admin_event?: AdminEvent; } interface StandardEvent { + timer_start?: string; active?: boolean; + active_start?: string; admin_secret?: string; } @@ -50,12 +58,15 @@ interface AdminEvent { } interface Client { - public_client_id: string; + client_id: string; name: string; admin: boolean; active: boolean; + active_start: string; } +const messageBus = new EventTarget(); + function main() { const url = new URL(location.href); @@ -91,7 +102,7 @@ function renderRoom(roomId: string) { } function newRoom() { - fetch("/api/create", {method: "POST"}) + fetch("api/create", {method: "POST"}) .then(resp => resp.json()) .then(data => { const resp = data as CreateResponse; @@ -112,7 +123,7 @@ function announce(roomId: string, clientId: string, adminSecret: string | null, name: name.value, }; - fetch("/api/announce", { + fetch("api/announce", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -121,26 +132,69 @@ function announce(roomId: string, clientId: string, adminSecret: string | null, }) .then(() => { setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }) + .catch(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); }); } function watch(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) { - const url = new URL("/api/watch", location.href); + const url = new URL("api/watch", location.href); url.searchParams.set("room_id", roomId); url.searchParams.set("client_id", clientId); if (adminSecret) { url.searchParams.set("admin_secret", adminSecret); } - const es = new EventSource(url.toString()); + createEventSource(url); - renderControls(roomId, clientId, adminSecret, prnt, es); + renderControls(roomId, clientId, adminSecret, prnt); + + renderTimers(roomId, adminSecret, prnt); if (adminSecret) { - renderAdmin(roomId, adminSecret, prnt, es); + renderAdmin(roomId, adminSecret, prnt); } } -function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { +function createEventSource(url: URL) { + const es = new EventSource(url.toString()); + + let lastMessage = performance.now(); + + const intId = setInterval(() => { + if (performance.now() - lastMessage > 10000) { + console.warn("timeout"); + es.dispatchEvent(new Event("error")); + } + }, 1000); + + es.addEventListener("open", () => { + console.info("connected"); + messageBus.dispatchEvent(new Event("open")); + }); + + es.addEventListener("message", (e) => { + messageBus.dispatchEvent(new MessageEvent("message", { + data: e.data, + lastEventId: e.lastEventId, + })); + + lastMessage = performance.now(); + }); + + es.addEventListener("error", () => { + console.warn("disconnected"); + + es.close(); + clearInterval(intId); + + setTimeout(() => createEventSource(url), 3000); + + messageBus.dispatchEvent(new Event("error")); + }); +} + +function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) { const controls = create(prnt, "div", undefined, ["controls"]) as HTMLDivElement; const left = create(controls, "span", "<<<", ["control-button"]) as HTMLDivElement; @@ -162,7 +216,8 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | } }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; const event = JSON.parse(e.data) as Event; if (!event.standard_event) { @@ -182,19 +237,103 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | }); } -function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: EventSource) { +function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElement) { + let overallStart: number | null = null; + let meStart: number | null = null; + + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; + const event = JSON.parse(e.data) as Event; + + if (!event.standard_event) { + return; + } + + overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + meStart = parseInt(event.standard_event.active_start || "0", 10) || null; + }); + + const width = 10; + + const statusDiv = create(prnt, "div", "Status: ".padStart(width, "\u00a0")); + const status = create(statusDiv, "span"); + + messageBus.addEventListener("open", () => { + status.innerText = "\u{1f7e2}"; + }); + + messageBus.addEventListener("error", () => { + status.innerText = "\u{1f534}"; + }); + + const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); + const clock = create(clockDiv, "span"); + + const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); + const overall = create(overallDiv, "span"); + + const meDiv = create(prnt, "div", "Me: ".padStart(width, "\u00a0")); + const me = create(meDiv, "span"); + + if (adminSecret) { + const reset = create(overallDiv, "span", "↺", ["action"]); + reset.addEventListener("click", () => { + const req: ResetRequest = { + room_id: roomId, + admin_secret: adminSecret, + }; + + fetch("api/reset", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); + }); + } + + setInterval(() => { + const now = new Date(); + clock.innerText = `${now.getHours().toString().padStart(2, "0")}h${now.getMinutes().toString().padStart(2, "0")}m${now.getSeconds().toString().padStart(2, "0")}s`; + + if (overallStart) { + const o = Math.trunc(now.getTime() / 1000 - overallStart); + overall.innerText = `${Math.trunc(o / 3600).toString().padStart(2, "0")}h${Math.trunc(o % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(o % 60).toString().padStart(2, "0")}s`; + } else { + overall.innerText = ""; + } + + if (meStart) { + const d = Math.trunc(now.getTime() / 1000 - meStart); + me.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } else { + me.innerText = ""; + } + }, 250); +} + +function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement) { const table = create(prnt, "table", undefined, ["users"]) as HTMLTableElement; const head = create(table, "thead"); const head1 = create(head, "tr"); create(head1, "th", "Name"); + create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); + create(head1, "th", "🌟"); const body = create(table, "tbody"); const rows: Map = new Map(); - es.addEventListener("message", (e) => { + messageBus.addEventListener("open", () => { + rows.clear(); + body.innerHTML = ""; + }); + + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; const event = JSON.parse(e.data) as Event; if (!event.admin_event) { @@ -202,11 +341,11 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: } const client = event.admin_event.client; - let row = rows.get(client.public_client_id); + let row = rows.get(client.client_id); if (row) { row.remove(); - rows.delete(client.public_client_id); + rows.delete(client.client_id); } if (event.admin_event.remove) { @@ -215,6 +354,8 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: row = document.createElement("tr") as HTMLTableRowElement; row.dataset.name = client.name; + row.dataset.active = client.active ? "active" : ""; + row.dataset.activeStart = client.active_start; let before = null; for (const iter of body.children) { @@ -227,6 +368,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: body.insertBefore(row, before); create(row, "td", client.name); + create(row, "td"); const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]) as HTMLTableCellElement; adminCell.addEventListener("click", () => { @@ -234,28 +376,71 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: if (!confirm(`Grant admin access to ${client.name}?`)) { return; } - admin(roomId, adminSecret, client.public_client_id); + admin(roomId, adminSecret, client.client_id); } }); const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]) as HTMLTableCellElement; activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable"), false); }); - rows.set(client.public_client_id, row); + const soloCell = create(row, "td", "🌟", ["solo"]) as HTMLTableCellElement; + soloCell.addEventListener("click", () => { + if (soloCell.classList.contains("enable")) { + active(roomId, adminSecret, client.client_id, false, false); + } else { + active(roomId, adminSecret, client.client_id, true, true); + } + }); + + rows.set(client.client_id, row); + + setSolo(rows); }); + + setInterval(() => { + const now = new Date(); + + for (const row of rows.values()) { + const cell = row.children[1] as HTMLTableCellElement; + const as = parseInt(row.dataset.activeStart || "0", 10) || null; + if (as) { + const d = Math.trunc(now.getTime() / 1000 - as); + cell.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + } + }, 250); } -function active(roomId: string, adminSecret: string, publicClientId: string, val: boolean) { +function setSolo(rows: Map) { + let activeCount = 0; + + for (const row of rows.values()) { + if (row.dataset.active === "active") { + activeCount++; + } + } + + for (const row of rows.values()) { + if (activeCount === 1 && row.dataset.active === "active") { + row.children[4].classList.add("enable"); + } else { + row.children[4].classList.remove("enable"); + } + } +} + +function active(roomId: string, adminSecret: string, clientId: string, val: boolean, solo: boolean) { const req: ActiveRequest = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, active: val, + solo, }; - fetch("/api/active", { + fetch("api/active", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -264,14 +449,14 @@ function active(roomId: string, adminSecret: string, publicClientId: string, val }) } -function admin(roomId: string, adminSecret: string, publicClientId: string) { +function admin(roomId: string, adminSecret: string, clientId: string) { const req: AdminRequest = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, }; - fetch("/api/admin", { + fetch("api/admin", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -291,7 +476,7 @@ function control(roomId: string, clientId: string, controls: HTMLElement, ctrl: control: ctrl, }; - fetch("/api/control", { + fetch("api/control", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -320,7 +505,7 @@ function remove(roomId: string, clientId: string) { room_id: roomId, client_id: clientId, } - navigator.sendBeacon("/api/remove", JSON.stringify(req)); + navigator.sendBeacon("api/remove", JSON.stringify(req)); } function uuid() {