Files
polylan-submitter/polylan_submitter/src/composables/usePlaceSocket.ts
T

160 lines
5.3 KiB
TypeScript

import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useWebSocket } from '@vueuse/core'
import { placeApiCanvas } from '@/api'
// Mirrors place/wire.py: a one-letter type tag then '|'-separated fields, in
// a fixed order the server always uses. See that file for the message table.
// Everyone reaching this page is already authenticated (the Django view is
// behind @login_required), so unlike pplace's original there's no
// "not logged in" branch to handle here.
const RECONNECT_DELAY_MS = 1000
const DEFAULT_GRID_SIZE = 64
// Mirrors place/models.py's PLACE_COOLDOWN_SECONDS - the server is the
// source of truth for enforcement, this is only used to size the cooldown
// progress bar.
const PLACE_COOLDOWN_SECONDS = 30
export type OnPixel = (x: number, y: number, colorIndex: number) => void
export type OnResize = (width: number, height: number) => void
// Plain (non-reactive) - a several-thousand-cell reactive array is
// pointless overhead. Kept module-level, alongside its dimensions, so the
// grid survives across component remounts. The grid isn't a fixed size -
// an admin can resize it - so it's reallocated whenever the dimensions
// change (see applyGridSize below).
let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE)
const gridWidth = ref(DEFAULT_GRID_SIZE)
const gridHeight = ref(DEFAULT_GRID_SIZE)
export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
const cooldownEndMs = ref<number | null>(null)
const cooldownRemainingMs = ref(0)
let animationFrame: number | null = null
function applyCooldown(nextMs: number | null) {
cooldownEndMs.value = nextMs ?? null
}
function tickCooldown() {
cooldownRemainingMs.value =
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0
animationFrame = requestAnimationFrame(tickCooldown)
}
// Returns true if the size actually changed, so callers can decide
// whether to re-fetch the canvas (the old pixel buffer is meaningless at
// the new dimensions).
function applyGridSize(width: number, height: number): boolean {
if (width === gridWidth.value && height === gridHeight.value) return false
gridWidth.value = width
gridHeight.value = height
grid = new Uint8Array(width * height)
onResize?.(width, height)
return true
}
function setPixel(x: number, y: number, colorIndex: number) {
grid[y * gridWidth.value + x] = colorIndex
onPixel?.(x, y, colorIndex)
}
function handleMessage(_ws: WebSocket, event: MessageEvent<string>) {
const parts = event.data.split('|')
switch (parts[0]) {
case 's': {
const [, next, width, height] = parts
applyCooldown(next !== '' ? Number(next) : null)
applyGridSize(Number(width), Number(height))
break
}
case 'p': {
const [, x, y, c] = parts
setPixel(Number(x), Number(y), Number(c))
break
}
case 'k': {
applyCooldown(Number(parts[1]))
break
}
case 'r': {
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
break
}
case 'e': {
const [, width, height] = parts
// The server already rebuilt its canvas at the new size (see
// place/signals.py) - just re-fetch it rather than guess how to
// remap our local pixels.
if (applyGridSize(Number(width), Number(height))) loadCanvas()
break
}
default:
break
}
}
const wsUrl = computed(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/ws/place/${eventPath}/`
})
const { status, send } = useWebSocket(wsUrl, {
onMessage: handleMessage,
autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS },
})
const connected = computed(() => status.value === 'OPEN')
// Pixels placed while disconnected never reach us: catch up by reloading
// the whole canvas on every reconnect (the first load is done on mount).
let hasConnected = false
watch(connected, (isConnected) => {
if (!isConnected) return
if (hasConnected) loadCanvas()
hasConnected = true
})
async function loadCanvas() {
const response = await placeApiCanvas({ path: { event_path: eventPath } })
if (!response.data) return
const bytes = new Uint8Array(await (response.data as Blob).arrayBuffer())
// 4-byte little-endian header (uint16 width, uint16 height) - see
// place/api.py - then width*height pixel bytes.
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const width = view.getUint16(0, true)
const height = view.getUint16(2, true)
applyGridSize(width, height)
grid.set(bytes.subarray(4))
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
onPixel?.(x, y, grid[y * width + x])
}
}
}
function place(x: number, y: number, colorIndex: number) {
send(`p|${x}|${y}|${colorIndex}`)
}
onMounted(() => {
loadCanvas()
animationFrame = requestAnimationFrame(tickCooldown)
})
onBeforeUnmount(() => {
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
})
return {
connected,
cooldownRemainingMs,
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
gridWidth,
gridHeight,
place,
}
}