Skip to content

Instantly share code, notes, and snippets.

@greggman
Last active January 29, 2026 00:34
Show Gist options
  • Select an option

  • Save greggman/3fae0bad8fe4a2ac71bff5c8761865f4 to your computer and use it in GitHub Desktop.

Select an option

Save greggman/3fae0bad8fe4a2ac71bff5c8761865f4 to your computer and use it in GitHub Desktop.
Canvas2D: video icons
:root {
color-scheme: dark;
}
canvas {
display: block;
}
/*bug-in-github-api-content-can-not-be-empty*/
/*
< = prev
P = play/pause
> = next
S = speed
. = settings
? = list
X = exit
[<][P][>][S][.][?][X]
[<---O--------------]
*/
const clamp01 = v => Math.max(0, Math.min(1, v));
const speeds = '1½⅓¼';
function drawCenteredText(ctx, text, x, y) {
const metrics = ctx.measureText(text);
const drawX = x - (metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight) / 2 + metrics.actualBoundingBoxLeft;
const drawY = y + (metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent) / 2 - metrics.actualBoundingBoxDescent;
ctx.save();
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText(text, drawX, drawY);
ctx.restore();
}
const buttonRenderers = {
prev(ctx) {
ctx.beginPath();
ctx.lineWidth = 0.2;
for (let x = 0; x < 2; ++x) {
ctx.save();
ctx.translate(x * 0.5 - 0.3, 0)
ctx.moveTo( 0.5, 0.7);
ctx.lineTo(-0.3, 0);
ctx.lineTo( 0.5, -0.7);
ctx.restore();
}
ctx.stroke();
},
next(ctx) {
ctx.beginPath();
ctx.lineWidth = 0.2;
for (let x = 0; x < 2; ++x) {
ctx.save();
ctx.translate(x * 0.5 - 0.1, 0)
ctx.moveTo(-0.5, 0.7);
ctx.lineTo( 0.3, 0);
ctx.lineTo(-0.5, -0.7);
ctx.restore();
}
ctx.stroke();
},
play(ctx, { play }) {
if (play) {
ctx.beginPath();
ctx.moveTo(-0.5, 0.8);
ctx.lineTo( 0.7, 0);
ctx.lineTo(-0.5, -0.8);
ctx.fill();
} else {
for (let x = -1; x <= 1; x += 2) {
ctx.save();
ctx.translate(x * 0.3, 0);
ctx.fillRect(-0.15, -0.8, 0.3, 1.6);
ctx.restore();
}
}
},
speed(ctx, { speed }) {
ctx.scale(0.5 / kButtonSize, 0.5 / kButtonSize);
ctx.font = `${kButtonSize * 3}px monospace`;
drawCenteredText(ctx, `${speeds[speed]}×`, 0, 0);
},
settings(ctx) {
ctx.beginPath();
const kSteps = 32;
for (let k = 0; k < kSteps; ++k) {
const i = k / 2 | 0;
const j = (k + 1) / 2 | 0;
const r = j % 2 ? 0.8 : 0.65;
const a = (i * 2) / kSteps * Math.PI * 2;
ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, 0.4, 0, Math.PI * 2);
ctx.fillStyle = kBG;
ctx.fill();
},
list(ctx) {
for (let y = -1; y <= 1; ++y) {
ctx.save();
ctx.translate(0, y * 0.6);
ctx.fillRect(-0.8, -0.15, 1.6, 0.3);
ctx.restore();
}
},
exit(ctx) {
ctx.beginPath();
ctx.arc(0, 0, 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = kBG;;
for (let i = 0; i < 2; ++i) {
ctx.save();
ctx.rotate(Math.PI * (i * 0.5 + 0.25));
ctx.fillRect(-0.4, -0.1, 0.8, 0.2);
ctx.restore();
}
},
};
const kButtonSize = 64;
const kTextHeight = 20;
const kSliderHeight = 32;
const buttons = ['prev','play','next','speed','settings','list','exit'];
const kBG = '#333';
const kHoverBG = '#348'
const kFG = '#888';
const kHoverFG = '#FFF';
const kSliderEdge = 12;
function drawUI(ctx, state) {
ctx.fillStyle = kBG;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
buttons.forEach((button, i) => {
const renderer = buttonRenderers[button] ?? buttonRenderers.prev;
ctx.save();
ctx.translate(i * kButtonSize, 0);
ctx.fillStyle = state.buttons[i] ? kHoverBG : kBG;
ctx.fillRect(0, 0, kButtonSize, kButtonSize);
ctx.translate(0.5 * kButtonSize, kButtonSize * 0.5);
ctx.scale(kButtonSize * 0.5, kButtonSize * 0.5);
const color = state.buttons[i] ? kHoverFG : kFG;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 4 / kButtonSize;
//ctx.strokeRect(-1, -1, 2, 2);
ctx.save();
renderer(ctx, state);
ctx.restore();
ctx.restore();
});
function drawSlider(ctx, width, value0To1) {
const sliderWidth = width - kSliderEdge * 2;
ctx.beginPath();
ctx.lineTo(-sliderWidth * 0.5, 0);
ctx.lineTo(+sliderWidth * 0.5, 0);
ctx.lineCap = 'round';
ctx.lineWidth = 5;
ctx.stroke();
ctx.translate((value0To1 - 0.5) * sliderWidth, 0);
ctx.beginPath();
ctx.arc(0, 0, 10, 0, Math.PI * 2);
ctx.fill();
}
ctx.save()
ctx.fillStyle = '#008';
ctx.translate(0, kButtonSize);
ctx.fillRect(0, 0, ctx.canvas.width, kTextHeight);
ctx.font = `${kTextHeight * 0.8 | 0}px monospace`;
ctx.fillStyle = 'white';
ctx.translate(ctx.canvas.width / 2, kTextHeight * 0.5);
drawCenteredText(ctx, state.name, 0, 1);
ctx.restore();
ctx.save();
ctx.translate(0, kButtonSize + kTextHeight);
ctx.fillStyle = state.slider.hover ? kHoverBG : kBG;
ctx.fillRect(0, 0, ctx.canvas.width, kSliderHeight);
ctx.translate(ctx.canvas.width / 2, kSliderHeight * 0.5);
[ctx.strokeStyle, ctx.fillStyle] = state.slider.hover
? ['#CCC', kHoverFG]
: ['#555', kFG]
drawSlider(ctx, ctx.canvas.width, state.slider.value);
ctx.restore();
}
class UIRenderer {
#ctx/*: CanvasRenderingContext2D */;
constructor() {
const ctx = document.createElement('canvas').getContext('2d');
ctx.canvas.width = kButtonSize * buttons.length;
ctx.canvas.height = kButtonSize + kTextHeight + kSliderHeight;
this.#ctx = ctx;
}
render(state) {
drawUI(this.#ctx, state);
}
get domElement() {
return this.#ctx.canvas;
}
}
const renderer = new UIRenderer();
function getPX(e, elem) {
const rect = elem.getBoundingClientRect();
const cx = (e.clientX - rect.left) / elem.clientWidth * 2 - 1;
const cy = (e.clientY - rect.top) / elem.clientHeight * 2 - 1;
const px = (cx * 0.5 + 0.5) * elem.width;
const py = (cy * 0.5 + 0.5) * elem.height;
return { px, py };
}
function getButton(buttons, px, py) {
for (let b = 0; b < buttons.length; ++b) {
const {x, y, width, height} = buttons[b];
const rx = px - x;
const ry = py - y;
if (rx >= 0 && ry >= 0 && rx < width && ry < height) {
return {
index: b,
x: rx,
y: ry,
};
}
}
return undefined;
}
class PlayerUIlem extends EventTarget {
#renderer/* UIRenderer*/;
#state = {
buttons: new Array(buttons.size).fill(false),
name: 'the-thing-that-did-the-thing.mp4',
slider: {
hover: false,
value: 0.3,
},
play: true,
speed: 0,
};
#uiButtons/*: Rect[]*/
constructor() {
super();
this.#renderer = new UIRenderer();
this.#uiButtons = Array.from(buttons, (v, i) => ({
x: i * kButtonSize,
y: 0,
width: kButtonSize,
height: kButtonSize,
}));
this.#uiButtons.push({
x: 0,
y: kButtonSize + kTextHeight,
width: kButtonSize * buttons.length,
height: kSliderHeight,
});
const elem = this.#renderer.domElement;
const state = this.#state;
const onMove = (e) => {
if (!elem.hasPointerCapture(e.pointerId)) {
//return;
}
const { px, py } = getPX(e, elem);
state.buttons.fill(false);
const b = getButton(this.#uiButtons, px, py);
if (b?.index >= 0 && b?.index < buttons.length) {
state.buttons[b.index] = true;
}
state.slider.hover = false;
if (b?.index === 7) {
state.slider.hover = true;
if (e.buttons & 1) {
const sliderWidth = elem.width - kSliderEdge * 2;
const oldValue = state.slider.value;
state.slider.value = clamp01((b.x - kSliderEdge) / sliderWidth);
if (oldValue !== state.slider.value) {
this.dispatchEvent(new CustomEvent('position', {
detail: {
value: state.slider.value,
},
}));
}
}
}
this.#renderer.render(state);
};
const onCancel = (e) => {
elem.releasePointerCapture(e.pointerId);
};
const onUp = (e) => {
const { px, py } = getPX(e, elem);
const b = getButton(this.#uiButtons, px, py);
if (b && b.index >= 0 && b.index < buttons.length) {
this.dispatchEvent(new CustomEvent(buttons[b.index]));
}
onCancel(e);
};
const onDown = (e) => {
elem.setPointerCapture(e.pointerId);
};
elem.addEventListener('pointerup', onUp);
elem.addEventListener('pointercancel', onCancel);
elem.addEventListener('lostpointercapture', onCancel);
elem.addEventListener('pointerdown', onDown);
elem.addEventListener('pointermove', onMove);
}
setPlay(play) {
this.#state.play = play;
this.#renderer.render(this.#state);
}
setSpeed(speed) {
this.#state.speed = speed;
this.#renderer.render(this.#state);
}
get domElement() {
return this.#renderer.domElement;
}
}
// 1, 1/2, 1/3, 1/4
const state = {
play: true, // true = show play
speed: 0,
}
const uiElem = new PlayerUIlem();
document.body.append(uiElem.domElement);
for (const button of buttons) {
uiElem.addEventListener(button, e => console.log(e.type))
}
uiElem.addEventListener('play', e => {
state.play = !state.play;
uiElem.setPlay(state.play);
});
uiElem.addEventListener('speed', e => {
state.speed = (state.speed + 1) % 4;
uiElem.setSpeed(state.speed);
});
uiElem.addEventListener('position', e => console.log('pos:', e.detail.value));
class ListRenderer {
}
class ListElem {
constructor() {
}
}
{"name":"Canvas2D: video icons","settings":{},"filenames":["index.html","index.css","index.js"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment