diff --git a/cmd/stream-tools/main.go b/cmd/stream-tools/main.go index 3551529..3001e19 100644 --- a/cmd/stream-tools/main.go +++ b/cmd/stream-tools/main.go @@ -73,7 +73,6 @@ func main() { public := srv.Group("/public") public.GET("/*path", func(ctx *gin.Context) { filepath := ctx.Request.URL.Path - log.Printf("serving %s from static FS...", filepath) http.ServeFileFS(ctx.Writer, ctx.Request, static.PublicFS, filepath) }) diff --git a/static/public/fonts/darumadrop-one/DarumadropOne-Regular.ttf b/static/public/fonts/darumadrop-one/DarumadropOne-Regular.ttf new file mode 100644 index 0000000..53acdae Binary files /dev/null and b/static/public/fonts/darumadrop-one/DarumadropOne-Regular.ttf differ diff --git a/static/public/fonts/darumadrop-one/OFL.txt b/static/public/fonts/darumadrop-one/OFL.txt new file mode 100644 index 0000000..2c9afce --- /dev/null +++ b/static/public/fonts/darumadrop-one/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Darumadrop Project Authors (https://github.com/ManiackersDesign/darumadrop) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/twitch/api.go b/twitch/api.go index 8284b16..cfd4b8e 100644 --- a/twitch/api.go +++ b/twitch/api.go @@ -213,15 +213,16 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { if event.MessageType == api.MESSAGE_CHANNEL_POINTS_HIGHLIGHTED { modifier = CHAT_MODIFIER_HIGHLIGHT - log.Printf("Highlighted from %s: %s", event.ChatterLogin, event.Message.Text) + log.Printf("Highlighted from %s: %s", event.ChatterName, event.Message.Text) } else { - log.Printf("%s: %s", event.ChatterLogin, event.Message.Text) + log.Printf("%s: %s", event.ChatterName, event.Message.Text) } srv.ChatC <- &chatMessage{ ID: event.MessageID, - Username: event.ChatterLogin, + Username: event.ChatterName, Colour: event.Color, + Badges: event.Badges, Text: event.Message.Text, Fragments: event.Message.Fragments, Modifier: modifier, diff --git a/twitch/pages/chat.html b/twitch/pages/chat.html index d0e06ab..1821b29 100644 --- a/twitch/pages/chat.html +++ b/twitch/pages/chat.html @@ -4,7 +4,6 @@ Twitch Chat - diff --git a/twitch/public/chat.css b/twitch/public/chat.css index e69de29..0ef8a6c 100644 --- a/twitch/public/chat.css +++ b/twitch/public/chat.css @@ -0,0 +1,95 @@ +/* +@font-face { + font-family: 'Darumadrop One'; + src: url('/public/fonts/darumadrop-one/DarumadropOne-Regular.ttf') format('truetype'); +} +*/ + +@import url('https://fonts.googleapis.com/css2?family=Darumadrop+One&display=swap'); + +body { + min-height: 100vh; + margin: 0; + padding: 0; + + background: hsl(0 0 50%); +} + +#chat { + position: relative; + top: .33em; + min-height: calc(100vh - .66em); + max-width: 600px; + margin: .33em; + + display: flex; + flex-direction: column; + justify-content: end; + gap: .25em; + + font-family: "Darumadrop One", sans-serif; + font-size: 32px; + line-height: 1em; + + word-wrap: break-word; + + &.end { + text-align: end; + align-items: end; + } +} + +.message { + width: 100%; + + --colour: hsl(0 0 75%); + + color: hsl(0 0 100%); + text-shadow: .05em .05em 0 hsl(0 0 0 / 75%); + + &.system { + width: 420px; + margin: 0 1em; + + display: flex; + flex-direction: row; + + --colour: hsl(0 0 100%); + color: var(--colour); + + &.follow { --hue: 128; } + &.subscribe { --hue: 284; } + &.cheer { --hue: 222; } + &.raid { --hue: 28; } + &.poll { --hue: 269; } + &.shoutout { --hue: 104; } + &.point-redeem { --hue: 338; } + + &.follow, &.subscribe, &.cheer, &.raid, &.poll, &.shoutout, &.point-redeem { + --colour: oklch(80% 80% var(--hue) / 100%); + } + + .icon { + height: .5em; + margin-top: .3em; + margin-right: .2em; + transform: translateY(.15em); + aspect-ratio: 1; + + background-color: var(--colour); + border-radius: 100%; + box-shadow: .05em .05em 0 hsl(0 0 0 / 75%); + } + } + + p { margin: 0; } + + .header { + color: var(--colour); + } + + .content { + z-index: 1; + position: relative; + } +} diff --git a/twitch/public/chat.js b/twitch/public/chat.js index 13adf86..b07c106 100644 --- a/twitch/public/chat.js +++ b/twitch/public/chat.js @@ -1,4 +1,98 @@ const chatContainer = document.getElementById("chat"); +const end = new URLSearchParams(location.search).get("end") !== null; + +class ChatMessage { + get username() { return this._username; } + set username(username) { + this._username = username; + this._usernameEl.innerText = username; + } + + get content() { return this._content; } + set content(content) { + this._content = content; + this._contentEl.innerText = content; + } + + get colour() { return this._colour; } + set colour(colour) { + this._colour = colour; + if (colour != undefined) + this._container.style.setProperty("--colour", colour); + } + + get element() { return this._container; } + + destroy() { + this._container.remove(); + } + + constructor(message) { + this._headerEl = document.createElement('div'); + this._headerEl.className = "header"; + + // TODO: assemble badges in header, before username + + this._usernameEl = document.createElement('p'); + this._usernameEl.className = "username"; + this.username = message.username; + this._headerEl.appendChild(this._usernameEl); + + this._contentEl = document.createElement('p'); + this._contentEl.className = "content"; + + // TODO: build message content from fragments + // TODO: 7TV emotes (stretch goal: FFZ emotes) + this.content = message.text; + + this._container = document.createElement("div"); + this._container.className = "message"; + this._container.dataset.id = message.id; + this._container.dataset.username = message.username; + + // TODO: generate and cache random colour for users that do not have one + this.colour = message.colour; + + this._container.appendChild(this._headerEl); + this._container.appendChild(this._contentEl); + } +} + +class SystemMessage { + get text() { return this._text; } + set text(text) { + this._text = text; + this._textEl.innerText = text; + } + + get type() { return this._type; } + set type(type) { + this._type = type; + this._container.className = ["message", "system", type].join(" ").trim(); + } + + get element() { return this._container; } + + destroy() { + this._container.remove(); + } + + constructor(text, type) { + // TODO: actual icon designs for system messages + this._iconEl = document.createElement('div'); + this._iconEl.className = "icon"; + + this._textEl = document.createElement('p'); + this._textEl.className = "text"; + this.text = text; + + this._container = document.createElement("div"); + this.type = type; + + this._container.appendChild(this._iconEl); + this._container.appendChild(this._textEl); + } +} async function sync() { let eventSource; @@ -20,10 +114,16 @@ async function sync() { }); eventSource.addEventListener("chat", event => { - console.log(`[${chat}] ${event.data}`); - const messageEl = document.createElement("p"); - messageEl.innerText = event.data; - chatContainer.appendChild(messageEl); + handleChatMessage(JSON.parse(event.data)); + }); + eventSource.addEventListener("system", event => { + handleSystemMessage(JSON.parse(event.data)); + }); + eventSource.addEventListener("delete", event => { + const data = JSON.parse(event.data); + const username = data.username; + const message_id = data.id; + handleDeleteMessage(username, message_id); }); eventSource.addEventListener("open", () => { @@ -31,8 +131,60 @@ async function sync() { }); } -function twitchEmoteURL(emoteID) { - return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/1.0#e=0` +function handleChatMessage(data) { + console.log(`[CHAT] ${data.username}: ${data.text}`); + + const chatMessage = new ChatMessage(data); + addMessageAndTruncateChat(chatMessage.element); } +function handleSystemMessage(data) { + if (data.type) + console.log(`[SYSTEM] [${data.type}] ${data.text}`); + else + console.log(`[SYSTEM] ${data.text}`); + + const systemMessage = new SystemMessage(data.text, data.type || ""); + addMessageAndTruncateChat(systemMessage.element); +} + +function handleDeleteMessage(username, message_id) { + if (username == "*" && message_id == "*") { + chatContainer.innerHTML = ""; + return; + } + + if (username.length > 0 && message_id == "*") { + chatContainer.querySelectorAll(`.message[data-username=${username}]`) + .forEach(msg => { + msg.remove(); + }); + return; + } + + if (username.length == 0 && message_id.length > 0) { + const msg = chatContainer.querySelector(`.message[data-id=${message_id}]`); + if (msg) msg.remove(); + } +} + +const CHAT_LIMIT = 50; +function addMessageAndTruncateChat(element) { + chatContainer.appendChild(element); + window.scrollTo(0, document.body.scrollHeight); + + if (chatContainer.childElementCount <= CHAT_LIMIT) return; + chatContainer.children[0].remove(); +} + +function twitchEmoteURL(emoteID) { + return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/4.0#e=0` +} + +function twitchBadgeURL(badgeID) { + return `https://static-cdn.jtvnw.net/badges/v1/${badgeID}/3` +} + +if (end) + chatContainer.classList.add("end") sync(); diff --git a/twitch/twitch.go b/twitch/twitch.go index 34770d0..e5df0e1 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -42,21 +42,22 @@ type ( ChatMessageModifier string chatMessage struct { ID string `json:"id"` - Username string `json:"username,omitempty"` + Username string `json:"username"` Colour string `json:"colour,omitempty"` - Text string `json:"text,omitempty"` - Fragments []api.ChatMessageFragment `json:"fragments,omitempty"` + Text string `json:"text"` + Badges []api.ChatBadge `json:"badges"` + Fragments []api.ChatMessageFragment `json:"fragments"` Modifier ChatMessageModifier `json:"modifier,omitempty"` AnnouncementColour string `json:"announcement_colour,omitempty"` } SystemChatMessageType string systemChatMessage struct { - Text string `json:"text,omitempty"` + Text string `json:"text"` Type SystemChatMessageType `json:"type,omitempty"` } deleteChatMessage struct { - Username string `json:"username,omitempty"` - MessageID string `json:"message_id,omitempty"` + Username string `json:"username"` + MessageID string `json:"message_id"` } ServiceOptions struct { @@ -319,6 +320,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) { deleteChatUpdate := srv.DeleteChatBroadcast.Subscribe() defer srv.DeleteChatBroadcast.Cancel(deleteChatUpdate) + ctx.SSEvent("hello", "hello") + ticker := time.NewTicker(10 * time.Millisecond) ctx.Stream(func(w io.Writer) bool { select {