From c25f7d9bc9152af2b95edc6432cd49fedbe9f2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Fri, 23 Jan 2026 00:04:35 +0100 Subject: [PATCH] Add lichess marketplace script --- .gitattributes | 12 + .../attachments/Chess_bdt45.png | 3 + .../attachments/Chess_blt45.png | 3 + .../attachments/Chess_kdt45.png | 3 + .../attachments/Chess_klt45.png | 3 + .../attachments/Chess_ndt45.png | 3 + .../attachments/Chess_nlt45.png | 3 + .../attachments/Chess_pdt45.png | 3 + .../attachments/Chess_plt45.png | 3 + .../attachments/Chess_qdt45.png | 3 + .../attachments/Chess_qlt45.png | 3 + .../attachments/Chess_rdt45.png | 3 + .../attachments/Chess_rlt45.png | 3 + .../lichess-tv-chess/metadata.json | 4 + .../lichess-tv-chess/source.js | 553 ++++++++++++++++++ .../service/MarketplaceScriptSeedLoader.java | 1 + .../service/media/MediaTypeRegistry.java | 2 + 17 files changed, 608 insertions(+) create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png create mode 100644 doc/marketplace-scripts/lichess-tv-chess/metadata.json create mode 100644 doc/marketplace-scripts/lichess-tv-chess/source.js diff --git a/.gitattributes b/.gitattributes index ae1d649..bea39bf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,15 @@ doc/marketplace-scripts/kirov-reporting/logo.png filter=lfs diff=lfs merge=lfs - doc/marketplace-scripts/teapot-viewer/logo.png filter=lfs diff=lfs merge=lfs -text doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 filter=lfs diff=lfs merge=lfs -text doc/marketplace-scripts/emote-combo/logo.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png filter=lfs diff=lfs merge=lfs -text diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png new file mode 100644 index 0000000..7e6df9d --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2122eee93fcdffce6d2bd17153d46fb37c99f6350a26c1bc225361165db3a609 +size 2291 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png new file mode 100644 index 0000000..5ab11c7 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdecac32cdf33f8c83659359720150dd6ea3452278ecdd2b6d9bc105740de13e +size 3894 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png new file mode 100644 index 0000000..e3e5025 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33f52de6409b80c83c994effdabb7f6e5c6de123f61dc2e6c5063f2d39d7f3b8 +size 5130 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png new file mode 100644 index 0000000..80db487 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40f2592ac94a43cd6e54ace59510b39859d285c43be6bc4601886899bc195586 +size 4765 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png new file mode 100644 index 0000000..885bf10 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0588989510cf3d23a23268e501979065c10e5d65fee36212d091bb5f5426601 +size 3164 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png new file mode 100644 index 0000000..a3bfc4d --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:466812e60663804f2364af96d193c34da30def05974c0233d5b0b8ddd383ddef +size 4035 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png new file mode 100644 index 0000000..528b729 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79b9e5919b13afd62f9b284df753cc747f7c9b6db18f8238608e5cc837ffb4ba +size 1314 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png new file mode 100644 index 0000000..7fd03d2 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca9e8db6a183b14fdc8786f1fe939c05fa7ac85d2e24fed75b6d7e6ae86334b2 +size 2499 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png new file mode 100644 index 0000000..f68a89e --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a934491912c7defe11fba82cdee9bf2d4c73848bbe9db5b9ba400e587d14749 +size 4963 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png new file mode 100644 index 0000000..031114b --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:616f7e40bfda8ac734438d2d6ce13257ada8689077efee68a4212912a53eaf03 +size 6219 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png new file mode 100644 index 0000000..2a0031c --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca78b6390208ca85f3be969590a19dba47f386ae77d26943e1c4bb68d6031cf7 +size 1219 diff --git a/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png new file mode 100644 index 0000000..716ea4e --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74679901dde5455a982075a3cdd1a3908a374b59450c30a1288bd8516d86efaf +size 1502 diff --git a/doc/marketplace-scripts/lichess-tv-chess/metadata.json b/doc/marketplace-scripts/lichess-tv-chess/metadata.json new file mode 100644 index 0000000..380b9b2 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/metadata.json @@ -0,0 +1,4 @@ +{ + "name": "Lichess TV Chess", + "description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends." +} diff --git a/doc/marketplace-scripts/lichess-tv-chess/source.js b/doc/marketplace-scripts/lichess-tv-chess/source.js new file mode 100644 index 0000000..233dcf6 --- /dev/null +++ b/doc/marketplace-scripts/lichess-tv-chess/source.js @@ -0,0 +1,553 @@ +const COMMAND = "!chess"; +const FEED_URL = "https://lichess.org/api/tv/feed"; +const HEADER_HEIGHT = 40; +const PADDING = 16; +const BOARD_MARGIN = 12; +const BORDER_RADIUS = 14; +const FOOTER_HEIGHT = 30; +const LIGHT_SQUARE = "#f0d9b5"; +const DARK_SQUARE = "#b58863"; +const BOARD_OUTLINE = "#111827"; +const PANEL_BG = "rgba(15, 23, 42, 0.72)"; +const PANEL_BORDER = "rgba(255, 255, 255, 0.1)"; +const TEXT_PRIMARY = "#f8fafc"; +const TEXT_MUTED = "#cbd5f5"; + +const PIECE_ASSETS = { + P: "Chess_plt45.png", + R: "Chess_rlt45.png", + N: "Chess_nlt45.png", + B: "Chess_blt45.png", + Q: "Chess_qlt45.png", + K: "Chess_klt45.png", + p: "Chess_pdt45.png", + r: "Chess_rdt45.png", + n: "Chess_ndt45.png", + b: "Chess_bdt45.png", + q: "Chess_qdt45.png", + k: "Chess_kdt45.png", +}; + +const ACTIVE_STATUSES = new Set(["started", "playing", "created"]); + +function normalizeMessage(message) { + return (message || "").trim().toLowerCase(); +} + +function isCommandMatch(message) { + const normalized = normalizeMessage(message); + if (!normalized.startsWith(COMMAND)) { + return false; + } + return normalized.length === COMMAND.length || normalized.startsWith(`${COMMAND} `); +} + +function parseFen(fen) { + if (!fen || typeof fen !== "string") { + return null; + } + const parts = fen.trim().split(" "); + if (parts.length < 2) { + return null; + } + const rows = parts[0].split("/"); + if (rows.length !== 8) { + return null; + } + const board = rows.map((row) => { + const squares = []; + for (const char of row) { + if (Number.isInteger(Number(char))) { + const emptyCount = Number(char); + for (let i = 0; i < emptyCount; i += 1) { + squares.push(null); + } + } else { + squares.push(char); + } + } + return squares.length === 8 ? squares : null; + }); + if (board.some((row) => !row || row.length !== 8)) { + return null; + } + return { + board, + turn: parts[1], + }; +} + +function parseUciMove(move) { + if (!move || typeof move !== "string" || move.length < 4) { + return null; + } + const files = "abcdefgh"; + const fromFile = files.indexOf(move[0]); + const fromRank = Number(move[1]); + const toFile = files.indexOf(move[2]); + const toRank = Number(move[3]); + if (fromFile < 0 || toFile < 0 || !fromRank || !toRank) { + return null; + } + return { + from: { row: 8 - fromRank, col: fromFile }, + to: { row: 8 - toRank, col: toFile }, + }; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function ensurePieceCache(state) { + if (!state.pieceCache) { + state.pieceCache = new Map(); + } + return state.pieceCache; +} + +function resolvePieceAsset(pieceKey, assets) { + const assetName = PIECE_ASSETS[pieceKey]; + if (!assetName || !Array.isArray(assets)) { + return null; + } + return assets.find((asset) => asset?.name === assetName) || null; +} + +function loadPieceBitmap(pieceKey, assets, state) { + const cache = ensurePieceCache(state); + const existing = cache.get(pieceKey); + if (existing?.bitmap) { + return existing.bitmap; + } + if (existing?.loading) { + return null; + } + const asset = resolvePieceAsset(pieceKey, assets); + if (!asset?.url && !asset?.blob) { + return null; + } + const entry = { loading: true, bitmap: null }; + cache.set(pieceKey, entry); + const blobPromise = asset.blob + ? Promise.resolve(asset.blob) + : fetch(asset.url) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load piece asset"); + } + return response.blob(); + }); + blobPromise + .then((blob) => createImageBitmap(blob)) + .then((bitmap) => { + entry.bitmap = bitmap; + entry.loading = false; + }) + .catch(() => { + cache.delete(pieceKey); + }); + return null; +} + +function drawRoundedRect(ctx, x, y, width, height, radius) { + const r = clamp(radius, 0, Math.min(width, height) / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); +} + +function resolvePayload(event) { + if (!event || typeof event !== "object") { + return {}; + } + if (event.d && typeof event.d === "object") { + return event.d; + } + return event; +} + +function resolveGameId(payload) { + return payload?.id || payload?.gameId || payload?.game?.id || payload?.game?.gameId || null; +} + +function resolveStatus(payload) { + return payload?.status || payload?.game?.status || payload?.status?.name || null; +} + +function resolvePlayerFromList(players, color) { + if (!Array.isArray(players)) { + return null; + } + return players.find((player) => player?.color === color) || null; +} + +function resolvePlayers(payload) { + const list = payload?.players || payload?.game?.players; + const white = + resolvePlayerFromList(list, "white") || + payload?.players?.white || + payload?.white || + payload?.game?.players?.white || + payload?.game?.white; + const black = + resolvePlayerFromList(list, "black") || + payload?.players?.black || + payload?.black || + payload?.game?.players?.black || + payload?.game?.black; + const whiteName = white?.name || white?.user?.name || white?.username || "White"; + const blackName = black?.name || black?.user?.name || black?.username || "Black"; + return { white, black, whiteName, blackName }; +} + +function resolveRating(player) { + if (!player) { + return null; + } + return player?.rating || player?.ratingDiff || player?.user?.rating || null; +} + +function resolveClockValue(payload, color) { + const fromList = resolvePlayerFromList(payload?.players || payload?.game?.players, color); + if (Number.isFinite(fromList?.seconds)) { + return fromList.seconds; + } + const direct = payload?.clocks?.[color]; + if (Number.isFinite(direct)) { + return direct; + } + const nested = payload?.game?.clocks?.[color]; + if (Number.isFinite(nested)) { + return nested; + } + return null; +} + +function formatClock(value) { + if (!Number.isFinite(value)) { + return "--:--"; + } + const seconds = value > 1000 ? Math.floor(value / 100) : Math.floor(value); + const minutes = Math.floor(seconds / 60); + const remaining = Math.max(seconds % 60, 0); + return `${minutes}:${String(remaining).padStart(2, "0")}`; +} + +function updateFromEvent(event, state) { + const payload = resolvePayload(event); + const gameId = resolveGameId(payload); + if (gameId && !state.gameId) { + state.gameId = gameId; + } + if (gameId && state.gameId && gameId !== state.gameId) { + state.shouldStop = true; + return; + } + + const fen = payload?.fen || payload?.game?.fen || payload?.position?.fen; + const parsedFen = parseFen(fen); + if (parsedFen) { + state.fen = fen; + state.board = parsedFen.board; + state.turn = parsedFen.turn; + state.boardVisible = true; + } + + const lastMove = payload?.lm || payload?.lastMove || payload?.move || payload?.moves?.split(" ")?.slice(-1)[0]; + if (lastMove) { + state.lastMove = lastMove; + } + + const status = resolveStatus(payload); + if (status) { + state.status = status; + } + + const players = resolvePlayers(payload); + if (players.whiteName || players.blackName) { + state.whiteName = players.whiteName; + state.blackName = players.blackName; + } + + const whiteRating = resolveRating(players.white); + const blackRating = resolveRating(players.black); + if (whiteRating) { + state.whiteRating = whiteRating; + } + if (blackRating) { + state.blackRating = blackRating; + } + + const whiteClock = resolveClockValue(payload, "white"); + const blackClock = resolveClockValue(payload, "black"); + if (whiteClock !== null) { + state.whiteClock = whiteClock; + } + if (blackClock !== null) { + state.blackClock = blackClock; + } + + if (status && !ACTIVE_STATUSES.has(String(status).toLowerCase())) { + state.gameOver = true; + } +} + +async function streamFeed(state) { + state.feedActive = true; + state.error = null; + state.shouldStop = false; + state.gameOver = false; + state.gameId = null; + state.status = "connecting"; + state.lastMove = null; + state.boardVisible = false; + state.fen = null; + state.whiteClock = null; + state.blackClock = null; + + const controller = new AbortController(); + state.abortController = controller; + + try { + const response = await fetch(FEED_URL, { + method: "GET", + headers: { + Accept: "application/x-ndjson", + }, + signal: controller.signal, + }); + if (!response.ok || !response.body) { + throw new Error("Unable to load Lichess TV feed"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + if (state.shouldStop) { + break; + } + const { value, done } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + lines.forEach((line) => { + if (!line.trim()) { + return; + } + try { + const event = JSON.parse(line); + updateFromEvent(event, state); + } catch (_error) { + // Ignore malformed events. + } + }); + + if (state.gameOver) { + state.shouldStop = true; + } + } + } catch (error) { + if (error?.name !== "AbortError") { + state.error = error?.message || String(error); + } + } finally { + if (state.abortController === controller) { + state.abortController = null; + } + state.feedActive = false; + if (state.shouldStop) { + state.boardVisible = false; + state.board = null; + state.fen = null; + state.status = null; + state.gameId = null; + state.lastMove = null; + state.turn = null; + state.gameOver = false; + state.whiteClock = null; + state.blackClock = null; + state.needsClear = true; + } + } +} + +function stopFeed(state) { + state.shouldStop = true; + if (state.abortController) { + state.abortController.abort(); + } + state.needsClear = true; +} + +function processChatCommands(chatMessages, state) { + if (!Array.isArray(chatMessages) || chatMessages.length === 0) { + return; + } + const lastSeen = state.lastChatTimestamp ?? 0; + let latestSeen = lastSeen; + let shouldStart = false; + + chatMessages.forEach((message) => { + const timestamp = message?.timestamp ?? 0; + if (timestamp <= lastSeen) { + return; + } + latestSeen = Math.max(latestSeen, timestamp); + if (!shouldStart && isCommandMatch(message?.message)) { + shouldStart = true; + } + }); + + state.lastChatTimestamp = latestSeen; + + if (shouldStart && !state.feedActive && !state.boardVisible) { + streamFeed(state); + } +} + +function drawBoard(context, state) { + const { ctx, width, height, assets } = context; + const board = state.board; + if (!ctx || !board) { + return; + } + + ctx.clearRect(0, 0, width, height); + const maxBoardSize = Math.min(width, height) * 0.55; + const boardSize = clamp(maxBoardSize, 180, Math.min(width, height)); + if (boardSize <= 0) { + return; + } + + const panelWidth = boardSize + BOARD_MARGIN * 2; + const panelHeight = boardSize + HEADER_HEIGHT + FOOTER_HEIGHT + BOARD_MARGIN; + const panelX = clamp(width - panelWidth - PADDING, PADDING, width - panelWidth); + const panelY = clamp(height - panelHeight - PADDING, PADDING, height - panelHeight); + ctx.fillStyle = PANEL_BG; + drawRoundedRect(ctx, panelX, panelY, panelWidth, panelHeight, BORDER_RADIUS); + ctx.fill(); + ctx.strokeStyle = PANEL_BORDER; + ctx.lineWidth = 2; + ctx.stroke(); + + const headerX = panelX + BOARD_MARGIN; + const headerY = panelY + BOARD_MARGIN * 0.6; + + ctx.fillStyle = TEXT_PRIMARY; + ctx.font = "600 20px 'Inter', 'Segoe UI', sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + + const whiteLabel = state.whiteRating ? `${state.whiteName} (${state.whiteRating})` : state.whiteName || "White"; + const blackLabel = state.blackRating ? `${state.blackName} (${state.blackRating})` : state.blackName || "Black"; + const whiteClock = formatClock(state.whiteClock); + const blackClock = formatClock(state.blackClock); + + ctx.fillText(blackLabel, headerX, headerY); + ctx.textAlign = "right"; + ctx.fillText(blackClock, panelX + panelWidth - BOARD_MARGIN, headerY); + ctx.textAlign = "left"; + + const statusText = state.status ? String(state.status).toUpperCase() : "LIVE"; + ctx.fillStyle = TEXT_MUTED; + ctx.font = "600 13px 'Inter', 'Segoe UI', sans-serif"; + ctx.fillText(statusText, headerX, headerY + 22); + + const boardX = panelX + BOARD_MARGIN; + const boardY = panelY + HEADER_HEIGHT; + const squareSize = boardSize / 8; + + ctx.strokeStyle = BOARD_OUTLINE; + ctx.lineWidth = 3; + ctx.strokeRect(boardX - 1.5, boardY - 1.5, boardSize + 3, boardSize + 3); + + const lastMove = parseUciMove(state.lastMove); + + for (let row = 0; row < 8; row += 1) { + for (let col = 0; col < 8; col += 1) { + const isLight = (row + col) % 2 === 0; + ctx.fillStyle = isLight ? LIGHT_SQUARE : DARK_SQUARE; + ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize); + + if ( + lastMove && + ((lastMove.from.row === row && lastMove.from.col === col) || + (lastMove.to.row === row && lastMove.to.col === col)) + ) { + ctx.fillStyle = "rgba(59, 130, 246, 0.35)"; + ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize); + } + } + } + + for (let row = 0; row < 8; row += 1) { + for (let col = 0; col < 8; col += 1) { + const piece = board[row]?.[col]; + if (!piece) { + continue; + } + const bitmap = loadPieceBitmap(piece, assets, state); + if (!bitmap) { + continue; + } + const size = squareSize * 0.85; + const offset = (squareSize - size) / 2; + ctx.drawImage( + bitmap, + boardX + col * squareSize + offset, + boardY + row * squareSize + offset, + size, + size + ); + } + } + + const turnIndicator = state.turn === "b" ? "Black to move" : "White to move"; + ctx.fillStyle = TEXT_MUTED; + ctx.font = "500 13px 'Inter', 'Segoe UI', sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(whiteLabel, panelX + BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2); + ctx.textAlign = "right"; + ctx.fillText(whiteClock, panelX + panelWidth - BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2); + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.font = "500 12px 'Inter', 'Segoe UI', sans-serif"; + ctx.fillText(turnIndicator, panelX + BOARD_MARGIN, panelY + panelHeight - FOOTER_HEIGHT + 12); +} + +function init() { } + +function tick(context, state) { + const { ctx, width, height, chatMessages } = context; + if (!ctx) { + return; + } + + processChatCommands(chatMessages, state); + + if (state.needsClear) { + ctx.clearRect(0, 0, width, height); + state.needsClear = false; + } + + if (!state.boardVisible || !state.board) { + return; + } + + drawBoard(context, state); + + if (state.shouldStop) { + stopFeed(state); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java index 036f1be..e47a9eb 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java @@ -34,6 +34,7 @@ public class MarketplaceScriptSeedLoader { java.util.Map.entry("jpeg", "image/jpeg"), java.util.Map.entry("gif", "image/gif"), java.util.Map.entry("webp", "image/webp"), + java.util.Map.entry("svg", "image/svg+xml"), java.util.Map.entry("mp4", "video/mp4"), java.util.Map.entry("webm", "video/webm"), java.util.Map.entry("mov", "video/quicktime"), diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java index 493d644..942b558 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java @@ -64,6 +64,7 @@ public final class MediaTypeRegistry { map.put("jpeg", "image/jpeg"); map.put("gif", "image/gif"); map.put("webp", "image/webp"); + map.put("svg", "image/svg+xml"); map.put("bmp", "image/bmp"); map.put("tiff", "image/tiff"); map.put("mp4", "video/mp4"); @@ -89,6 +90,7 @@ public final class MediaTypeRegistry { map.put("image/jpg", ".jpg"); map.put("image/gif", ".gif"); map.put("image/webp", ".webp"); + map.put("image/svg+xml", ".svg"); map.put("image/bmp", ".bmp"); map.put("image/tiff", ".tiff"); map.put("video/mp4", ".mp4");