diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Audio.js | 30 | ||||
| -rw-r--r-- | src/Audio.purs | 10 | ||||
| -rw-r--r-- | src/Auth.js | 45 | ||||
| -rw-r--r-- | src/Auth.purs | 29 | ||||
| -rw-r--r-- | src/Config.js | 4 | ||||
| -rw-r--r-- | src/Config.purs | 6 | ||||
| -rw-r--r-- | src/Main.purs | 157 | ||||
| -rw-r--r-- | src/Model.js | 108 | ||||
| -rw-r--r-- | src/Model.purs | 10 | ||||
| -rw-r--r-- | src/UI.js | 2 | ||||
| -rw-r--r-- | src/UI.purs | 13 |
11 files changed, 414 insertions, 0 deletions
diff --git a/src/Audio.js b/src/Audio.js new file mode 100644 index 0000000..2a9ae54 --- /dev/null +++ b/src/Audio.js @@ -0,0 +1,30 @@ +let initialized = false; +let ctx = null; +let voiceTracks = null; + +function initializeCtx() { + if (!initialized) { + try { + initialized = true; + ctx = new window.AudioContext(); + voiceTracks = [ + document.getElementById("lcolonq-audio-voice-0"), + document.getElementById("lcolonq-audio-voice-1"), + document.getElementById("lcolonq-audio-voice-2"), + document.getElementById("lcolonq-audio-voice-3"), + document.getElementById("lcolonq-audio-voice-4"), + document.getElementById("lcolonq-audio-voice-5"), + document.getElementById("lcolonq-audio-voice-6"), + ]; + } catch (e) { + initialized = false; + } + } +} + +export const _playVoice = (b) => (i) => () => { + if (b) initializeCtx(); + try { + if (initialized) voiceTracks[i].play(); + } catch (e) {} +}; diff --git a/src/Audio.purs b/src/Audio.purs new file mode 100644 index 0000000..8f1f319 --- /dev/null +++ b/src/Audio.purs @@ -0,0 +1,10 @@ +module Audio where + +import Prelude + +import Effect (Effect) +import Effect.Class (class MonadEffect, liftEffect) + +foreign import _playVoice :: Boolean -> Int -> Effect Unit +playVoice :: forall m. MonadEffect m => Boolean -> Int -> m Unit +playVoice b i = liftEffect $ _playVoice b i diff --git a/src/Auth.js b/src/Auth.js new file mode 100644 index 0000000..7254c40 --- /dev/null +++ b/src/Auth.js @@ -0,0 +1,45 @@ +function generateNonce() { + var arr = new Uint8Array(20); + window.crypto.getRandomValues(arr); + return Array.from(arr, b => b.toString(16).padStart(2, "0")).join(""); +} + +export const _startTwitchAuth = (clientID) => (redirectURL) => () => { + const nonce = generateNonce(); + document.cookie = `authnonce=${nonce}; path=/; max-age=3000`; + window.location.href = + `https://id.twitch.tv/oauth2/authorize?response_type=id_token` + + `&client_id=${clientID}` + + `&redirect_uri=${redirectURL}` + + `&scope=openid` + + `&nonce=${nonce}` + + `&claims=${JSON.stringify({id_token: {preferred_username: null}})}` + ; +}; + +function getFragmentQuery() { + let query = new Map(); + const hashQuery = document.location.hash.slice(1).split("&"); + for (let equals of hashQuery) { + const pair = equals.split("="); + query.set(decodeURIComponent(pair[0]), decodeURIComponent(pair[1])); + } + return query; +} + +export const _getToken = (Just) => (Nothing) => (pair) => () => { + const frag = getFragmentQuery(); + const token = frag.get("id_token"); + if (token) { + document.cookie = `id_token=${token}; path=/; SameSite=Strict`; + } + let id_token = null; + let authnonce = null; + for (let c of document.cookie.split("; ")) { + const [k, v] = c.split("="); + if (k === "id_token") id_token = v; + else if (k === "authnonce") authnonce = v; + } + if (id_token && authnonce) return Just(pair(id_token)(authnonce)); + return Nothing; +}; diff --git a/src/Auth.purs b/src/Auth.purs new file mode 100644 index 0000000..2a53629 --- /dev/null +++ b/src/Auth.purs @@ -0,0 +1,29 @@ +module Auth where + +import Prelude + +import Config (authRedirectURL, clientID) +import Data.Array (fold) +import Data.Maybe (Maybe(..)) +import Data.Tuple (Tuple(..)) +import Effect (Effect) +import Effect.Class (class MonadEffect, liftEffect) + +foreign import _startTwitchAuth :: String -> String -> Effect Unit +startTwitchAuth :: forall m. MonadEffect m => m Unit +startTwitchAuth = liftEffect $ _startTwitchAuth clientID authRedirectURL + +type AuthInfo = Tuple String String +foreign import _getToken :: forall a. (a -> Maybe a) -> Maybe a -> (a -> a -> Tuple a a) -> Effect (Maybe (Tuple String String)) +getToken :: forall m. MonadEffect m => m (Maybe AuthInfo) +getToken = liftEffect $ _getToken Just Nothing Tuple + +authHeader :: AuthInfo -> String +authHeader (Tuple t n) = + fold + [ "FIG-TWITCH token=\"" + , t + , "\", nonce=\"" + , n + , "\"" + ] diff --git a/src/Config.js b/src/Config.js new file mode 100644 index 0000000..25259c7 --- /dev/null +++ b/src/Config.js @@ -0,0 +1,4 @@ +export const mode = globalThis.mode; +export const apiServer = globalThis.apiServer; +export const clientID = globalThis.clientID; +export const authRedirectURL = globalThis.authRedirectURL; diff --git a/src/Config.purs b/src/Config.purs new file mode 100644 index 0000000..536f163 --- /dev/null +++ b/src/Config.purs @@ -0,0 +1,6 @@ +module Config where + +foreign import mode :: Int +foreign import apiServer :: String +foreign import clientID :: String +foreign import authRedirectURL :: String diff --git a/src/Main.purs b/src/Main.purs new file mode 100644 index 0000000..bb5fa57 --- /dev/null +++ b/src/Main.purs @@ -0,0 +1,157 @@ +module Main where + +import Prelude + +import Audio as Audio +import Auth (AuthInfo, authHeader, getToken, startTwitchAuth) +import Config as Config +import Data.Array (head) +import Data.Array as Array +import Data.Foldable (fold) +import Data.Maybe (Maybe(..)) +import Data.Traversable (for, for_) +import Data.Tuple (Tuple(..)) +import Effect (Effect) +import Effect.Aff (Aff, launchAff_) +import Effect.Class (class MonadEffect, liftEffect) +import Effect.Console (log) +import Effect.Exception (throw) +import Fetch (fetch) +import Model (startModel) +import UI as UI +import Web.DOM as DOM +import Web.DOM.DOMTokenList as DOM.DTL +import Web.DOM.Document (doctype) +import Web.DOM.Document as DOM.Doc +import Web.DOM.Element as DOM.El +import Web.DOM.Node as DOM.Node +import Web.DOM.NodeList as DOM.NL +import Web.DOM.NonElementParentNode as DOM.NEP +import Web.DOM.ParentNode as DOM.P +import Web.DOM.Text as DOM.Text +import Web.Event.Event as Ev +import Web.Event.EventTarget as Ev.Tar +import Web.HTML as HTML +import Web.HTML.HTMLDocument as HTML.Doc +import Web.HTML.Window as HTML.Win + +maybeToArray :: forall a. Maybe a -> Array a +maybeToArray (Just x) = [x] +maybeToArray Nothing = [] + +byId :: forall m. MonadEffect m => String -> m DOM.Element +byId i = do + w <- liftEffect HTML.window + d <- liftEffect $ HTML.Doc.toDocument <$> HTML.Win.document w + liftEffect (DOM.NEP.getElementById i (DOM.Doc.toNonElementParentNode d)) >>= case _ of + Nothing -> liftEffect $ throw $ "could not find element with id: " <> i + Just e -> pure e + +queryAll :: forall m. MonadEffect m => String -> m (Array DOM.Element) +queryAll q = do + w <- liftEffect HTML.window + d <- liftEffect $ HTML.Doc.toDocument <$> HTML.Win.document w + nl <- liftEffect (DOM.P.querySelectorAll (DOM.P.QuerySelector q) (DOM.Doc.toParentNode d)) + ns <- liftEffect $ DOM.NL.toArray nl + pure $ fold $ (maybeToArray <<< DOM.El.fromNode) <$> ns + +query :: forall m . MonadEffect m => String -> m DOM.Element +query q = do + queryAll q >>= head >>> case _ of + Nothing -> liftEffect $ throw $ "could not find element matching query: " <> q + Just x -> pure x + +listen :: forall m. MonadEffect m => DOM.Element -> String -> (Ev.Event -> Effect Unit) -> m Unit +listen e ev f = do + l <- liftEffect $ Ev.Tar.eventListener f + liftEffect $ Ev.Tar.addEventListener (Ev.EventType ev) l false $ DOM.El.toEventTarget e + +create :: forall m. MonadEffect m => String -> Array String -> Array DOM.Element -> m DOM.Element +create tag classes children = do + w <- liftEffect HTML.window + d <- liftEffect $ HTML.Doc.toDocument <$> HTML.Win.document w + el <- liftEffect $ DOM.Doc.createElement tag d + cl <- liftEffect $ DOM.El.classList el + for_ classes \c -> + liftEffect $ DOM.DTL.add cl c + for_ children \c -> + appendElement el c + pure el + +appendElement :: forall m. MonadEffect m => DOM.Element -> DOM.Element -> m Unit +appendElement parent child = liftEffect $ DOM.Node.appendChild (DOM.El.toNode child) (DOM.El.toNode parent) + +appendText :: forall m. MonadEffect m => DOM.Element -> String -> m Unit +appendText parent s = do + w <- liftEffect HTML.window + d <- liftEffect $ HTML.Doc.toDocument <$> HTML.Win.document w + n <- liftEffect $ DOM.Doc.createTextNode s d + liftEffect $ DOM.Node.appendChild (DOM.Text.toNode n) (DOM.El.toNode parent) + +setText :: forall m. MonadEffect m => DOM.Element -> String -> m Unit +setText e s = liftEffect $ DOM.Node.setTextContent s $ DOM.El.toNode e + +updateSubtitle :: Aff Unit +updateSubtitle = do + subtitle <- byId "lcolonq-subtitle" + { text: catchphrase } <- fetch (Config.apiServer <> "/catchphrase") {} + catchphrase >>= setText subtitle + +checkAuth :: AuthInfo -> Aff String +checkAuth auth = do + { text: resp } <- + fetch (Config.apiServer <> "/check") + { headers: + { "Authorization": authHeader auth + } + } + resp + +mainHomepage :: Effect Unit +mainHomepage = launchAff_ do + liftEffect $ log "hi" + startModel + marq <- byId "lcolonq-marquee" + { text: motd } <- fetch (Config.apiServer <> "/motd") {} + motd >>= setText marq + + getToken >>= case _ of + Just a@(Tuple t n) -> do + liftEffect $ log t + liftEffect $ log n + checkAuth a >>= log >>> liftEffect + _ -> pure unit + + updateSubtitle + subtitle <- byId "lcolonq-subtitle" + listen subtitle "click" \_ev -> do + startTwitchAuth + launchAff_ updateSubtitle + + for_ (Array.range 0 6) \i -> do + letter <- byId $ "lcolonq-letter-" <> show i + listen letter "click" \_ev -> do + Audio.playVoice true i + listen letter "mouseover" \_ev -> do + Audio.playVoice false i + +mainExtension :: Effect Unit +mainExtension = launchAff_ do + liftEffect $ log "hello from extension" + UI.setInterval 1000.0 do + e <- query ".chat-scrollable-area__message-container" + new <- create "div" [".chat-line__message"] [] + appendText new "test" + appendElement e new + +mainObs :: Effect Unit +mainObs = launchAff_ do + startModel + +main :: Effect Unit +main = case Config.mode of + 0 -> mainHomepage + 1 -> mainExtension + 2 -> mainObs + -- 3 -> mainButton + _ -> throw "unknown mode" diff --git a/src/Model.js b/src/Model.js new file mode 100644 index 0000000..797bae4 --- /dev/null +++ b/src/Model.js @@ -0,0 +1,108 @@ +let canvas = document.getElementById("lcolonq-canvas"); +let socket = null; +let currentFrame = null; + +async function decompress(blob) { + let ds = new DecompressionStream("gzip"); + let stream = blob.stream(); + let out = await new Response(stream.pipeThrough(ds)); + return out.arrayBuffer(); +} + +function readCell(dv, base) { + let cell = {}; + let o = base; + if (dv.getUint8(o) == 0) { + return [{type: "bg"}, o + 1]; + } else { + cell.type = "fg"; + cell.custom = dv.getUint8(o + 1); + cell.r = dv.getUint8(o + 2); + cell.g = dv.getUint8(o + 3); + cell.b = dv.getUint8(o + 4); + cell.g0 = dv.getUint32(o + 5); + if (dv.getUint8(o + 9) == 0) { + return [cell, o + 10]; + } else { + cell.g1 = dv.getUint32(o + 10); + return [cell, o + 14]; + } + } +} + +function readKeyframe(dv, base) { + let ret = []; + let o = base; + for (let idx = 0; idx < (64 * 64); ++idx) { + let res = readCell(dv, o); + ret.push(res[0]); + o = res[1]; + } + currentFrame = ret; +} + +function readDiff(dv, base) { + if (currentFrame) { + let len = dv.getUint32(base); + let o = base + 4; + for (let idx = 0; idx < len; ++idx) { + let x = dv.getUint8(o); + let y = dv.getUint8(o + 1); + let c = readCell(dv, o + 2); + currentFrame[x + (y * 64)] = c[0]; + o = c[1]; + } + } +} + +function readPacket(dv) { + if (dv.getUint8(0) == 0) { + readKeyframe(dv, 1); + } else { + readDiff(dv, 1); + } +} + +function renderCellCanvas(ctx, x, y, c) { + if (c && c.type === "fg") { + let msg = c.g1 ? String.fromCodePoint(c.g0, c.g1) : String.fromCodePoint(c.g0); + if (msg.trim().length) { + ctx.fillStyle = "black"; + ctx.fillRect(13 * y, 13 * x, 13, 13); + ctx.fillStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 1.0)`; + ctx.fillText(msg, 13 * y, 13 * x + 10); + } + } +} + +function renderCanvas() { + if (canvas.width != canvas.clientWidth) { + canvas.width = canvas.clientWidth; + } + if (canvas.height != canvas.clientHeight) { + canvas.height = canvas.clientHeight; + } + if (currentFrame) { + let ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = "12px Iosevka Comfy"; + for (let y = 0; y < 64; ++y) { + for (let x = 0; x < 64; ++x) { + renderCellCanvas(ctx, x, y, currentFrame[(x * 64) + y]); + } + } + } +} + +export const _startModel = () => { + socket = new WebSocket("wss://colonq.computer/bullfrog/api/channel/listen/model"); + socket.addEventListener("open", (ev) => { + console.log("connected"); + }); + socket.addEventListener("message", async (ev) => { + let arr = await decompress(ev.data); + let view = new DataView(arr); + readPacket(view); + renderCanvas(); + }); +}; diff --git a/src/Model.purs b/src/Model.purs new file mode 100644 index 0000000..a9f2888 --- /dev/null +++ b/src/Model.purs @@ -0,0 +1,10 @@ +module Model where + +import Prelude + +import Effect (Effect) +import Effect.Class (class MonadEffect, liftEffect) + +foreign import _startModel :: Effect Unit +startModel :: forall m. MonadEffect m => m Unit +startModel = liftEffect _startModel diff --git a/src/UI.js b/src/UI.js new file mode 100644 index 0000000..0b00e5d --- /dev/null +++ b/src/UI.js @@ -0,0 +1,2 @@ +export const _cheatLog = (a) => () => console.log(a); +export const _setInterval = (delay) => (f) => () => setInterval(f, delay); diff --git a/src/UI.purs b/src/UI.purs new file mode 100644 index 0000000..49d797a --- /dev/null +++ b/src/UI.purs @@ -0,0 +1,13 @@ +module UI where + +import Prelude +import Effect (Effect) +import Effect.Class (class MonadEffect, liftEffect) + +foreign import _cheatLog :: forall a. a -> Effect Unit +cheatLog :: forall m a. MonadEffect m => a -> m Unit +cheatLog x = liftEffect $ _cheatLog x + +foreign import _setInterval :: Number -> Effect Unit -> Effect Unit +setInterval :: forall m. MonadEffect m => Number -> Effect Unit -> m Unit +setInterval d f = liftEffect $ _setInterval d f |
